从零开始写一个代理

在一些网络管控严格的企业,我们访问互联网的流量通常会被安全部门拦截审查,因此诞生了许多基于混淆、加密流量原理的代理工具,为了更深层地了解代理的工作原理,本文来研究一下如何从零写一个自己的代理工具。

本文只是提供一个造轮子的思路,目前市面上的代理工具和协议已经相当完善,请自行搜索和使用。

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

在实际应用中,我们只需要将 serversocks 部署在企业外部,本机运行 client 即可实现流量加密传输。

为了验证流量的加密效果,我们可以本地使用 wireshark 软件对本地回环进行抓包。

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
承受不住.jpg

从结果上来看,性能倒是不用怎么关心。

最后

本文所造的轮子相当粗糙,一些错误处理和节流控制都没有实现,有感兴趣的同学可以按照本文思路自行改造。

文章内涉及的代码均已上传到 metowolf/pipe-demo

加入对话

16条评论

  1. 运行了你的文件后 crypto.scryptSync is not a function 不知道 最新的方法是什么啊

留下评论

电子邮件地址不会被公开。 必填项已用*标注