從零開始寫一個代理
在一些網路管控嚴格的企業,我們訪問網際網路的流量通常會被安全部門攔截審查,因此誕生了許多基於混淆、加密流量原理的代理工具,為了更深層地瞭解代理的工作原理,本文來研究一下如何從零寫一個自己的代理工具。
本文只是提供一個造輪子的思路,目前市面上的代理工具和協議已經相當完善,請自行搜尋和使用。
socks5
socks 是一種網路傳輸協議,我們先在企業外架設一個 socks 服務端,而後在企業內通過設定 socks 代理訪問其它網頁。
目前 socks 最新的協議版本是 5,然而 socks5 協議全部採用明文傳輸資料,防火牆只需要稍加分析即可作出攔截。

那麼思路很簡單了,我們在使用者側增加一個加密端,在伺服器增加一個解密端,那麼防火牆便無法識別流量內容。

手敲一個 socks5 server 得不償失,因此我們將 socks5 放到服務端,客戶端部分一旦接受到請求就直接加密轉發到服務端,由服務端解密後直接丟給 socks5,我們要造的輪子就只相當於一條管道罷了。
元件 | 監聽埠 | 遠端埠 | 作用 |
client | (192.168.1.2) 127.0.0.1:1080 |
192.168.1.1:8838 | 加密使用者流量 解密遠端流量 |
server | (192.168.1.1) 0.0.0.0:8838 |
(192.168.1.1) 127.0.0.1:8080 |
解密使用者流量 加密遠端流量 |
socks5 | (192.168.1.1) 127.0.0.1:8080 |
網際網路 | 提供 socks5 服務 |
加密
加密部分我們採用對稱金鑰加密演算法,需要在 client 和 server 端都配置好相同的金鑰串。
加密演算法採用 aes-256-gcm,定義加密的資料包格式為:
[nonce][encrypted data][data tag]
nonce 是每次隨機生成的 16 byte 字串,為的是防止同一個資料包在相同的金鑰加密下產生相同的密文,避免 socks5 的握手特徵暴露,同時 nonce 也等同於加鹽操作,增加了安全性。
為了防止攻擊者通過修改密文的特定位置來間接影響明文達到主動探測的目的,我們可以在密文後附加一段 16 byte 的 tag 簽名資料,防止密文被惡意篡改。
具體原因可以參考: 為何某協議要棄用一次性驗證 (OTA)
資料幀
由於加密後的資料在網路中是分片進行傳輸的,服務端接收到的加密資料會拼接到一起無法解密,這時候我們需要規定資料幀的協議頭格式,便於拆分資料包。
參考 TCP、UDP、HTTP 等常用資料包,設計以下幀頭:
[4-byte encrypted payload length][encrypted payload]
在每段資料包的前面增加 4 個位元組,表示資料包的長度。當然,這樣簡單的設計並沒有完全隱藏特徵,同時也無法防範主動探測。更進一步應該參考某協議的設計格式:
[encrypted payload length][length tag][encrypted payload][payload tag]
這裡對 length 也需要做一次簽名,因為攻擊者可以篡改前四個位元組的密文,使得在某些情況下解密後的 length 特別大,伺服器就會一直等待後續的資料包進來,從而暴露特徵。
實踐
原理和思路講完,下面開始造輪子。
socks5
首先搭建一個 socks5 服務端,這裡選用 heroku 維護的 socksv5 來搭建
// socks.js const socks = require('@heroku/socksv5') var srv = socks.createServer() srv.listen(8080, '127.0.0.1', () => { console.log('SOCKS server listening on port 8080') }) srv.useAuth(socks.auth.None())
執行 node socks.js
後,使用 curl
命令進行測試
$ curl -x socks5://127.0.0.1:8080 ip.sb 6.6.6.6
看到螢幕上顯示了本機的 IP,socks5 服務端程式碼寫完。
crypt
接下來編寫加解密函式,絕大多數程式碼都是從 官方文件 抄的
// crypto.js const crypto = require('crypto') const algorithm = 'aes-256-gcm' const password = crypto.scryptSync('demo', 'salt', 32) const RESET = Buffer.alloc(0) const _encrypt = (data, _nonce = null) => { let nonce = _nonce || crypto.randomBytes(16) let cipher = crypto.createCipheriv(algorithm, password, nonce) let buf = Buffer.concat([cipher.update(data), cipher.final()]) let tag = cipher.getAuthTag() return { nonce, buf, tag } } const _decrypt = (data, tag, nonce) => { try { let decipher = crypto.createDecipheriv(algorithm, password, nonce) decipher.setAuthTag(tag) let buf = Buffer.concat([decipher.update(data), decipher.final()]) return buf } catch (e) { return RESET } } module.exports.encrypt = data => { let payload = _encrypt(data) let nonce = payload.nonce return Buffer.concat([nonce, payload.buf, payload.tag]) } module.exports.decrypt = data => { let nonce = data.slice(0, 16) let payload = data.slice(16, -16) let tag = data.slice(-16) return _decrypt(payload, tag, nonce) }
接下來寫個簡單的測試檔案,看看是否能正確加解密
const crypto = require('./crypto') let message = Buffer.from("I'm METO, I love China!") console.log('message:', message) // message: <Buffer 49 27 6d 20 4d 45 54 4f 2c 20 49 20 6c 6f 76 65 20 43 68 69 6e 61 21> let enc = crypto.encrypt(message) console.log('encrypt:', enc) // encrypt: <Buffer 13 a3 bf 81 93 7e 0e 5a 17 89 ca 6a 36 90 3f 3f ed eb 78 ea 72 87 5c 6a 63 e1 26 be 84 fa 3f d6 fe a8 17 8a 88 91 ac 98 e8 92 50 e3 3e 80 80 c8 ae 6b ... 5 more bytes> let dec = crypto.decrypt(enc) console.log('decrypt:', dec) // decrypt: <Buffer 49 27 6d 20 4d 45 54 4f 2c 20 49 20 6c 6f 76 65 20 43 68 69 6e 61 21>
加密方法寫完,接下來開始肝服務端。
server
服務端的邏輯很簡單,建立一個本地監聽 8838 埠的服務,遇到請求後建立一個 socket,緊接著連線到 socks 的 8080 埠。接下去只幹兩件事:
- 收到 client 發來的請求,解密後發給 socks
- 收到 socks 發來的請求,加密後發給 client
// server.js const net = require('net') const Frap = require('frap') const crypto = require('./crypto') let server = net.createServer() server.listen(8838, '0.0.0.0') server.on('connection', local => { let frap = new Frap(local) remote = net.connect(8080, '127.0.0.1') frap.on('data', data => { data = crypto.decrypt(data) remote.write(data) }) remote.on('data', data => { data = crypto.encrypt(data) frap.write(data) }) frap.on('error', () => {}) remote.on('error', () => {}) })
這裡用到了一個 frap
庫,這個庫的作用就是自動劃分 socket 流量,也就是之前說到過的資料幀幀頭。
client
客戶端的寫法和服務端是剛好相反的,客戶端也主要做兩件事:
- 收到 client 發來的請求,加密後發給 server
- 收到 server 發來的請求,解密後發給 client
const net = require('net') const Frap = require('frap') const crypto = require('./crypto') let server = net.createServer() server.listen(1080, '127.0.0.1') server.on('connection', local => { remote = net.connect(8838, '127.0.0.1') let frap = new Frap(remote) local.on('data', data => { data = crypto.encrypt(data) frap.write(data) }) frap.on('data', data => { data = crypto.decrypt(data) local.write(data) }) frap.on('error', () => {}) local.on('error', () => {}) })
對稱美有沒有。
執行
我們開啟三個視窗,分別啟動這三個模組
$ node socks.js $ node server.js $ node client.js
另外開啟一個視窗,通過 curl
連線 client 代理進行測試
$ curl -x socks5://127.0.0.1:1080 ip.sb 6.6.6.6
成功,此時我們的網路包是以如下的鏈路進行傳輸的
curl <---> client (:1080) <--encrypted--> server (:8838) <---> socks (:8080) <---> ip.sb
在實際應用中,我們只需要將 server
和 socks
部署在企業外部,本機執行 client
即可實現流量加密傳輸。
為了驗證流量的加密效果,我們可以本地使用 wireshark 軟體對本地迴環進行抓包。

亂碼,什麼都看不出來。
效能
市面上大多數代理使用 golang 或者 C 來編寫,那麼本文用 Node.js 寫的輪子效能到底如何呢?我們用 iperf3
這款測速工具來跑跑分。
首先停掉 socks 端,空出 8080 埠,然後啟動 iperf3
服務端
$ iperf3 -s -p 8080 ----------------------------------------------------------- Server listening on 8080 -----------------------------------------------------------
此時我們通過 client 的 1080 埠訪問,效果是等同於直接訪問 8080 埠的,直接跑分!
$ iperf3 -c 127.0.0.1 -p 1080 -t 10 -J
最後給出測試結果
代理工具 | 測試結果 |
pipe (aes-256-gcm) | 1.68 Gbits/sec |
某協議 (aes-256-gcm) | 663 Mbits/sec |
直連 | 31.4 Gbits/sec |

從結果上來看,效能倒是不用怎麼關心。
最後
本文所造的輪子相當粗糙,一些錯誤處理和節流控制都沒有實現,有感興趣的同學可以按照本文思路自行改造。
文章內涉及的程式碼均已上傳到 metowolf/pipe-demo