一直以来都对ss的底层实现原理非常感兴趣,但是却一直都没有去真正地学习研究过。这次恰好因为服务器被连封两次,借此契机,决定研究一下socks5代理,并写出一个属于自己的socks5代理服务器。
准备
应用架构
参考ss的结构,整个应用包含了两个部分——服务端和客户端。其中客户端的作用是为了实现与服务端之间的自定义通信。
socket编程的整体流程
socks5协议
socks5协议分为三个阶段,握手阶段、建立连接和传输阶段。
1. 握手阶段
在验证阶段,首先客户端会向服务端发送一个包含版本识别码和验证方法选择的消息。格式如下:
第一位是版本号,对于socks5协议固定是0x05
第二位是methods的数量,决定了后面methods的长度
最后是nmethods个method,表示客户端的验证方法列表,最常用的两个如下:
1 | 0x00: 无需认证 |
接下来,服务端从客户端的验证方法列表中选择一个,并返回给客户端,格式如下:
第一位是协议版本号,固定0x05
第二位表明服务端接受的客户端的验证方法。0xFF表示都不接受
如果这一步选择了无需验证,这个阶段就已经结束了。但是如果选择的是用户名和密码验证,那么接下来客户端还需要与服务端进行用户名密码的验证。
客户端先发给服务端一条包含用户名和密码的消息,格式如下:
这个请求包含了五个参数:版本号、用户名长度、用户名、密码长度、密码。
接下来服务端会响应客户端的请求:
status为0x00表示验证成功,0x01表示验证失败。
2. 建立连接
验证成功后,就需要正式建立连接了。这一步主要是客户端告诉服务端目标服务器地址。
首先客户端向服务端发送一条包含目标服务器地址的请求,格式如下:
CMD有三种情况,0x01表示CONNECT,0x02表示BIND,0x03表示UDP
RSV为保留字,固定为0x00
ATYP表示后面的地址类型,0x01表示IPv4地址,0x03表示域名,0x04表示IPv6地址
DST.ADDR表示目标主机地址,对于域名类型,第一位表示长度,对于IPv4和IPv6分为占4位和16位
DST.PORT表示目标主机端口
接下来服务器需要连接目标主机,然后对客户端的请求作出回应。回应格式如下:
REP取0x00表示正常返回,其他的表示各种错误,例如0x08表示地址类型不支持
BND.ADDR和BND.PORT表示服务器的地址和接口,当CMD为0x01的情况下,绝大多数客户端会忽略这两个字端
3. 传输阶段
传输阶段的时候socks5服务器存粹只是作为一个数据转发的工具,因为在握手阶段客户端就已经和服务器建立了一条socket通道,而建立连接阶段服务器也与目标主机建立了一条socket通道。
编码
为了简单起见,本次只写一个不需要认证且只支持TCP连接的socks5代理服务器和相应的socks5客户端。同样是为了简单,本次就不写界面了,只写一个命令行程序。
1. 命令行参数解析
参数是一个命令行程序的重要组成部分,我们可以通过这些参数来灵活地让我们的程序执行不同的功能。命令行参数分为两种,短参数和长参数。本文只介绍c语言中短参数的解析(因为简单)。
1 | int opt; |
c语言中,命令行的短参数解析是通过getopt这个函数来做的,每个字母代表一个参数,如果后面跟着冒号,则代表这个参数后面还有值。在我的程序中,-P [port]代表本地监听端口,-c/-s代表是作为客户端还是服务端启动,若果是作为客户端启动,那么还需要-h和-p参数分别代表socks5服务端的主机名和端口。
2. 打开监听端口
无论是对于socks5代理客户端还是socks5代理服务端,我们都需要打开监听端口。这里或许会有疑问,为什么socks5代理客户端也需要监听端口,其实从应用架构那张图中可以看到,socks5代理客户端在充当客户端的同时也充当了服务端的角色,它相对用户而言,又变成了服务端。那么下面是打开监听端口的代码:
1 | int createListeningSocket(int port) { |
3. 获取请求数据并进行处理
1 | void serverLoop(struct Config config, int serverSock) { |
在这里接收请求以及处理请求的过程放入了一个死循环里面进行处理。这块代码是个很神奇的代码,最开始我并没有使用fork()来写,直接在同一个线程里面执行了接收请求和处理请求的逻辑,发现也能正常跑通,但是网页加载速度却异常之慢,于是找到了这样一种很古老的解决方法,就是通过fork()的方式来处理。
fork()的方式有一块地方比较难以理解,从代码中我们可以看到子进程close了监听的socket,父进程close了用户端的socket。这里其实是利用了这样一个原理,调用了fork()之后,serverSock和clientSock这两个socket在父子进程间共享,只是两者的引用计数都变为了2,而关闭了不属于自己的socket之后,两个socket的引用计数都变为了1,并不会关闭socket文件。这样的话,父进程可以继续监听serverSock,子进程可以继续处理clientSock,两者互不干扰。
4. 处理请求
1 | void handleClientRequest(struct Config config, int clientSock) { |
1 | // 转发数据 |
从handleClientRequest()函数中可以看到整个处理过程分别对应socks5协议的三个阶段:握手、建立连接、传输数据。在我的设计中,处于简单考虑,socks5客户端部分只做简单地转发数据,至于握手和建立连接部分则全部由服务端来完成。
直接来看转发数据部分,转发数据设计成了单向的数据流,A->B和B->A方向的数据分别开出两个进程来处理,调用recv()不停地从src读取数据,每次读到数据后,都立即通过send()向dst发送完全相同的数据。
至于握手和建立连接部分,原理类似,同样是通过recv和send来收发数据,只不过中间根据socks5协议的内容多了一些逻辑操作。
5. 一些其他内容
僵尸进程
当子进程比父进程先结束,而父进程又没有回收子进程的情况下,就会产生僵尸进程。调用exit结束自己生命的时候,仅仅是使进程退出,仅仅限于将一个正常的进程变成一个僵尸进程,并不能将其完全销毁。
处理僵尸进程的一种简单的方式是忽略SIGCHLD信号。
1 | signal(SIGCHLD, SIG_IGN); |
或者使用waitpid这个函数来处理。
1 | while (waitpid(-1, NULL, WNOHANG) > 0); |
让程序后台运行
1 | pid_t pid; |
使程序后台运行的方式,就是通过创建一个子进程来处理主流程,然后干掉自身。
项目地址
https://github.com/lyytaw/tawdemo-socks5-c
参考
[1] SOCKS Protocol Version 5
[2] Username/Password Authentication for SOCKS V5