JS 端口敲門探索

傳統的端口敲門

端口敲門是一種特殊的安全認證方案。它沒有固定的標準,每個人的實現各不相同。當然,即使沒聽說過這個名詞,不少人也有類似的想法和實現。

例如我曾經使用 Windows 服務器時,一直對遠程桌面頗爲不滿。不是因爲這個服務做得不好,相反,是做得太好了,以至於一個不知道賬號密碼的陌生人試着訪問時,都會爲它繪製窗口、傳輸畫面,服務太過周到了!攻擊者即使猜不到密碼,也能白白消耗服務器資源和網絡流量。況且,越複雜的程序出現漏洞的可能性越大,萬一哪天出現緩衝區溢出之類的漏洞,攻擊者不用知道密碼也能控制服務器。這太不完美了!

在我看來,認證應該越早越好。對於不知道認證規則的陌生人,甚至 TCP 連接都不值得爲它建立!這即利於 系統安全(減少密碼爆破、漏洞攻擊等風險),又利於 網絡安全(減少惡意消耗流量、DDoS 攻擊等風險)。

於是寫了個簡單的防火牆小程序,默認攔截所有 IP。只有當某個 IP 訪問了「祕密端口」後,纔將其加入白名單,允許進一步通信。這就是一個典型的端口敲門方案,那個訪問祕密端口的數據包,便是敲門磚。


當然,僅憑一個端口作爲暗號還是有些簡陋,很容易被破解,因此需加強。例如使用多個端口、使用特定數據的 UDP 包等等。甚至還可以在數據包中加入更復雜的認證信息,當然這需要通過專門的程序敲門了,而不能簡單地使用系統命令手動敲門。

事實上用程序敲門是很常見的方案。例如一些公司要求員工安裝某個程序才能訪問內網,該程序很可能給網關發送了敲門數據包。

那麼,敲門程序是否適用於公網?更進一步,是否能做成 Web 版?

公網 Web 敲門

敲門程序放在公網似乎不太好,畢竟這是一種基於保密的安全方案,細節公開後效果就大打折扣了,除非對程序做很強的混淆保護。

前面提到,敲門有兩個意義:保護系統安全、保護網絡安全。現在聊聊後者。

接着從之前做防火牆聊起。雖然那只是個玩具級的小程序,但實際效果還不錯,並且性能極高(驅動層過濾包)。分享給一些好友試用後,很快就有人思考如何應用到現實中 —— 那些經常被攻擊的遊戲服務器。

但這並不好實現。總不能要求玩家複製粘貼一堆 cmd 命令手動敲門吧。讓玩家下載敲門程序?這相當於是在推廣軟件,成本不小。除非把敲門程序和登錄器捆綁在一起,但這款遊戲被魔改的五花八門,登錄器各不相同,實現起來很麻煩。

一番摸索後發現,幾乎所有的登錄器都內嵌網頁,顯示公告信息之類的。如果實現一個 JS 版的敲門程序,那麼直接讓管理員插到公告頁裏就可以,連登錄器都不用升級!

TCP/SYN 直接敲門

v1 單端口

第一個實驗版本非常簡單,甚至都沒用上 JS,僅僅敲服務器一個固定端口。

<img src="http://xxx.xxx:12345/logo.gif">

爲了隱蔽,URL 裏沒有暴露服務器 IP,而是用一個指向該 IP 的域名替代,並加上迷惑性路徑。

事實上服務器會丟棄 SYN 敲門包,所以連接都無法建立,URL 路徑是毫無用途的

試用後效果很好。僞造遊戲協議的搗亂機器人 TCP 都建立不了,更別說登錄了;即使用 SYN Flood 攻擊遊戲端口,服務器也是一個 ACK 都不回覆,極大節省了系統資源和流量開銷(出站流量很貴)。通過這個簡單粗暴的敲門方案,L7 和 L4 攻擊都能輕鬆防住。當然,除非特別大的流量直接把服務器 IP 打進黑洞,那倒是無解,不過軟件形式的防火牆對此都無能爲力。

儘管敲門機關是公開的,但大部分人都不會想到藏在網頁裏~

當然,被發現也是早晚的事。在被破解之前,這個方案改良了多次。

v2 多端口

敲門端口只有一個,感覺太簡陋。但即使有多個,攻擊者只要掃描下所有端口,總能敲到這幾個。

因此,多端口敲門必須要按順序,否則意義就不大了。

不過實現後發現有個問題,服務器收到的 SYN 包順序,未必就是客戶端發送時的順序。因此只能通過延遲發送的方式,儘量保證順序。

function load(url) {
  new Image().src = url
}

load('http://xxx.xxx:50000')

setTimeout(function() {
  load('http://xxx.xxx:10000')
}, 100)

setTimeout(function() {
  load('http://xxx.xxx:30000')
}, 200)

當然,這麼做的後果就是增加了敲門時間。

這裏用 onerror 事件是沒有意義的。因爲服務器會丟棄 SYN 包,所以客戶端需要重發多次 SYN 纔會產生 TCP 連接超時錯誤,可能要幾秒甚至幾十秒之後纔會觸發該事件。

v3 動態多端口

多端口實現了,但是端口號仍是固定的,感覺仍不完美。於是接着改進。

如果端口號是動態的,那麼前後端如何保持一致?最容易想到的,就是通過時間生成端口號。

var port = gen_port(time())
load('http://xxx.xxx:' + port)

但這也存在一個問題,並非所有用戶的時間都是準的。如果誤差超出允許範圍,那麼敲門就會失效。

因此,敲門之前需校準時間。我們提供一個返回時間的接口,或者直接用網上免費公開的接口。爲了確保穩定性,JS 裏準備多個接口,只要有一個可用就不會失效。

當然,動態端口也有一些問題。例如配有硬件防火牆的服務器可能沒法使用,除非服務器能和防火牆設備保持互動。(如今雲服務器的雲防火牆確實可通過 API 實時修改規則~)

UDP/DNS 直接敲門

事實上,前面幾個改良的意義都不大,都是建立在攻擊者還沒發現的基礎上。然而祕密總是會暴露的。

如何繼續改進?思考了下問題所在:只發幾個 SYN 包就能通過防火牆驗證的話,攻擊者太容易模擬,僞造成本太低。但如果敲門包裏能容納更多信息,那就可以帶上 JS 生成的認證數據,這樣僞造起來麻煩多了。

但 TCP 顯然不行,畢竟端口號才 16bit,可攜帶的信息太少了,需要發送幾十上百個請求才能勉強傳遞認證數據,效率太低。因此只能用 UDP。

在 Web 中和 UDP 相關的通信,最容易想到的是 DNS。並且可通過 NS 型泛域名,一次攜帶可觀的數據到指定服務器。

var auth = gen_auth()
load('http://' + encode(auth) + '.xxx.xxx.xxx')

不過這個方案存在本質性問題:服務器收到的 UDP 包,並不是用戶發給它的,而是用戶運營商 DNS 發的。所以服務器看到的源 IP 是運營商 DNS IP,而不是用戶 IP!既然連用戶 IP 都拿不到,那還有什麼用。

當時想到一個有趣的解決方案:JS 先通過一些免費公開的接口獲取公網 IP,然後將其加密到認證信息裏;服務器最終使用認證信息裏的 IP,而不是數據包的 IP。這樣就能拿到用戶 IP 了!

jsonp_public_ip_callback = function(ip) {
  var auth = gen_auth(ip, ...)
  load('http://' + encode(auth) + '.xxx.xxx.xxx')
}

load_js('http://xxx.com/get_public_ip')

當然,這個方案嚴重依賴 JS 代碼混淆。一旦加密算法被破解,攻擊者可以輕輕鬆鬆將任何 IP 刷進防火牆白名單。

其實 SYN 敲門方案,攻擊者也能僞造源 IP,給任意 IP 加白,但需要特殊的網絡環境才能發送。而 UDP/DNS 這種方案任何網絡環境都可以

UDP/DNS 中轉敲門

使用 UDP/DNS 敲門還有一個問題:服務器需要開放 UDP:53 端口,但有些網絡可能不允許。

這種情況只能通過中轉。NS 域名指向我們的服務器,我們分析 UDP 數據後再將用戶 IP 推送給遊戲服務器(遊戲服務器和我們保持長連接)。這樣就不用關心網絡問題了。

TCP/HTTP 中轉敲門

基於 UDP/DNS 的敲門仍存在不少問題。JS 需要先獲取公網 IP 地址,UDP 數據需要經過 DNS 轉發,這些額外的鏈路大幅增加了敲門時間和故障機率。

因此最終還是迴歸到穩定的 HTTP 中轉方案。這個方案原理很簡單,沒什麼騷操作。JS 將認證信息提交到我們的 HTTP 服務器,我們驗證後再推送給遊戲服務器。由於 HTTP 可攜帶的信息量沒有限制,因此除了基本驗證信息外,還可採集瀏覽器環境、Flash 環境、用戶行爲等大量信息,進一步加強安全防禦。

不過,中心化的驗證服務顯然會成爲衆矢之的。攻擊者只要打垮這個服務就可以,都用不着攻擊遊戲服務器~

但相比傳統 C/S 架構的網遊服務,B/S 架構的 Web 服務更容易防禦,並且有很多現成的解決方案,例如 CDN、WAF 等。因此 藉助相對穩定的 Web 服務給更脆弱的遊戲服務敲門,還是值得的。

這個方案一直用到項目結束。遺憾的是沒趕上 HTML5 時代,不然還可以用上更多黑科技。

UDP/STUN 直接敲門

雖然之後沒再研究防火牆,但對 Web 仍保持關注,每當有新的 API 出現時都會琢磨一番,尤其和網絡相關的,總會聯想到敲門服務。

當然,基於 TCP 的直接忽略。無論功能多豐富,都繞不過操作系統的握手連接。所以像 WebSocket 這種都不用考慮。(更何況它還是基於 HTTP 的)

UDP 端口敲門

WebRTC 的出現,終於能讓 JS 發送 UDP 包了,並能指定 IP 和端口。

var pc = new RTCPeerConnection({
  iceServers: [
    {urls: 'stun:1.2.3.4:56789'}
  ]
})
pc.createDataChannel('')

pc.createOffer(function(v) {
  pc.setLocalDescription(v)
}, function() {})

但是,如果只能指定端口而不能攜帶數據,那和 SYN 敲門包有什麼區別?

根據 API 文檔,iceServers 參數可配置用戶名和密碼。假如用戶名能出現在 UDP 包裏,那就能攜帶自定義數據了。

不過之前多次試驗都未成功(也許是還沒發現),於是換了種思路。

UDP 數據敲門

WebRTC 的本質是 P2P 通信,讓兩個內網用戶直連。大致步驟是:

1.每個用戶通過 STUN 服務,獲取自己的公網 IP 及打洞端口

2.通過某個服務器交換各自信息

3.兩個用戶互相通信

前面的代碼只是第 1 步,訪問 1.2.3.4:56789 只是查詢公網地址而已。對於端口敲門來說,這一步並不關鍵,假如對方地址已知,不妨跳過 1 和 2,直接從第 3 步開始。

我們可以虛構一份 SDP(Session Description Protocol)欺騙 WebRTC,假裝已知對方信息。然後把對方 IP 和端口加入 Ice Candidate,瀏覽器即可發出 UDP 包!

與第一步不同,這一步可設置 ufrag 字段,並且最終會明文出現在 UDP 包中!

sendUDP('1.2.3.4', 56789, 'Hello_World_1234567890')

async function sendUDP(addr, port, data) {
  const pc = new RTCPeerConnection()

  const sd = new RTCSessionDescription({
    type: 'offer',
    sdp: `\
v=0
o=- 1234567890 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE data
m=application 9 UDP/DTLS/SCTP webrtc-datachannel
c=IN IP4 0.0.0.0
a=ice-ufrag:${data}
a=ice-pwd:0000000000000000000000
a=ice-options:trickle
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
a=setup:actpass
a=mid:data
a=sctp-port:5000
a=max-message-size:262144
`
  })
  await pc.setRemoteDescription(sd)

  const answer = await pc.createAnswer()
  const desc = new RTCSessionDescription(answer)
  await pc.setLocalDescription(desc)

  pc.addIceCandidate({
    candidate: `candidate:842163049 1 udp 1677729535 ${addr} ${port} typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag ${data} network-cost 999`,
    sdpMLineIndex: 0,
    sdpMid: 'data',
  })

  setTimeout(_ => pc.close(), 30)
}

該字段可接受字母、數字和 #+-/=_ 共 68 種字符,最大長度 256。即使用 Base64 編碼,也能容納 192 字節。

雖然容量不算大,但編碼緊湊點的話,還是可以採集不少端上信息。況且 JS 和服務器直接通信,無需經過第三方中轉,鏈路更短!


當然,基於 UDP/STUN 的敲門方案也有不少缺陷。有些運營商會降低 UDP 優先級,甚至使用不同的公網 IP!而且低版本瀏覽器並不支持 WebRTC,高版本瀏覽器的用戶也可能禁用了 WebRTC。

此外,瀏覽器開啓 HTTP/SOCKS 代理後,敲門也可能會失效。因爲 UDP 不走代理,而之後的 TCP 訪問走代理,兩者 IP 不一致,顯然無法通過驗證。

在線演示

https://www.etherdream.com/port-knocking/

只有點了 Knock 按鈕,你才能訪問 Test 頁面,否則和測試服務器的 TCP 連接都無法建立。可以抓包試試~ (出於演示,敲門服務直接用 iptables 實現)

除了 recent 模塊,還可使用更靈活的 ipset 模塊

當然這裏沒有解析數據,僅僅判斷 UDP 包是攜帶某個暗號(OpenSesame)。

實際應用中,你可以自由發揮想象,帶上更多有意義的認證信息,以及更完善的加密。

更多認證信息

HTML5 的發展使得越來越多有趣的 API 加入到 Web 中。例如 WebGL、WebAssembly、Web Crypto API 等等,這些 API 都可參與到安全防禦中。

例如通過 WebGL2 調用 GPU 實現工作量證明,使得攻擊者生成敲門認證數據需要大量算力,而無法大批量生成。

例如通過 WebAssembly 混淆敲門數據的生成邏輯,使得 DDoS 攻擊者還需掌握二進制逆向能力。

如果攻擊者需要執行復雜耗時的 JS 才能讓一個 IP 進入防火牆白名單,那麼我們的目的也就達到了。

更多敲門方案

Web 仍在不斷髮展,例如 Chrome 的 QUIC 協議也使用 UDP 通信,儘管目前還無法用於敲門場合。未來 HTTP/3 的出現,是否會有更多的改進,開放更強大的網絡通信能力呢。拭目以待中...

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章