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