├── README.md ├── iptables-ipset.sh ├── iptables-recent.sh ├── stun-packet.png └── udp-knock-demo.html /README.md: -------------------------------------------------------------------------------- 1 | # 传统的端口敲门 2 | 3 | [端口敲门](https://en.wikipedia.org/wiki/Port_knocking) 是一种特殊的安全认证方案。它没有固定的标准,每个人的实现各不相同。当然,即使没听说过这个名词,不少人也有类似的想法和实现。 4 | 5 | 例如我曾经使用 Windows 服务器时,一直对远程桌面颇为不满。不是因为这个服务做得不好,相反,是做得太好了,以至于一个不知道账号密码的陌生人试着访问时,都会为它绘制窗口、传输画面,服务太过周到了!攻击者即使猜不到密码,也能白白消耗服务器资源和网络流量。况且,越复杂的程序出现漏洞的可能性越大,万一哪天出现缓冲区溢出之类的漏洞,攻击者不用知道密码也能控制服务器。这太不完美了! 6 | 7 | 在我看来,认证应该越早越好。对于不知道认证规则的陌生人,甚至 TCP 连接都不值得为它建立!这即利于 **系统安全**(减少密码爆破、漏洞攻击等风险),又利于 **网络安全**(减少恶意消耗流量、DDoS 攻击等风险)。 8 | 9 | 于是写了个简单的防火墙小程序,默认拦截所有 IP。只有当某个 IP 访问了「秘密端口」后,才将其加入白名单,允许进一步通信。这就是一个典型的端口敲门方案,那个访问秘密端口的数据包,便是敲门砖。 10 | 11 | ---- 12 | 13 | 当然,仅凭一个端口作为暗号还是有些简陋,很容易被破解,因此需加强。例如使用多个端口、使用特定数据的 UDP 包等等。甚至还可以在数据包中加入更复杂的认证信息,当然这需要通过专门的程序敲门了,而不能简单地使用系统命令手动敲门。 14 | 15 | 事实上用程序敲门是很常见的方案。例如一些公司要求员工安装某个程序才能访问内网,该程序很可能给网关发送了敲门数据包。 16 | 17 | 那么,敲门程序是否适用于公网?更进一步,是否能做成 Web 版? 18 | 19 | 20 | # 公网 Web 敲门 21 | 22 | 敲门程序放在公网似乎不太好,毕竟这是一种基于保密的安全方案,细节公开后效果就大打折扣了,除非对程序做很强的混淆保护。 23 | 24 | 前面提到,敲门有两个意义:保护系统安全、保护网络安全。现在聊聊后者。 25 | 26 | 接着从之前做防火墙聊起。虽然那只是个玩具级的小程序,但实际效果还不错,并且性能极高(驱动层过滤包)。分享给一些好友试用后,很快就有人思考如何应用到现实中 —— 那些经常被攻击的游戏服务器。 27 | 28 | 但这并不好实现。总不能要求玩家复制粘贴一堆 cmd 命令手动敲门吧。让玩家下载敲门程序?这相当于是在推广软件,成本不小。除非把敲门程序和登录器捆绑在一起,但这款游戏被魔改的五花八门,登录器各不相同,实现起来很麻烦。 29 | 30 | 一番摸索后发现,几乎所有的登录器都内嵌网页,显示公告信息之类的。如果实现一个 JS 版的敲门程序,那么直接让管理员插到公告页里就可以,连登录器都不用升级! 31 | 32 | 33 | ## TCP/SYN 直接敲门 34 | 35 | ### v1 单端口 36 | 37 | 第一个实验版本非常简单,甚至都没用上 JS,仅仅敲服务器一个固定端口。 38 | 39 | ```html 40 | 41 | ``` 42 | 43 | 为了隐蔽,URL 里没有暴露服务器 IP,而是用一个指向该 IP 的域名替代,并加上迷惑性路径。 44 | 45 | > 事实上服务器会丢弃 SYN 敲门包,所以连接都无法建立,URL 路径是毫无用途的 46 | 47 | 试用后效果很好。伪造游戏协议的捣乱机器人 TCP 都建立不了,更别说登录了;即使用 SYN Flood 攻击游戏端口,服务器也是一个 ACK 都不回复,极大节省了系统资源和流量开销(出站流量很贵)。通过这个简单粗暴的敲门方案,L7 和 L4 攻击都能轻松防住。当然,除非特别大的流量直接把服务器 IP 打进黑洞,那倒是无解,不过软件形式的防火墙对此都无能为力。 48 | 49 | 尽管敲门机关是公开的,但大部分人都不会想到藏在网页里~ 50 | 51 | 当然,被发现也是早晚的事。在被破解之前,这个方案改良了多次。 52 | 53 | ### v2 多端口 54 | 55 | 敲门端口只有一个,感觉太简陋。但即使有多个,攻击者只要扫描下所有端口,总能敲到这几个。 56 | 57 | 因此,多端口敲门必须要按顺序,否则意义就不大了。 58 | 59 | 不过实现后发现有个问题,服务器收到的 SYN 包顺序,未必就是客户端发送时的顺序。因此只能通过延迟发送的方式,尽量保证顺序。 60 | 61 | ```js 62 | function load(url) { 63 | new Image().src = url 64 | } 65 | 66 | load('http://xxx.xxx:50000') 67 | 68 | setTimeout(function() { 69 | load('http://xxx.xxx:10000') 70 | }, 100) 71 | 72 | setTimeout(function() { 73 | load('http://xxx.xxx:30000') 74 | }, 200) 75 | ``` 76 | 77 | 当然,这么做的后果就是增加了敲门时间。 78 | 79 | > 这里用 `onerror` 事件是没有意义的。因为服务器会丢弃 SYN 包,所以客户端需要重发多次 SYN 才会产生 TCP 连接超时错误,可能要几秒甚至几十秒之后才会触发该事件。 80 | 81 | 82 | ### v3 动态多端口 83 | 84 | 多端口实现了,但是端口号仍是固定的,感觉仍不完美。于是接着改进。 85 | 86 | 如果端口号是动态的,那么前后端如何保持一致?最容易想到的,就是通过时间生成端口号。 87 | 88 | ```js 89 | var port = gen_port(time()) 90 | load('http://xxx.xxx:' + port) 91 | ``` 92 | 93 | 但这也存在一个问题,并非所有用户的时间都是准的。如果误差超出允许范围,那么敲门就会失效。 94 | 95 | 因此,敲门之前需校准时间。我们提供一个返回时间的接口,或者直接用网上免费公开的接口。为了确保稳定性,JS 里准备多个接口,只要有一个可用就不会失效。 96 | 97 | 当然,动态端口也有一些问题。例如配有硬件防火墙的服务器可能没法使用,除非服务器能和防火墙设备保持互动。(如今云服务器的云防火墙确实可通过 API 实时修改规则~) 98 | 99 | 100 | ## UDP/DNS 直接敲门 101 | 102 | 事实上,前面几个改良的意义都不大,都是建立在攻击者还没发现的基础上。然而秘密总是会暴露的。 103 | 104 | 如何继续改进?思考了下问题所在:只发几个 SYN 包就能通过防火墙验证的话,攻击者太容易模拟,伪造成本太低。但如果敲门包里能容纳更多信息,那就可以带上 JS 生成的认证数据,这样伪造起来麻烦多了。 105 | 106 | 但 TCP 显然不行,毕竟端口号才 16bit,可携带的信息太少了,需要发送几十上百个请求才能勉强传递认证数据,效率太低。因此只能用 UDP。 107 | 108 | 在 Web 中和 UDP 相关的通信,最容易想到的是 DNS。并且可通过 NS 型泛域名,一次携带可观的数据到指定服务器。 109 | 110 | ```js 111 | var auth = gen_auth() 112 | load('http://' + encode(auth) + '.xxx.xxx.xxx') 113 | ``` 114 | 115 | 不过这个方案存在本质性问题:服务器收到的 UDP 包,并不是用户发给它的,而是用户运营商 DNS 发的。所以服务器看到的源 IP 是运营商 DNS IP,而不是用户 IP!既然连用户 IP 都拿不到,那还有什么用。 116 | 117 | 当时想到一个有趣的解决方案:JS 先通过一些免费公开的接口获取公网 IP,然后将其加密到认证信息里;服务器最终使用认证信息里的 IP,而不是数据包的 IP。这样就能拿到用户 IP 了! 118 | 119 | ```js 120 | jsonp_public_ip_callback = function(ip) { 121 | var auth = gen_auth(ip, ...) 122 | load('http://' + encode(auth) + '.xxx.xxx.xxx') 123 | } 124 | 125 | load_js('http://xxx.com/get_public_ip') 126 | ``` 127 | 128 | 当然,这个方案严重依赖 JS 代码混淆。一旦加密算法被破解,攻击者可以轻轻松松将任何 IP 刷进防火墙白名单。 129 | 130 | > 其实 SYN 敲门方案,攻击者也能伪造源 IP,给任意 IP 加白,但需要特殊的网络环境才能发送。而 UDP/DNS 这种方案任何网络环境都可以 131 | 132 | 133 | ## UDP/DNS 中转敲门 134 | 135 | 使用 UDP/DNS 敲门还有一个问题:服务器需要开放 UDP:53 端口,但有些网络可能不允许。 136 | 137 | 这种情况只能通过中转。NS 域名指向我们的服务器,我们分析 UDP 数据后再将用户 IP 推送给游戏服务器(游戏服务器和我们保持长连接)。这样就不用关心网络问题了。 138 | 139 | 140 | ## TCP/HTTP 中转敲门 141 | 142 | 基于 UDP/DNS 的敲门仍存在不少问题。JS 需要先获取公网 IP 地址,UDP 数据需要经过 DNS 转发,这些额外的链路大幅增加了敲门时间和故障几率。 143 | 144 | 因此最终还是回归到稳定的 HTTP 中转方案。这个方案原理很简单,没什么骚操作。JS 将认证信息提交到我们的 HTTP 服务器,我们验证后再推送给游戏服务器。由于 HTTP 可携带的信息量没有限制,因此除了基本验证信息外,还可采集浏览器环境、Flash 环境、用户行为等大量信息,进一步加强安全防御。 145 | 146 | 不过,中心化的验证服务显然会成为众矢之的。攻击者只要打垮这个服务就可以,都用不着攻击游戏服务器~ 147 | 148 | 但相比传统 C/S 架构的网游服务,B/S 架构的 Web 服务更容易防御,并且有很多现成的解决方案,例如 CDN、WAF 等。因此 **借助相对稳定的 Web 服务给更脆弱的游戏服务敲门**,还是值得的。 149 | 150 | 这个方案一直用到项目结束。遗憾的是没赶上 HTML5 时代,不然还可以用上更多黑科技。 151 | 152 | 153 | ## UDP/STUN 直接敲门 154 | 155 | 虽然之后没再研究防火墙,但对 Web 仍保持关注,每当有新的 API 出现时都会琢磨一番,尤其和网络相关的,总会联想到敲门服务。 156 | 157 | 当然,基于 TCP 的直接忽略。无论功能多丰富,都绕不过操作系统的握手连接。所以像 WebSocket 这种都不用考虑。(更何况它还是基于 HTTP 的) 158 | 159 | 160 | ### UDP 端口敲门 161 | 162 | WebRTC 的出现,终于能让 JS 发送 UDP 包了,并能指定 IP 和端口。 163 | 164 | ```js 165 | var pc = new RTCPeerConnection({ 166 | iceServers: [ 167 | {urls: 'stun:1.2.3.4:56789'} 168 | ] 169 | }) 170 | pc.createDataChannel('') 171 | 172 | pc.createOffer(function(v) { 173 | pc.setLocalDescription(v) 174 | }, function() {}) 175 | ``` 176 | 177 | 但是,如果只能指定端口而不能携带数据,那和 SYN 敲门包有什么区别? 178 | 179 | 根据 [API 文档](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer/username),iceServers 参数可配置用户名和密码。假如用户名能出现在 UDP 包里,那就能携带自定义数据了。 180 | 181 | 不过之前多次试验都未成功(也许是还没发现),于是换了种思路。 182 | 183 | 184 | ### UDP 数据敲门 185 | 186 | WebRTC 的本质是 P2P 通信,让两个内网用户直连。大致步骤是: 187 | 188 | 1.每个用户通过 STUN 服务,获取自己的公网 IP 及打洞端口 189 | 190 | 2.通过某个服务器交换各自信息 191 | 192 | 3.两个用户互相通信 193 | 194 | 前面的代码只是第 1 步,访问 1.2.3.4:56789 只是查询公网地址而已。对于端口敲门来说,这一步并不关键,假如对方地址已知,不妨跳过 1 和 2,直接从第 3 步开始。 195 | 196 | 我们可以虚构一份 SDP(Session Description Protocol)欺骗 WebRTC,假装已知对方信息。然后把对方 IP 和端口加入 Ice Candidate,浏览器即可发出 UDP 包! 197 | 198 | 与第一步不同,这一步可设置 `ufrag` 字段,并且最终会明文出现在 UDP 包中! 199 | 200 | ```js 201 | sendUDP('1.2.3.4', 56789, 'Hello_World_1234567890') 202 | 203 | async function sendUDP(addr, port, data) { 204 | const pc = new RTCPeerConnection() 205 | 206 | const sd = new RTCSessionDescription({ 207 | type: 'offer', 208 | sdp: `\ 209 | v=0 210 | o=- 1234567890 2 IN IP4 127.0.0.1 211 | s=- 212 | t=0 0 213 | a=group:BUNDLE data 214 | m=application 9 UDP/DTLS/SCTP webrtc-datachannel 215 | c=IN IP4 0.0.0.0 216 | a=ice-ufrag:${data} 217 | a=ice-pwd:0000000000000000000000 218 | a=ice-options:trickle 219 | a=fingerprint:sha-256 00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00:00 220 | a=setup:actpass 221 | a=mid:data 222 | a=sctp-port:5000 223 | a=max-message-size:262144 224 | ` 225 | }) 226 | await pc.setRemoteDescription(sd) 227 | 228 | const answer = await pc.createAnswer() 229 | const desc = new RTCSessionDescription(answer) 230 | await pc.setLocalDescription(desc) 231 | 232 | pc.addIceCandidate({ 233 | candidate: `candidate:842163049 1 udp 1677729535 ${addr} ${port} typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag ${data} network-cost 999`, 234 | sdpMLineIndex: 0, 235 | sdpMid: 'data', 236 | }) 237 | 238 | setTimeout(_ => pc.close(), 30) 239 | } 240 | ``` 241 | 242 | ![stun.png](stun-packet.png) 243 | 244 | 该字段可接受字母、数字和 `#+-/=_` 共 68 种字符,最大长度 256。即使用 Base64 编码,也能容纳 192 字节。 245 | 246 | 虽然容量不算大,但编码紧凑点的话,还是可以采集不少端上信息。况且 JS 和服务器直接通信,无需经过第三方中转,链路更短! 247 | 248 | ---- 249 | 250 | 当然,基于 UDP/STUN 的敲门方案也有不少缺陷。有些运营商会降低 UDP 优先级,甚至使用不同的公网 IP!而且低版本浏览器并不支持 WebRTC,高版本浏览器的用户也可能禁用了 WebRTC。 251 | 252 | 此外,浏览器开启 HTTP/SOCKS 代理后,敲门也可能会失效。因为 UDP 不走代理,而之后的 TCP 访问走代理,两者 IP 不一致,显然无法通过验证。 253 | 254 | 255 | ### 在线演示 256 | 257 | https://www.etherdream.com/port-knocking/ 258 | 259 | 只有点了 Knock 按钮,你才能访问 Test 页面,否则和测试服务器的 TCP 连接都无法建立。可以抓包试试~ 260 | 261 | 出于演示,敲门服务直接用 iptables 实现: 262 | 263 | ```bash 264 | # 放行白名单 IP(600s 后需重新敲门) 265 | iptables \ 266 | -t raw --append PREROUTING \ 267 | -m recent --name knocked --rcheck --seconds 600 \ 268 | -j ACCEPT 269 | 270 | # 敲门包 271 | iptables \ 272 | -t raw --append PREROUTING \ 273 | -p udp --dport 30000 \ 274 | -m string --string "OpenSesame" --algo bm \ 275 | -m recent --name knocked --set \ 276 | -j DROP 277 | 278 | # 默认拒绝所有 IP 279 | iptables \ 280 | -t raw --append PREROUTING \ 281 | -j DROP 282 | 283 | # 实时查看白名单 IP 284 | watch -n1 \ 285 | cat /proc/net/xt_recent/knocked 286 | ``` 287 | 288 | > 除了 recent 模块,还可使用更灵活的 ipset 模块 289 | 290 | 当然这里没有解析数据,仅仅判断 UDP 包是携带某个暗号(`OpenSesame`)。 291 | 292 | 实际应用中,你可以自由发挥想象,带上更多有意义的认证信息,以及更完善的加密。 293 | 294 | 295 | ### 更多认证信息 296 | 297 | HTML5 的发展使得越来越多有趣的 API 加入到 Web 中。例如 WebGL、WebAssembly、Web Crypto API 等等,这些 API 都可参与到安全防御中。 298 | 299 | 例如通过 [WebGL2 调用 GPU 实现工作量证明](https://www.etherdream.com/FunnyScript/glminer/glminer.html),使得攻击者生成敲门认证数据需要大量算力,而无法大批量生成。 300 | 301 | 例如通过 WebAssembly 混淆敲门数据的生成逻辑,使得 DDoS 攻击者还需掌握二进制逆向能力。 302 | 303 | 如果攻击者需要执行复杂耗时的 JS 才能让一个 IP 进入防火墙白名单,那么我们的目的也就达到了。 304 | 305 | 306 | ## 更多敲门方案 307 | 308 | Web 仍在不断发展,例如 Chrome 的 QUIC 协议也使用 UDP 通信,尽管目前还无法用于敲门场合。未来 HTTP/3 的出现,是否会有更多的改进,开放更强大的网络通信能力呢。拭目以待中... 309 | -------------------------------------------------------------------------------- /iptables-ipset.sh: -------------------------------------------------------------------------------- 1 | # iptables -t raw -F 2 | 3 | ipset create knocked hash:ip \ 4 | timeout 120 \ 5 | maxelem 2000 6 | 7 | iptables \ 8 | -t raw --append PREROUTING \ 9 | -m set --match-set knocked src \ 10 | -j ACCEPT 11 | 12 | iptables \ 13 | -t raw --append PREROUTING \ 14 | -p udp --dport 30000 \ 15 | -m string --string "OpenSesame" --algo bm \ 16 | -j SET --add-set knocked src --exist 17 | 18 | iptables \ 19 | -t raw --append PREROUTING \ 20 | -j DROP 21 | 22 | 23 | # watch -n1 ipset list knocked 24 | # watch -n1 iptables -t raw -nvL 25 | -------------------------------------------------------------------------------- /iptables-recent.sh: -------------------------------------------------------------------------------- 1 | # iptables -F -t raw 2 | 3 | iptables \ 4 | -t raw --append PREROUTING \ 5 | -m recent --name knocked --rcheck --seconds 600 \ 6 | -j ACCEPT 7 | 8 | iptables \ 9 | -t raw --append PREROUTING \ 10 | -p udp --dport 30000 \ 11 | -m string --string "OpenSesame" --algo bm \ 12 | -m recent --name knocked --set \ 13 | -j DROP 14 | 15 | iptables \ 16 | -t raw --append PREROUTING \ 17 | -j DROP 18 | 19 | # cat /proc/net/xt_recent/knocked 20 | -------------------------------------------------------------------------------- /stun-packet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EtherDream/js-port-knocking/964bef5588ab5fc30bfeee448c412de42e73b23c/stun-packet.png -------------------------------------------------------------------------------- /udp-knock-demo.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JS Port Knocking 7 | 12 | 13 | 14 |

UDP Port Knocking

15 |
16 | Host: 17 |
18 |
19 | Port: 20 |
21 |
22 | Data: 23 |
24 |
25 | 26 |
27 |
28 |

Test:

29 |
30 | 31 | http://146.56.248.195:8080/hello 32 | 33 |
34 |

Server Policy:

35 |
 36 | iptables \
 37 |   -i eth0 \
 38 |   -t raw --append PREROUTING \
 39 |   -p tcp --dport 8080 --syn \
 40 |   -m recent --name knocked ! --rcheck --seconds 600 \
 41 |   -j DROP
 42 | 
 43 | iptables \
 44 |   -i eth0 \
 45 |   -t raw --append PREROUTING \
 46 |   -p udp --dport 30000 \
 47 |   -m string --string "OpenSesame" --algo bm \
 48 |   -m recent --name knocked --set \
 49 |   -j DROP
 50 |   
51 | 103 | 104 | 105 | --------------------------------------------------------------------------------