SOCKS5 协议与 C 语言实现

SOCKS5 协议的作用是为应用层提供代理服务,通信过程分为三个阶段:

  1. 握手阶段。客户端跟代理服务器确定协议的版本和认证方法,完成身份认证;
  2. 确定服务阶段。客户端向代理服务器说明需要提供何种代理服务;
  3. 转发数据阶段。代理服务器在客户端和目标服务器之间转发数据。

在确定服务阶段,客户端要向代理服务器发送一个请求,代理服务器要给客户端发送一个应答。如果是要进行 TCP 代理,客户端发送的请求须包含目标服务器的地址和端口;代理服务器成功连接到目标服务器后,须在应答中给出代理服务器与目标服务器通信用的地址和端口号。

TCP 过程

如果是要进行 UDP 代理,则目标服务器的地址和端口号是包含在每一个 UDP 数据报中的。客户端发送的请求中包含的是 UDP 数据报的源地址和源端口;代理服务器在应答中给出的是代理服务器用来接收 UDP 数据报的地址和端口号。

UDP 过程

不管是 TCP 代理还是 UDP 代理,代理服务器和客户端之间都要建立一条 TCP 连接来完成前面两个阶段。

👉 握手阶段

握手的目的是确定协议的版本和认证方法,并依据选定的认证方法进行认证。

客户端发送请求

客户端发送给代理服务器的 方法选择消息 格式如下(表格第二行表示长度,单位字节):

ver nmethods methods
1 1 1-255
  • ver 协议的版本号,固定为 0x05
  • nmethods 认证方法的数量,即第三个字段 methods 的长度
  • methods 认证方法的列表,每个字节代表一种认证方法

目前,绝大多数客户端只支持下面两种认证方法:

  • 0x00 NO AUTHENTICATION REQUIRED,无需认证
  • 0x02 USERNAME/PASSWORD,用户名密码

如果客户端不支持用户名密码认证,应该把 nmethods 设为 0x01,并在 methods 中填上 0x00,实际发送的数据如下:

1
2
ver  nmethods methods
0x05 0x01 0x00

SOCKS 协议把「不需要认证」也视为一种认证方法

如果客户端支持用户名密码认证,应该把 nmethods 设为 0x02,然后在 methods 中填上 0x000x02,实际发送的数据如下:

1
2
ver  nmethods methods
0x05 0x02 0x00 0x02

客户端发送的方法选择消息可用下面结构体封装:

1
2
3
4
5
typedef struct {
uint8_t ver;
uint8_t nmethods;
uint8_t methods[];
} client_hello_t;

代理服务器发送应答

代理服务器收到客户端的消息后,先检查 methods 中是否包含要求的认证方法,然后向客户端发送一条 认证方法选择消息,以说明被采纳的认证方法,格式如下:

ver method
1 1
  • ver 协议的版本号,固定为 0x05
  • method 采纳的认证方法,决定后续的动作

下面是 method 的三种常见取值:

  • 0x00 NO AUTHENTICATION REQUIRED,无需认证
  • 0x02 USERNAME/PASSWORD,用户名密码
  • 0xff NO ACCEPTABLE METHODS,没有能够接受的认证方法

若不需要进行认证,就把 method 设为 0x00,实际发送给客户端的数据如下:

1
2
ver  method
0x05 0x00

若代理服务器要求通过用户名密码认证,但客户端发来的消息中的 methods 字段不包含代表用户名密码认证方式的 0x02,则代理服务器应该把 method 设为 0xff,表示没有能够接受的认证方法,此时客户端应关闭连接。

下面函数可检查 methods 中是否包含要求的认证方法。函数需要两个参数:第一个参数 request 指向客户端发来的方法选择消息;第二个参数 method 是要查找的认证方法。若找到,返回 1,否则返回 0

1
2
3
4
5
6
7
8
int method_exists(client_hello_t * client_hello, uint8_t method)
{
int i;
for (i = 0; i < client_hello->nmethods; i++)
if (client_hello->methods[i] == method)
return 1;
return 0;
}

代理服务器发送的方法选中消息可用下面结构体封装:

1
2
3
4
typedef struct {
uint8_t ver;
uint8_t method;
} server_hello_t;

此外,SOCKS5 还定义了认证方法在下面三种取值时的含义:

  • 0x01 GSSAPI,采用 GSSAPI
  • 0x03 - 0x7e ANA ASSIGNED,由互联网数字分配机构定义
  • 0x80 - 0xef RESERVED FOR PRIVATE METHODS,保留,可定义私有的认证方法

进行用户名密码认证

如果代理服务器要求通过用户名密码认证,客户端在收到代理服务器的方法选中消息后,就要向代理服务器发送一个包含用户名和密码的消息,格式如下:

ver ulen uname plen passwd
1 1 1-255 1 1-255
  • ver 认证协议的版本号,固定为 0x01
  • ulen 用户名的长度
  • uname 用户名
  • plen 密码的长度
  • passwd 密码

若客户端提供的用户名是 admin,密码是 123456,实际发送的数据如下:

1
2
ver  ulen uname plen passwd
0x01 0x05 admin 0x06 123456

代理服务器校验后,给客户端发送一个说明认证结果的消息,格式如下:

ver status
1 1
  • ver 认证协议的版本号,固定为 0x01
  • status 认证结果,只有等于 0x00 时才表示认证成功

🎯 确定服务阶段

客户端发送请求

客户端发送一个请求,说明需要代理服务器提供何种服务,格式如下:

ver cmd rsv atyp dst.addr dst.port
1 1 1 1 Variable 2
  • ver 协议的版本号,固定为 0x05
  • cmd 命令,说明需要代理服务器提供何种服务,取值如下:
    • 0x01 CONNECT,建立 TCP 连接(提供 TCP 代理服务)
    • 0x02 BIND,用于 FTP 等罕见场景
    • 0x03 UDP,关联 UDP 请求(提供 UDP 代理服务)
  • rsv reserved 的缩写,保留,无意义,固定为 0x00
  • atyp address type 的缩写,下一个字段的类型,取值如下:
    • 0x01 下一个字段 dst.addr 是 4 个字节的 IPv4 地址
    • 0x03 下一个字段 dst.addr 的第 1 个字节是域名长度,之后是域名本身
    • 0x04 下一个字段 dst.addr 是 16 个字节的 IPv6 地址
  • dst.addr destination address 的缩写,目标地址或源地址
  • dst.port 目标端口或源端口

最后两个字段 dst.addrdst.port 在不同的情况下有不同的含义:在 TCP 代理中,是目标服务器的地址和端口号;在 UDP 代理中,是 UDP 数据报的源地址和源端口号。

代理服务器发送应答

代理服务器收到客户端的请求后,要给客户端一个应答,格式如下:

ver rep rsv atyp bnd.addr bnd.port
1 1 1 1 Variable 2
  • ver 协议的版本号,固定为 0x05
  • rep 状态码,说明客户端的请求是否能够得到满足,取值如下:
    • 0x00 succeeded,成功
    • 0x01 general SOCKS server failure,一般错误
    • 0x02 connection not allowed by ruleset,连接不被规则集所接受
    • 0x03 Network unreachable,网络不可到达
    • 0x04 Host unreachable,主机不可到达(主机名无效)
    • 0x05 Connection refused,连接被拒绝
    • 0x06 TTL expired,TTL 过期
    • 0x07 Command not supported,命令不支持
    • 0x08 Address type not supported,地址类型不支持
    • 0x09 - 0xff unassigned,未指派(暂无意义)
  • rsv 保留,无任何作用,固定为 0x00
  • atyp 下一个字段的类型,取值如下:
    • 0x01 IPv4
    • 0x03 域名
    • 0x04 IPv6
  • bnd.addr 地址
  • bnd.port 端口

同样,最后两个字段 bnd.addrbnd.port 在不同的情况下有不同的含义:在 TCP 代理中,是代理服务器与目标服务器通信使用的地址和端口号;在 UDP 代理中,是代理服务器用来接收来自客户端的 UDP 数据报的地址和端口号。

TCP 代理

如果客户端要代理服务器提供 TCP 代理服务,应该把 cmd 设为 0x01。如果目标地址的类型是 IPv4,应该把 atyp 设为 0x01,接着 4 个字节的 IPv4 地址和 2 字节的端口号。

例如,客户端要访问 192.168.1.3:80,实际发送的请求数据如下:

1
2
ver  cmd  rsv  atyp dst.addr    dst.port
0x05 0x01 0x00 0x01 192.168.1.3 80

若代理服务器与目标服务器成功建立连接,使用的 IP 地址和端口号是 192.168.1.2:58328,实际发送的应答数据如下:

1
2
ver  cmd  rsv  atyp bnd.addr    bnd.port
0x05 0x00 0x00 0x01 192.168.1.2 58328

在实际应用中,代理服务器常常忽略应答中的 bnd.addrbnd.port 两个字段,并用 0 填充。

UDP 代理

如果客户端要代理服务器提供 UDP 代理服务,应该把 cmd 设为 0x03。假如客户端准备将 UDP 数据报从 192.168.0.2:59285 发出,实际发送的请求数据如下:

1
2
ver  cmd  rsv  atyp dst.addr    dst.port
0x05 0x03 0x00 0x01 192.168.0.2 59285

若代理服务器希望从 192.168.0.3:1080 接收 UDP 数据报,实际发送的应答数据如下:

1
2
ver  cmd  rsv  atyp bnd.addr    bnd.port
0x05 0x00 0x00 0x01 192.168.0.3 1080

C 语言对请求的封装

可用下面结构体封装客户端发送的请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct {
uint8_t ver;
uint8_t cmd;
uint8_t rsv;
uint8_t atyp;
union {
struct {
in_addr_t addr;
in_port_t port;
} ipv4;
struct {
uint8_t len;
char str[];
} domain;
struct {
uint8_t addr[16];
in_port_t port;
} ipv6;
} dst;
} request_t;

通过上述的结构指针来访问发送缓存,可以很方便地读写各个字段的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

char buf[1024];
request_t * req;

req = (request_t *)buf;
req->ver = 0x05;
req->cmd = 0x01;
req->rsv = 0x00;
req->atyp = 0x01;
req->dst.ipv4.addr = inet_addr("127.0.0.1");
req->dst.ipv4.port = htons(8080);

在填写目标地址和目标端口时,用到下面两个函数:

  • inet_addr() 将一个点分十进制表示的 IP 地址转换成网络字节序
  • htons() 将端口号从主机字节序转换成网络字节序

C 语言对应答的封装

代理服务器发送给客户端的应答可用下面结构体封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef struct {
uint8_t ver;
uint8_t rep;
uint8_t rsv;
uint8_t atyp;
union {
struct {
in_addr_t addr;
in_port_t port;
} ipv4;
struct {
uint8_t len;
uint8_t str[];
} domain;
struct {
uint8_t addr[16];
in_port_t port;
} ipv6;
} bnd;
} reply_t;

代理服务器用一个 request 结构指针来访问接收缓存,可以很方便地解析客户端发来的请求,同时准备一个 reply 结构的指针来访问发送缓存,可以很方便地解设定应答的各个字段。

1
2
3
4
5
char recv_buf[1024];
char send_buf[1024];

request_t * req = (request_t *)recv_buf;
reply_t * rep = (reply_t *)send_buf;

代理服务器先检查 cmd,以确定客户端的需求,若服务器只支持 TCP 代理,可使用下面条件判断语句:在 cmd 不是 0x01 时,将状态码 rep 设为 0x07,表示命令不支持。

1
2
3
4
if (req->cmd != 0x01) {
rep->rep = 0x07;
printf("Command not supported.\n");
}

然后检查 atyp,以确定地址的类型,若代理服务器不支持 IPv6,可使用下面条件判断语句:在 atyp0x04 时,将状态码 rep 设为 0x08,表示地址类型不支持。

1
2
3
4
if (req->atyp == 0x04) {
rep->rep = 0x08;
printf("Address type not supported.\n");
}

📡 转发数据阶段

TCP 代理

这个阶段,代理服务器只需简单地在客户端和目标服务器之间转发数据。对于 TCP 代理,使用的连接是现有的连接,即前面进行握手和确定服务两个过程的连接。

下面函数是在两个套接字之间转发数据,将两个套接字的描述符将作为参数调用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

#define BUFF_SIZE 10240

void tcp_forward(int fd1, int fd2)
{
int maxfd = fd1 > fd2 ? fd1 : fd2;
maxfd++;
fd_set readfds;
struct timeval timeout;
int n;
uint8_t buf[BUFF_SIZE];
while (1) {
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
timeout.tv_sec = 10;
timeout.tv_usec = 0;
n = select(maxfd, &readfds, NULL, NULL, &timeout);
if (n > 0) {
memset(buf, 0, BUFF_SIZE);
if (FD_ISSET(fd1, &readfds)) {
n = recv(fd1, buf, BUFF_SIZE, 0);
if (n > 0) {
send(fd2, buf, n, 0);
} else {
perror("recv");
return;
}
} else {
n = recv(fd2, buf, BUFF_SIZE, 0);
if (n > 0) {
send(fd1, buf, n, 0);
} else {
perror("recv");
return;
}
}
} else if (n == -1) {
perror("select");
return;
}
}
}

UDP 代理

在第二阶段,客户端说明了其发送 UDP 数据报使用的地址和端口,代理服务器说明了其用来接收 UDP 数据报的地址和端口。至于目标地址和端口号,则是包含在每个 UDP 数据报中。

客户端必须在 UDP 数据报中给出目标服务器的地址和端口,否则代理服务器无法知道要将 UDP 数据报发往何处;代理服务器在收到目标服务器的响应数据后,也要在前面加上目标服务器的地址和端口后,才能返回给对应的客户端。

客户端发出的 UDP 数据报和代理服务器返回的 UDP 数据报使用相同的格式,具体如下:

rsv frag atyp dst.addr dst.port data
2 1 1 Variable 2 Variable
  • rsv 保留,无意义,固定为 0x0000
  • frag 分片的序号,一般为 0x00
  • atyp 下一个字段的类型
  • dst.addr 目标地址
  • dst.port 目标端口
  • data 数据

如果一个 UDP 数据报所携带的数据并不完整,完整的数据由若干个 UDP 数据报有序拼接而成,这种数据报就称为 分片,分片的序号通过 frag 字段给出。如果该字段被设为 0x00,则表示该 UDP 数据报不是分片,携带的是完整的数据。

在代理服务器和客户端之间传输的 UDP 数据报可用下面结构体封装:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct datagram {
uint16_t rsv;
uint8_t frag;
uint8_t atyp;
union {
struct {
in_addr_t addr;
in_port_t port;
} ipv4;
struct {
uint8_t len;
char str[];
} domain;
struct {
uint8_t addr[16];
in_port_t port;
} ipv6;
} dst;
uint8_t data[];
};

实际上,代理服务器要维护一个客户端和目标服务器之间的关系表,这样代理服务器才知道哪些 UDP 数据报对应是合法的,以及在收到目标服务器的响应数据时,才能正确返回给客户端。