从零实现隧道穿透(四):隧道穿透原理详解

一、原理

项目地址为:https://github.com/ccccj/Socks5Server

不是网络之类的专业,所以了解的不太清楚,仅自己的理解。

  • 学网络的时候我们知道,若两个节点之间不支持 IPv6,比如3号节点与4号节点之间不支持,就需要在3号的前一个节点(2号),将整个 IPv6 的数据包,封装一层 IPv4 的头部,原本的头部+数据,全部变成新的数据。而在4号节点的后一个节点(5号),解封装 IPv4 的头部,将其重新变为 IPv6 数据包。

  • 这一段通道,我们称之为隧道。按我的理解来看,隧道就是对原本的数据包加一层封装,因此有很多不同的隧道。我这里写的是加一层 Socks5 协议的头部,将原本的目的 IP 隐藏的数据中,从而可以绕开防火墙,比如通常大家用 VPN 来翻墙。

  • 也就是 本地浏览器 A,将数据封装了 Socks5 的头部,发往 C 服务器(称之为 Socks5服务器),Socks5服务器需要对头部进行解析,把原本的数据剥离出来,再发给真正的目标服务器。若防火墙在 A、C之间,就可以绕开防火墙(绕开限制了目标 IP 的防火墙)。

  • 但是如果 本地浏览器 A、Socks5服务器之间还存在内容过滤,比如有的公司会监测员工平时浏览的网站,要想公司的防火前发现不了自己访问的数据,就需要在 本地浏览器 A、Socks5服务器之间再加一个 中转服务器B,对数据进行加密,加密后再将数据发给 Socks5服务器。若防火墙在 中转服务器B 和 Socks5服务器之间进行内容过滤,我们的加密就可以让其看不到自己真实想访问的数据。

  • 而 Socks5服务器此时要先将拿到的数据进行解密,再解析 Socks5协议,最后再将数据转发给目标服务器。目标服务器将返回的数据传给 Socks5服务器,由 Socks5服务器进行加密,再传给中转服务器B,B 对数据进行解密,最后返还给本地浏览器 A。

发送数据时的流程如下:

图中只显示了转发出去的流程,转发回来也是一样的。只是不需要 Socks5的认证了,两个服务器对数据进行加密或解密,再直接转发即可。

二、Transfe.cpp

先说明一下,Socks5服务器 等于 服务器C。

服务器B 上运行的 Transfer.cpp,主要实现加密和转发的功能。

在这里,我们给 服务器B 和 服务器C 各开放一个端口,假设给 服务器B 监听 5678端口,服务器C 监听7890端口。

浏览器会尝试连接 服务器B的5678端口,连上后将数据发送给 服务器B。而 服务器B需要 请求连接服务器C 的7890端口。

在 A->B 之间,可以理解为 A是客户端,B是服务器端。在 B->C 之间,可以理解为 B是客户端,C是服务器端。在 C->D 之间,可以理解为 C是客户端,D是服务器端。

最复杂的地方在于,我们要处理好并发的关系。在这里我选择了使用 epoll 来处理并发(在前面的博客里)。

epoll 要监测的事件分为三种:

  • 对于 main_fd 的监测,mian_fd 用于监听端口5678,当客户端尝试连接时,就触发了mian_fd的事件。对该事件的处理,也就是创建一个新的套接字描述符,用于接收客户端的数据;同时创建一个套接字描述符,用于请求连接Socks5服务器,用于后续的把数据加密后发给Socks5服务器;这时候两个套接字描述符进行epoll的注册,用于后续的对他们的读监听(两个都要是因为,无论是客户端还是Socks5服务器,都有可能给B发数据)。

  • 对于集合内文件描述符的读监听。无论是客户端还是Socks5服务器进行连接的套接字描述符,触发读监听时说明有数据发来,我们对其进行读(recv),并进行转发(发给另一端)即可。但发送时有可能发不完(比如缓冲区满了)。如果发生这种情况,需要我们将该文件描述符进行写监听,后续对方读取数据后,缓冲区可以放新数据了,这时候就会触发这个写事件(我们这里用的是非阻塞)。

  • 对于集合内文件描述符的写监听。是由于发送(send)的实际发送长度小于要发送的长度引起的。再次发送并判断是否发完即可。若没有发完,和上一步一样,将该文件描述符进行写监听。

这一步的代码如下:

   _events_fd = epoll_create(10000);
   events_ctl(_main_fd, EPOLL_CTL_ADD, EPOLLIN);
   struct epoll_event events[100000];
   
   while (1) {
       int num = epoll_wait(_events_fd, events, 100000, 0);
       
       for (int i = 0; i < num; ++i) {
           if (events[i].data.fd == _main_fd) {
               // 监测连接请求
               connect_handler();
           } else if(events[i].events & (EPOLLIN | EPOLLPRI)) {
               read_handler(events[i].data.fd);
           } else if(events[i].events & EPOLLOUT) {
               PriInfo("write events!");
               write_handler(events[i].data.fd);
           } else{
               // ...
           }
       }
   }

还有一个问题就是,由于连接请求都是并发的,我们要对B的每一个套接字描述符进行存储,比如,和浏览器A进行连接的是套接字描述符是9,对于9发来的数据,要发给C,和C连接的套接字描述符是10,那么9-10这一对套接字描述符要存储好。具体可以看github的代码。

同时,对于没发完的数据,也要记录该数据是哪个套接字描述符发来的,将要用哪个套接字描述符发出去。

最后,关于加密方式,由于我写的只是一个小demo,并未采取什么高端的加密方式,但改一下加密的函数即可。

三、SocksServer.cpp

Socks5服务器 和服务器B 最大的不同就是,要对 B 发来的认证(实际上是浏览器发来的)进行回复。

结合 VPN实现(三)来看。

    +---------+---------------+-----------+
    | VERSION | METHODS_COUNT |  METHODS  |
    +---------+---------------+-----------+
    |   0x05  |       1       |   '00'    |
    +---------+---------------+-----------+

第一次的认证,要判断第一个字节是不是第一个字节是不是 0x05。如果是的话,返回 0x05,0x00 一共两个字节即可。(0x00表示选择的不加密的认证)

    +---------+---------+-------+------+----------+----------+
    | VERSION | COMMAND |  RSV  | TYPE | DST.ADDR | DST.PORT |
    +---------+---------+-------+------+----------+----------+
    |    1    |    1    |   1   |  1   | Variable |    2     |
    +---------+---------+-------+------+----------+----------+

第二次的认证,要判断第一个字节是不是第一个字节是不是 0x05。如果是的话,再判断第四个字节 TYPE 的值是多少。因为我们不支持 ipv6,所以这个字段不能是 0x04(代表ipv6)。
如果这个字段是 0x01 的话,说明下一个字段是 ip 地址。所以读取下一个字段4字节,再读取2字节的端口即可。
如果这个字段是 0x03 的话,说明下一个字段是域名。

当然,我们每次读取发来的数据,都要先进行解密,才能读取。发回给 B 的数据,也要先加密再发。

在这一个过程中,我们已经知道了 ip 和端口,可以和目标服务器 D 建立连接。建立完连接,后面就可以发数据了。

因为我们不知道目前到了第几个认证步骤,所以要分成三个状态。第一个状态是 STEP1,代表还未进行认证。第二个状态是 STEP2,代表第一步的认证已经完成。第三个状态是 FORWARD,代表代表第二步的认证已经完成,已经可以进行数据转发了。

每次完成一个认证步骤,就将状态修改。

    if (con->_state == STEP1) {
            int ret = negotiation1(fd); // 自己写的认证函数
            if (ret == 1) {
                con->_state = STEP2;
            } else if (ret == -1) {
                // 失败
            }
        } else if (con->_state == STEP2) {
            char ret[10];
            memset(ret, 0, 10);
            ret[0] = 0x05;
            int right_fd = negotiation2(fd); // 自己写的认证函数

            if (right_fd > 0) {
                // 认证成功 ...
                con->_state = FORWARD;
            } else if (right_fd == -1) {
                // 认证失败 ...
            }
        } else if (con->_state == FORWARD) {
            // 转发数据
        } else {
            // ...
        }
    }

关于认证过程不细写了,可以看github的代码。

四、不足

1、两个服务器的很多功能还是比较相似的,可以进行复用,但是我写的比较粗糙,是单独的两个文件。
2、很多功能都不支持,比如不支持ipv6、不支持udp、不支持加密认证等等,很多情况都粗暴的报错处理了。
3、代码还是有一些bug存在,比如我的浏览器插件,只会发送域名而不是ip的情况,发送ip的情况并没有测试bug。
4、加密用的异或加密,主要是方便,因为不管加密还是解密代码都一样,不用传参细分..但实在是有些low了。

参考:
https://www.cnblogs.com/0xl4k1d/p/15664414.html

哈哈哈哈哈哈