{"id":19430192,"url":"https://github.com/etherdream/js-port-knocking","last_synced_at":"2025-05-08T21:25:36.033Z","repository":{"id":46605158,"uuid":"293714353","full_name":"EtherDream/js-port-knocking","owner":"EtherDream","description":"Web 端口敲门的奇思妙想","archived":false,"fork":false,"pushed_at":"2020-10-22T01:37:08.000Z","size":573,"stargazers_count":267,"open_issues_count":1,"forks_count":28,"subscribers_count":6,"default_branch":"master","last_synced_at":"2025-02-02T03:54:00.710Z","etag":null,"topics":["ddos-mitigation","javascript","portknocking","security"],"latest_commit_sha":null,"homepage":"","language":"HTML","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/EtherDream.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-09-08T05:54:58.000Z","updated_at":"2025-01-20T03:34:02.000Z","dependencies_parsed_at":"2022-09-08T08:22:08.028Z","dependency_job_id":null,"html_url":"https://github.com/EtherDream/js-port-knocking","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/EtherDream%2Fjs-port-knocking","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/EtherDream%2Fjs-port-knocking/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/EtherDream%2Fjs-port-knocking/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/EtherDream%2Fjs-port-knocking/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/EtherDream","download_url":"https://codeload.github.com/EtherDream/js-port-knocking/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":238044095,"owners_count":19407128,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ddos-mitigation","javascript","portknocking","security"],"created_at":"2024-11-10T14:23:54.316Z","updated_at":"2025-02-10T02:09:49.310Z","avatar_url":"https://github.com/EtherDream.png","language":"HTML","funding_links":[],"categories":[],"sub_categories":[],"readme":"# 传统的端口敲门\n\n[端口敲门](https://en.wikipedia.org/wiki/Port_knocking) 是一种特殊的安全认证方案。它没有固定的标准，每个人的实现各不相同。当然，即使没听说过这个名词，不少人也有类似的想法和实现。\n\n例如我曾经使用 Windows 服务器时，一直对远程桌面颇为不满。不是因为这个服务做得不好，相反，是做得太好了，以至于一个不知道账号密码的陌生人试着访问时，都会为它绘制窗口、传输画面，服务太过周到了！攻击者即使猜不到密码，也能白白消耗服务器资源和网络流量。况且，越复杂的程序出现漏洞的可能性越大，万一哪天出现缓冲区溢出之类的漏洞，攻击者不用知道密码也能控制服务器。这太不完美了！\n\n在我看来，认证应该越早越好。对于不知道认证规则的陌生人，甚至 TCP 连接都不值得为它建立！这即利于 **系统安全**（减少密码爆破、漏洞攻击等风险），又利于 **网络安全**（减少恶意消耗流量、DDoS 攻击等风险）。\n\n于是写了个简单的防火墙小程序，默认拦截所有 IP。只有当某个 IP 访问了「秘密端口」后，才将其加入白名单，允许进一步通信。这就是一个典型的端口敲门方案，那个访问秘密端口的数据包，便是敲门砖。\n\n----\n\n当然，仅凭一个端口作为暗号还是有些简陋，很容易被破解，因此需加强。例如使用多个端口、使用特定数据的 UDP 包等等。甚至还可以在数据包中加入更复杂的认证信息，当然这需要通过专门的程序敲门了，而不能简单地使用系统命令手动敲门。\n\n事实上用程序敲门是很常见的方案。例如一些公司要求员工安装某个程序才能访问内网，该程序很可能给网关发送了敲门数据包。\n\n那么，敲门程序是否适用于公网？更进一步，是否能做成 Web 版？\n\n\n# 公网 Web 敲门\n\n敲门程序放在公网似乎不太好，毕竟这是一种基于保密的安全方案，细节公开后效果就大打折扣了，除非对程序做很强的混淆保护。\n\n前面提到，敲门有两个意义：保护系统安全、保护网络安全。现在聊聊后者。\n\n接着从之前做防火墙聊起。虽然那只是个玩具级的小程序，但实际效果还不错，并且性能极高（驱动层过滤包）。分享给一些好友试用后，很快就有人思考如何应用到现实中 —— 那些经常被攻击的游戏服务器。\n\n但这并不好实现。总不能要求玩家复制粘贴一堆 cmd 命令手动敲门吧。让玩家下载敲门程序？这相当于是在推广软件，成本不小。除非把敲门程序和登录器捆绑在一起，但这款游戏被魔改的五花八门，登录器各不相同，实现起来很麻烦。\n\n一番摸索后发现，几乎所有的登录器都内嵌网页，显示公告信息之类的。如果实现一个 JS 版的敲门程序，那么直接让管理员插到公告页里就可以，连登录器都不用升级！\n\n\n## TCP/SYN 直接敲门\n\n### v1 单端口\n\n第一个实验版本非常简单，甚至都没用上 JS，仅仅敲服务器一个固定端口。\n\n```html\n\u003cimg src=\"http://xxx.xxx:12345/logo.gif\"\u003e\n```\n\n为了隐蔽，URL 里没有暴露服务器 IP，而是用一个指向该 IP 的域名替代，并加上迷惑性路径。\n\n\u003e 事实上服务器会丢弃 SYN 敲门包，所以连接都无法建立，URL 路径是毫无用途的\n\n试用后效果很好。伪造游戏协议的捣乱机器人 TCP 都建立不了，更别说登录了；即使用 SYN Flood 攻击游戏端口，服务器也是一个 ACK 都不回复，极大节省了系统资源和流量开销（出站流量很贵）。通过这个简单粗暴的敲门方案，L7 和 L4 攻击都能轻松防住。当然，除非特别大的流量直接把服务器 IP 打进黑洞，那倒是无解，不过软件形式的防火墙对此都无能为力。\n\n尽管敲门机关是公开的，但大部分人都不会想到藏在网页里~\n\n当然，被发现也是早晚的事。在被破解之前，这个方案改良了多次。\n\n### v2 多端口\n\n敲门端口只有一个，感觉太简陋。但即使有多个，攻击者只要扫描下所有端口，总能敲到这几个。\n\n因此，多端口敲门必须要按顺序，否则意义就不大了。\n\n不过实现后发现有个问题，服务器收到的 SYN 包顺序，未必就是客户端发送时的顺序。因此只能通过延迟发送的方式，尽量保证顺序。\n\n```js\nfunction load(url) {\n  new Image().src = url\n}\n\nload('http://xxx.xxx:50000')\n\nsetTimeout(function() {\n  load('http://xxx.xxx:10000')\n}, 100)\n\nsetTimeout(function() {\n  load('http://xxx.xxx:30000')\n}, 200)\n```\n\n当然，这么做的后果就是增加了敲门时间。\n\n\u003e 这里用 `onerror` 事件是没有意义的。因为服务器会丢弃 SYN 包，所以客户端需要重发多次 SYN 才会产生 TCP 连接超时错误，可能要几秒甚至几十秒之后才会触发该事件。\n\n\n### v3 动态多端口\n\n多端口实现了，但是端口号仍是固定的，感觉仍不完美。于是接着改进。\n\n如果端口号是动态的，那么前后端如何保持一致？最容易想到的，就是通过时间生成端口号。\n\n```js\nvar port = gen_port(time())\nload('http://xxx.xxx:' + port)\n```\n\n但这也存在一个问题，并非所有用户的时间都是准的。如果误差超出允许范围，那么敲门就会失效。\n\n因此，敲门之前需校准时间。我们提供一个返回时间的接口，或者直接用网上免费公开的接口。为了确保稳定性，JS 里准备多个接口，只要有一个可用就不会失效。\n\n当然，动态端口也有一些问题。例如配有硬件防火墙的服务器可能没法使用，除非服务器能和防火墙设备保持互动。（如今云服务器的云防火墙确实可通过 API 实时修改规则~）\n\n\n## UDP/DNS 直接敲门\n\n事实上，前面几个改良的意义都不大，都是建立在攻击者还没发现的基础上。然而秘密总是会暴露的。\n\n如何继续改进？思考了下问题所在：只发几个 SYN 包就能通过防火墙验证的话，攻击者太容易模拟，伪造成本太低。但如果敲门包里能容纳更多信息，那就可以带上 JS 生成的认证数据，这样伪造起来麻烦多了。\n\n但 TCP 显然不行，毕竟端口号才 16bit，可携带的信息太少了，需要发送几十上百个请求才能勉强传递认证数据，效率太低。因此只能用 UDP。\n\n在 Web 中和 UDP 相关的通信，最容易想到的是 DNS。并且可通过 NS 型泛域名，一次携带可观的数据到指定服务器。\n\n```js\nvar auth = gen_auth()\nload('http://' + encode(auth) + '.xxx.xxx.xxx')\n```\n\n不过这个方案存在本质性问题：服务器收到的 UDP 包，并不是用户发给它的，而是用户运营商 DNS 发的。所以服务器看到的源 IP 是运营商 DNS IP，而不是用户 IP！既然连用户 IP 都拿不到，那还有什么用。\n\n当时想到一个有趣的解决方案：JS 先通过一些免费公开的接口获取公网 IP，然后将其加密到认证信息里；服务器最终使用认证信息里的 IP，而不是数据包的 IP。这样就能拿到用户 IP 了！\n\n```js\njsonp_public_ip_callback = function(ip) {\n  var auth = gen_auth(ip, ...)\n  load('http://' + encode(auth) + '.xxx.xxx.xxx')\n}\n\nload_js('http://xxx.com/get_public_ip')\n```\n\n当然，这个方案严重依赖 JS 代码混淆。一旦加密算法被破解，攻击者可以轻轻松松将任何 IP 刷进防火墙白名单。\n\n\u003e 其实 SYN 敲门方案，攻击者也能伪造源 IP，给任意 IP 加白，但需要特殊的网络环境才能发送。而 UDP/DNS 这种方案任何网络环境都可以\n\n\n## UDP/DNS 中转敲门\n\n使用 UDP/DNS 敲门还有一个问题：服务器需要开放 UDP:53 端口，但有些网络可能不允许。\n\n这种情况只能通过中转。NS 域名指向我们的服务器，我们分析 UDP 数据后再将用户 IP 推送给游戏服务器（游戏服务器和我们保持长连接）。这样就不用关心网络问题了。\n\n\n## TCP/HTTP 中转敲门\n\n基于 UDP/DNS 的敲门仍存在不少问题。JS 需要先获取公网 IP 地址，UDP 数据需要经过 DNS 转发，这些额外的链路大幅增加了敲门时间和故障几率。\n\n因此最终还是回归到稳定的 HTTP 中转方案。这个方案原理很简单，没什么骚操作。JS 将认证信息提交到我们的 HTTP 服务器，我们验证后再推送给游戏服务器。由于 HTTP 可携带的信息量没有限制，因此除了基本验证信息外，还可采集浏览器环境、Flash 环境、用户行为等大量信息，进一步加强安全防御。\n\n不过，中心化的验证服务显然会成为众矢之的。攻击者只要打垮这个服务就可以，都用不着攻击游戏服务器~\n\n但相比传统 C/S 架构的网游服务，B/S 架构的 Web 服务更容易防御，并且有很多现成的解决方案，例如 CDN、WAF 等。因此 **借助相对稳定的 Web 服务给更脆弱的游戏服务敲门**，还是值得的。\n\n这个方案一直用到项目结束。遗憾的是没赶上 HTML5 时代，不然还可以用上更多黑科技。\n\n\n## UDP/STUN 直接敲门\n\n虽然之后没再研究防火墙，但对 Web 仍保持关注，每当有新的 API 出现时都会琢磨一番，尤其和网络相关的，总会联想到敲门服务。\n\n当然，基于 TCP 的直接忽略。无论功能多丰富，都绕不过操作系统的握手连接。所以像 WebSocket 这种都不用考虑。（更何况它还是基于 HTTP 的）\n\n\n### UDP 端口敲门\n\nWebRTC 的出现，终于能让 JS 发送 UDP 包了，并能指定 IP 和端口。\n\n```js\nvar pc = new RTCPeerConnection({\n  iceServers: [\n    {urls: 'stun:1.2.3.4:56789'}\n  ]\n})\npc.createDataChannel('')\n\npc.createOffer(function(v) {\n  pc.setLocalDescription(v)\n}, function() {})\n```\n\n但是，如果只能指定端口而不能携带数据，那和 SYN 敲门包有什么区别？\n\n根据 [API 文档](https://developer.mozilla.org/en-US/docs/Web/API/RTCIceServer/username)，iceServers 参数可配置用户名和密码。假如用户名能出现在 UDP 包里，那就能携带自定义数据了。\n\n不过之前多次试验都未成功（也许是还没发现），于是换了种思路。\n\n\n### UDP 数据敲门\n\nWebRTC 的本质是 P2P 通信，让两个内网用户直连。大致步骤是：\n\n1.每个用户通过 STUN 服务，获取自己的公网 IP 及打洞端口\n\n2.通过某个服务器交换各自信息\n\n3.两个用户互相通信\n\n前面的代码只是第 1 步，访问 1.2.3.4:56789 只是查询公网地址而已。对于端口敲门来说，这一步并不关键，假如对方地址已知，不妨跳过 1 和 2，直接从第 3 步开始。\n\n我们可以虚构一份 SDP（Session Description Protocol）欺骗 WebRTC，假装已知对方信息。然后把对方 IP 和端口加入 Ice Candidate，浏览器即可发出 UDP 包！\n\n与第一步不同，这一步可设置 `ufrag` 字段，并且最终会明文出现在 UDP 包中！\n\n```js\nsendUDP('1.2.3.4', 56789, 'Hello_World_1234567890')\n\nasync function sendUDP(addr, port, data) {\n  const pc = new RTCPeerConnection()\n\n  const sd = new RTCSessionDescription({\n    type: 'offer',\n    sdp: `\\\nv=0\no=- 1234567890 2 IN IP4 127.0.0.1\ns=-\nt=0 0\na=group:BUNDLE data\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\nc=IN IP4 0.0.0.0\na=ice-ufrag:${data}\na=ice-pwd:0000000000000000000000\na=ice-options:trickle\na=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\na=setup:actpass\na=mid:data\na=sctp-port:5000\na=max-message-size:262144\n`\n  })\n  await pc.setRemoteDescription(sd)\n\n  const answer = await pc.createAnswer()\n  const desc = new RTCSessionDescription(answer)\n  await pc.setLocalDescription(desc)\n\n  pc.addIceCandidate({\n    candidate: `candidate:842163049 1 udp 1677729535 ${addr} ${port} typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag ${data} network-cost 999`,\n    sdpMLineIndex: 0,\n    sdpMid: 'data',\n  })\n\n  setTimeout(_ =\u003e pc.close(), 30)\n}\n```\n\n![stun.png](stun-packet.png)\n\n该字段可接受字母、数字和 `#+-/=_` 共 68 种字符，最大长度 256。即使用 Base64 编码，也能容纳 192 字节。\n\n虽然容量不算大，但编码紧凑点的话，还是可以采集不少端上信息。况且 JS 和服务器直接通信，无需经过第三方中转，链路更短！\n\n----\n\n当然，基于 UDP/STUN 的敲门方案也有不少缺陷。有些运营商会降低 UDP 优先级，甚至使用不同的公网 IP！而且低版本浏览器并不支持 WebRTC，高版本浏览器的用户也可能禁用了 WebRTC。\n\n此外，浏览器开启 HTTP/SOCKS 代理后，敲门也可能会失效。因为 UDP 不走代理，而之后的 TCP 访问走代理，两者 IP 不一致，显然无法通过验证。\n\n\n### 在线演示\n\nhttps://www.etherdream.com/port-knocking/\n\n只有点了 Knock 按钮，你才能访问 Test 页面，否则和测试服务器的 TCP 连接都无法建立。可以抓包试试~\n\n出于演示，敲门服务直接用 iptables 实现：\n\n```bash\n# 放行白名单 IP（600s 后需重新敲门）\niptables \\\n  -t raw --append PREROUTING \\\n  -m recent --name knocked --rcheck --seconds 600 \\\n  -j ACCEPT\n\n# 敲门包\niptables \\\n  -t raw --append PREROUTING \\\n  -p udp --dport 30000 \\\n  -m string --string \"OpenSesame\" --algo bm \\\n  -m recent --name knocked --set \\\n  -j DROP\n\n# 默认拒绝所有 IP\niptables \\\n  -t raw --append PREROUTING \\\n  -j DROP\n\n# 实时查看白名单 IP\nwatch -n1 \\\n  cat /proc/net/xt_recent/knocked\n```\n\n\u003e 除了 recent 模块，还可使用更灵活的 ipset 模块\n\n当然这里没有解析数据，仅仅判断 UDP 包是携带某个暗号（`OpenSesame`）。\n\n实际应用中，你可以自由发挥想象，带上更多有意义的认证信息，以及更完善的加密。\n\n\n### 更多认证信息\n\nHTML5 的发展使得越来越多有趣的 API 加入到 Web 中。例如 WebGL、WebAssembly、Web Crypto API 等等，这些 API 都可参与到安全防御中。\n\n例如通过 [WebGL2 调用 GPU 实现工作量证明](https://www.etherdream.com/FunnyScript/glminer/glminer.html)，使得攻击者生成敲门认证数据需要大量算力，而无法大批量生成。\n\n例如通过 WebAssembly 混淆敲门数据的生成逻辑，使得 DDoS 攻击者还需掌握二进制逆向能力。\n\n如果攻击者需要执行复杂耗时的 JS 才能让一个 IP 进入防火墙白名单，那么我们的目的也就达到了。\n\n\n## 更多敲门方案\n\nWeb 仍在不断发展，例如 Chrome 的 QUIC 协议也使用 UDP 通信，尽管目前还无法用于敲门场合。未来 HTTP/3 的出现，是否会有更多的改进，开放更强大的网络通信能力呢。拭目以待中...\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fetherdream%2Fjs-port-knocking","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fetherdream%2Fjs-port-knocking","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fetherdream%2Fjs-port-knocking/lists"}