socks5 隧道原理#
其实我们经常在 fq 的时候用到 socks 协议,但对于其工作原理一直没有很清晰,趁着周末捋了一下
首先什么是网络隧道?
各种百科上给出的定义整理如下:
网络隧道是在现有的网络协议之上建立的一个新的虚拟网络连接。通过在一个网络协议中封装另一个网络协议的数据包,从而实现数据在不同网络之间的传输。这种方式可以将数据在公共网络(例如互联网)上的传输与私有网络或其他网络保持隔离,从而提高数据传输的安全性。
但是这个定义太难理解,于是我开始思考为什么要叫隧道呢?
类比我们日常中见到的隧道,比如火车山谷隧道,点 A 到点 B 有一座大山,于是挖了一条 A 到 B 的隧道,这条隧道可以允许火车等车辆通过
那么类比到网络协议中,点 A 到点 B 由于某种原因无法直接通信(原因 dddd),于是我们在 A 和 B 之间打一条隧道(socks5 协议),然后把我们的火车(HTTP 数据,毕竟我们上网也就是 HTTP 通信)从这条隧道传输过去
这样是不是就很容易理解 socks 网络隧道了!我真是个小机灵鬼
类似的在安全渗透中还有一种 HTTP 隧道,即将利用 HTTP 协议的某些特性(如 chunked),建立一条 HTTP 隧道,传输 HTTP 通信数据(禁止套娃 /doge) ,不过这是后话了,本文只研究 socks 网络隧道
从上述的类比中可以看到,socks 网络隧道建立的条件如下:
- 目的地,即 socks 代理需要连接的目标
- 施工队 即 socks 代理服务器
也就是说,客户端 A 需要有一个施工队,并且告诉施工队我要去哪,施工队才会给你挖一条隧道
最后放一个 socks5 隧道的定义吧,类比过来是不是觉得好理解多了
SOCKS5 隧道是一种网络协议隧道,用于在客户端和目标服务器之间传输数据。SOCKS5 是 SOCKS 协议的第五个版本,它支持多种身份验证方法,以及 IPv4 和 IPv6 地址。SOCKS5 隧道允许在其上运行各种协议(如 HTTP、FTP、SMTP 等),并在客户端和目标服务器之间提供中间代理服务。
SOCKS5 隧道的工作原理是在客户端和目标服务器之间建立一个代理服务器。客户端不直接与目标服务器通信,而是将数据发送到 SOCKS5 代理。SOCKS5 代理接收数据,然后将其转发到目标服务器。目标服务器将响应发送回 SOCKS5 代理,代理再将响应转发给客户端。
SOCKS5 隧道的主要优点是提供了一种通用的网络代理解决方案,支持多种协议和地址类型。这使得 SOCKS5 隧道可以用于绕过防火墙和内容过滤器,实现对受限网络资源的访问。
实现一个 socks 代理服务
这里我们选择 go 和 rust 来对比实现下 socks5 代理服务器,即隧道的施工队,并且简单对比下性能,看看 rust 和 go 在 socks5 代理这块的性能孰强孰弱
TCP 代理 server 实现#
我们先来看看一个通用的 TCP 的 server 咋搞,这里是通信数据的传递示意图:
+-----------+ +--------------+ +--------------+
| Browser | <---> | TCP Proxy | <---> | Target Server|
+-----------+ +--------------+ +--------------+
注意,TCP Proxy 本质上只是接收 TCP 数据并转发处理的,所以实际上 socks5 的请求发起方是浏览器,这也就是为什么我们通常要安装一个 chrome 插件(比如 proxy switchy omega)来选择代理方式了
在golang
中,实现一个代理服务器很简单,只需要 net.Listen
即可开启一个端口,开启端口后的 server 只需要不断地 Accept
,每来一个就开一个 goroutine
func main() {
server, err := net.Listen("tcp", ":1081")
if err != nil {
fmt.Printf("Listen failed: %v\n", err)
return
}
for {
client, err := server.Accept()
if err != nil {
fmt.Printf("Accept failed: %v", err)
continue
}
go process(client)
}
}
这个 client 中就同时包含了浏览器发送给我们的请求,以及暴露写接口供我们写入响应数据
对应到 rust 中,也有一个类似 goroutine 的实现,tokio,实现异步的 IO 任务,基本代码如下:
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:1080").await.unwrap();
loop {
let (client, _) = listener.accept().await.unwrap();
spawn(handle_client(client));
}
}
注意 socks5 代理最常用的端口是 1080,如果想要在 wireshark 中抓包查看,wireshark 只能解析 1080 端口的 socks5 通信
实现 socks5 代理#
socks5 协议本质上还是个应用层协议,数据会被打包到 TCP 数据包的 payload 中,sock5 协议类比挖隧道可以分为几个部分,
socks5auth
先找到施工队socks5connect
开始挖隧道socks5forward
隧道通车了!
socks5forward
即进入隧道通行阶段,这个阶段已经没有 socks5 参与了,因为隧道已经挖完了,就让 HTTP 数据包自由的驰骋吧!
socks5auth 先找到施工队#
socks5 协议是由客户端先发起的:
# 客户端发送
+----+----------+----------+
|VER | NMETHODS | METHODS |
+----+----------+----------+
| 1 | 1 | 1 to 255 |
+----+----------+----------+
# 服务器响应
+----+--------+
|VER | METHOD |
+----+--------+
| 1 | 1 |
+----+--------+
具体字段如下:
客户端请求
- VER 版本号 1 字节
- NMETHODS 可供选的认证方法,1 字节
- METHODS (长度等于 NMETHODS) 一个字节一个方法
服务端返回
- VER 版本号
- METHOD 认证方法,我们直接无认证梭哈,填 0x00
因此第一步就只需读取请求,然后返回 0x05,0x00
给客户端表示同意连接
func Socks5Auth(client net.Conn) (err error) {
buf := make([]byte, 256)
// 读取 VER 和 NMETHODS
n, err := io.ReadFull(client, buf[:2])
ver, nMethods := int(buf[0]), int(buf[1])
// 读取 METHODS 列表
n, err = io.ReadFull(client, buf[:nMethods])
//无需认证
n, err = client.Write([]byte{0x05, 0x00})
return nil
}
同理 rust 的实现:
async fn socks5_auth(client: &mut TcpStream) -> Result<(), Box<dyn std::error::Error>> {
let mut buf = [0u8; 2]; // 初始化为[0,0]
client.read_exact(&mut buf).await?;
let ver = buf[0];
let n_methods = buf[1];
let mut methods = vec![0u8; n_methods as usize];
client.read_exact(&mut methods).await?;
client.write_all(&[0x05, 0x00]).await?;
Ok(())
}
这样,socks5 协议的第一步,施工队已经找到了,并且告诉客户端我来帮你挖隧道!
socks5connect 开始挖隧道#
协议细节如下(数字表示字节长度):
# 客户端发送
+----+-----+-------+------+----------+----------+
|VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
# 服务端响应
+----+-----+-------+------+----------+----------+
|VER | REP | RSV | ATYP | BND.ADDR | BND.PORT |
+----+-----+-------+------+----------+----------+
| 1 | 1 | X'00' | 1 | Variable | 2 |
+----+-----+-------+------+----------+----------+
客户端请求:
- VER 版本号 1 字节,默认为 5
- CMD 0x01 表示连接
- RSV 保留固定位 0x00
- ATYP 请求类型,0x01 为 ipv4,0x03 为域名,0x04 为 ipv6
- DST.ADDR 地址,如果请求为域名,第一个字节为域名长度,否则 4 字节 ipv4 地址(ipv6 就不管了)
- DST.PORT 端口 2 字节
服务端响应:
- VER 版本号 1 字节,默认为 5
- REP 确认回应 0x00 succeed
- RSV 保留,默认 0
后面几个字段只适用于客户端 BIND 命令(不是我们用到的 connect 命令),都传 0 就行了
- ATYP 响应类型,0x01 表示 ipv4,0x03 表示域名,0x04 表示 ipv6
- BND.ADDR 地址
- BND.PORT 端口
既然这一步是挖隧道,那就要知道客户端让我们挖通往哪里的隧道,所以这里其实就分成两步
- 解析出客户端发给我们的目的地(按照上述协议解析)
- 建立通往目的地的 TCP 连接
客户端 → socks proxy
socks proxy → 客户端的代码就一行,我写在注释里了
func Socks5Connect(client net.Conn) (net.Conn, error) {
buf := make([]byte, 256)
n, err := io.ReadFull(client, buf[:4])
// 前四个字节
ver, cmd, _, atyp := buf[0], buf[1], buf[2], buf[3]
addr := ""
switch atyp {
case 1: // 假设只有 第一种ipv4的情况
n, err = io.ReadFull(client, buf[:4])
if n != 4 {
return nil, errors.New("invalid IPv4: " + err.Error())
}
addr = fmt.Sprintf("%d.%d.%d.%d", buf[0], buf[1], buf[2], buf[3])
// ...
default:
return nil, errors.New("invalid atyp")
}
// 解析端口,注意字节顺序
n, err = io.ReadFull(client, buf[:2])
port := binary.BigEndian.Uint16(buf[:2])
// 得到目的地地址了!
destAddrPort := fmt.Sprintf("%s:%d", addr, port)
// 开始挖隧道
dest, err := net.Dial("tcp", destAddrPort)
// 给客户端的响应,隧道已竣工!
_, err = client.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
return dest, nil
}
同理我们用 rust 实现
async fn socks5_connect(client: &mut TcpStream) -> Result<TcpStream, Box<dyn std::error::Error>> {
let mut buf = [0u8; 4];
client.read_exact(&mut buf).await?;
let ver = buf[0];
let cmd = buf[1];
let atyp = buf[3];
let target_addr = match atyp {
1 => {
let mut addr = [0u8; 4];
client.read_exact(&mut addr).await?;
format!("{}.{}.{}.{}", addr[0], addr[1], addr[2], addr[3])
}
_ => return Err("Invalid atyp".into()),
};
let mut port_buf = [0u8; 2];
client.read_exact(&mut port_buf).await?;
let port = u16::from_be_bytes(port_buf);
// 开始挖隧道!
let target = TcpStream::connect(format!("{}:{}", target_addr, port)).await?;
// 告诉客户端隧道已竣工!
client
.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await?;
Ok(target)
}
socks5forward 隧道通车啦#
此时我们就要让客户端的 client 和远端的 target 建立连接,等于是把这个隧道拼接起来,怎么说有点类似于詹天佑当年开凿京张铁路隧道时所用的两端并进的策略
在 go 中,我们直接用 io.Copy
去实现
func Socks5Forward(client, target net.Conn) {
forward := func(src, dest net.Conn) {
defer src.Close()
defer dest.Close()
io.Copy(src, dest)
}
go forward(client, target)
go forward(target, client)
}
在 rust 中,也有类似的 API,tokio::io::copy
let (mut cr, mut cw) = client.split();
let (mut tr, mut tw) = target.split();
let c_to_t = async {
match tokio::io::copy(&mut cr, &mut tw).await {
Ok(_) => {}
Err(e) => {
eprintln!("Error forwarding from client to target: {}", e);
}
}
};
let t_to_c = async {
match tokio::io::copy(&mut tr, &mut cw).await {
Ok(_) => {}
Err(e) => {
eprintln!("Error forwarding from target to client: {}", e);
}
}
};
至此,一条 socks5 的网络隧道建立完毕,之后就是 HTTP 数据包(火车)开始驰骋
wireshark 抓包测试#
前面已提到过,只有 socks 工作在 1080 端口时,wireshark 才能正确解析出 socks 协议
如下图标记处 socks 的三个过程,具体的数据包细节可自行查看:
压测对比#
这里的压测思路是搞一个 http server,然后分别用 go 和 rust 实现的 socks5 proxy 去建立隧道,发起请求,看看实际 QPS 表现
为了方便就用 gin 来搞个 http server
package main
import "github.com/gin-gonic/gin"
func main() {
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.String(200, "pong")
})
r.Run(":8082")
}
使用这个 benchmark 工具,可以支持 socks5 协议
go install github.com/cnlh/benchmark@latest
首先测下 web server 的 QPS,m1 mac 机器配置比较低,就用 100 个并发跑 10w 个请求吧
❯ benchmark -c 100 -n 100000 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.45s, 11.46MB read, 4.20MB write
Requests/sec: 22464.38
Transfer/sec: 3.52MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 2
65% 2
75% 3
80% 4
90% 8
95% 17
98% 29
99% 51
100% 82
这里的数据解释如下:
- 4.45s 内总共完成了 10w 次请求
- 平均每秒 22464.38,即 QPS 为 22k
- 最后的一段数据给出了不同时间段内请求的百分比,以及这些百分比所对应的响应时间
- 50% 的请求响应在 2ms 内
- 99% 的请求响应在 51ms 内
接下来有请两个施工队上场,首先是 go 代表的 goroutine,可以看到 QPS 虽然有所下降,但是下降不多,并且请求耗时分布居然更均匀了?
❯ benchmark -c 100 -n 100000 -proxy socks5://127.0.0.1:1080 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.49s, 11.46MB read, 4.20MB write
Requests/sec: 22295.77
Transfer/sec: 3.49MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 2
65% 3
75% 4
80% 4
90% 7
95% 14
98% 25
99% 35
100% 63
接下来有请 rust 选手代表的 tokio 上场,QPS 下降了 2k 左右,并且请求耗时分布差异更大了
❯ benchmark -c 100 -n 100000 -proxy socks5://127.0.0.1:1080 http://127.0.0.1:8082/ping -ignore-err
Running 100000 test @ 127.0.0.1:8082 by 100 connections
Request as following format:
GET /ping HTTP/1.1
Host: 127.0.0.1:8082
100000 requests in 4.95s, 11.46MB read, 4.20MB write
Requests/sec: 20218.10
Transfer/sec: 3.17MB
Error(s) : 0
Percentage of the requests served within a certain time (ms)
50% 3
65% 4
75% 4
80% 5
90% 7
95% 13
98% 25
99% 34
100% 92
看来 goroutine 选手终是更胜一筹
参考:
https://segmentfault.com/a/1190000038247560