在之前几次攻防演练中,经常遇到红队利用suo5工具来搭建隧道的场景,例如哥斯拉插件一键注入suo5内存马等。
因此,希望通过分析该工具,结合zeek,对其进行检出。
suo5 是一个高性能 HTTP 隧道代理工具,它基于双向的 Chunked-Encoding 构建,旨在通过常规的网络通信协议,绕过网络限制,实现数据的隐蔽传输。其工作原理基于客户端与服务端之间的特殊通信协议,结合 SOCKS5 代理,实现对网络请求的转发和隧道化。
如果有一个webshell,想要进一步去探索内网,就可以借助该webshell搭建一个socks5代理,将流量通过该webshell传递到内网。
首先需要上传一个服务端的jsp文件到目标server中,也就是上图中的proxybridge,随后在本机的客户端连接该jsp文件,连接成功后,会在本地开启一个socks5服务(服务端不会),借助该服务访问更内层的server。也就是说,图中socks5handler需要实现socks5服务,并开启监听,如果有请求,就将信息封装为http内容发送给proxybridge,proxybridge解析好之后,与目标机器进行连接,并按照同样的方式将response最终返回给用户。
前面的功能reGeorg和Neo-reGeorg已经实现了,suo5与他们的区别在于他是基于Chunked-Encoding来构建的:
分块传输编码(Chunked transfer encoding)是超文本传输协议(HTTP)中的一种数据传输机制,允许 HTTP 由应用服务器发送给客户端应用(通常是网页浏览器)的数据可以分成多个部分。分块传输编码只在 HTTP 协议 1.1 版本(HTTP/1.1)中提供。
通常,HTTP应答消息中发送的数据是整个发送的,Content-Length消息头字段表示数据的长度。数据的长度很重要,因为客户端需要知道哪里是应答消息的结束,以及后续应答消息的开始。然而,使用分块传输编码,数据分解成一系列数据块,并以一个或多个块发送,这样服务器可以发送数据而不需要预先知道发送内容的总大小。通常数据块的大小是一致的,但也不总是这种情况。
客户端:在本地运行,开启一个 SOCKS5 代理服务器,监听本地指定端口(默认127.0.0.1:1111)。本地应用程序(如浏览器)通过配置 SOCKS5 代理,将网络请求发送给 suo5 客户端。
服务端:部署在目标服务器上,用于接收来自客户端的特殊 HTTP 请求,解析并处理后,与目标服务器建立实际的网络连接。
suo5支持全双工和半双工两种通信模式,以适应不同的网络环境和限制。
长连接:客户端与服务端之间建立 HTTP/HTTPS 长连接,能够同时进行数据的发送和接收。
效率较高:适用于服务端允许长时间保持连接的情况,数据传输效率较高。
短连接:每次需要发送或接收数据时,客户端都需要与服务端建立新的 HTTP/HTTPS 连接。
场景更多:适用于服务端或网络环境不支持长连接的情况,但相对效率较低。
-
检测ConnectMode:客户端向服务端发送一个HTTP请求,来确定采用全双工还是半双工模式。
-
测试隧道:通过客户端本地的socks5代理,发送一条测试请求到服务端,并开始建立连接。
将suo5.jsp上传到目标server(虚拟机)中,连接即可:
google开启socks代理,访问github:
接下来结合代码进行分析,聚焦于握手和建立连接的阶段。
assets目录下包含服务端需要部署的代码(后门脚本),支持.NET、Java和PHP3种语言 。
ctrl中的代码实现了客户端的核心控制逻辑,包括配置解析、网络通信、数据处理等。
netrans目录下包含了网络传输相关的辅助模块,包括自定义协议的实现、读写缓冲等。
直接在CheckConnectMode函数里下断点:
首先会随机一个数作为body体的数据长度,并确保长度大于32,接下来设置requestheader:
Referer忽略,UA可能是一个静态特征,往前找config:
如果不指定UA,默认值为:Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.1.2.3,到服务端代码中(suo5.jsp)能够看到,对UA存在校验,如果不一致,直接退出:
因此,如果要修改UA特征,需要在客户端和服务端同步修改。
这里的键值对是以常量的形式定义在handler.go代码中:
最后是body的部分,通这里的随机长度为260,body为随机值,将数据写入通道,再进入reader,最终由req发送:
content-type,UA以及请求体均和代码分析的一致,104是260的16进制,0代表数据到此结束。
在suo5.jsp中,接收到第一个http请求,会直接进入tryFullDuplex函数:
直接从请求体中读取前 32 个字节,并将读取的数据写入response,返回给客户端:
完成第一次交互之后,根据duration,来决定使用全双工还是半双工模式进行后续的数据传输(如果响应时间在3s内,说明请求没有被缓存,采用全双工模式):
书接前文,在CheckConnectMode之后,就确定好了通信模式:
接着启动socks5代理服务器srv,默认在本地1111端口:
创建 socks5Handler 实例来处理 SOCKS5 连接,再开一个协程,启动 SOCKS5 服务器,当有新连接时,调用 ClientEventHandler 的Handle方法,该方法又会调用 socks5Handler.Handle 方法处理连接:
按照前面的分析,会创建一个goroutine,来处理此次连接,跟进代码:
执行完client.Dial后,会起一个goroutine,是从gofunc()中的Serve开始的:
即ClientEventHandler.handle:
继续到Inner.Handle,即socks5Handler.Handle:
执行到handleConnect,这里CmdConnect是常量,重点跟handleConnect:
首先是一个8位的随机id,随后通过buildBody来生成具体的请求内容:
状态码ActionCreate为0x00,剩下的分别是随机id,host和port,并组合成一个字典:
netrans.NewDataFrame(marshal(m)).MarshalBinary(),先看marshal(m),再看netrans.NewDataFrame(xx),最后是xx.MarshalBinary().
先进行简单的序列化,[键长度1字节][键内容][值长度4字节][值内容][键长度1字节][键内容][值长度4字节][值内容] ...的形式:
生成一个新的数据帧,其中格式为数据长度,随机数和数据内容:
加密的逻辑是将body长度以大端方式写入前四个字节,第五个字节为秘钥(即NewDataFrame中生成的随机值),后面的每个字节都与秘钥进行异或操作。
返回的result最终作为body体发送给server:
这里的id为G7Xbi6lH,h为127.0.0.1,p为0,抓包如下:
00000119 33 32 0d 0a 32..
0000011D 00 00 00 2d 65 67 04 06 65 65 65 64 65 67 0c 01 ...-eg.. eeedeg..
0000012D 65 65 65 6d 22 52 3d 07 0c 53 09 2d 64 0d 65 65 eeem"R=. .S.-d.ee
0000013D 65 6c 54 57 52 4b 55 4b 55 4b 54 64 15 65 65 65 elTWRKUK UKTd.eee
0000014D 64 55 dU
0000014F 0d 0a ..
import struct
import io
def hex_str_to_bytes(hex_str_list):
hex_str = ' '.join(hex_str_list)
hex_str = hex_str.replace('n', ' ').replace('r', ' ').replace('t', ' ')
byte_arr = bytearray()
for byte_str in hex_str.strip().split():
byte_arr.append(int(byte_str, 16))
return bytes(byte_arr)
def unmarshal(data):
header = data[:5]
length = int.from_bytes(header[:4], byteorder='big')
xor_key = header[4]
print(f"Data length: {length}")
print(f"XOR key: 0x{xor_key:02X}")
if length > 1024 * 1024 * 32:
raise ValueError("invalid length")
encrypted = data[5:5+length]
decrypted = bytes([b ^ xor_key for b in encrypted])
print(f"Decrypted data: {decrypted.hex()}")
result = {}
i = 0
while i < len(decrypted) - 1:
key_len = decrypted[i]
i += 1
print(f"Key length: {key_len}")
if key_len < 0 or i + key_len >= len(decrypted):
raise ValueError("key length error")
key = decrypted[i:i+key_len].decode()
i += key_len
print(f"Key: {key}")
if i + 4 >= len(decrypted):
raise ValueError("value length error")
value_len = int.from_bytes(decrypted[i:i+4], byteorder='big')
i += 4
print(f"Value length: {value_len}")
if value_len < 0 or i + value_len > len(decrypted):
raise ValueError("value error")
value = decrypted[i:i+value_len]
i += value_len
print(f"Value: {value.hex()}")
result[key] = value
return result
hex_data_list = [
'33 32 0d 0a',
'00 00 00 2d 65 67 04 06 65 65 65 64 65 67 0c 01',
'65 65 65 6d 22 52 3d 07 0c 53 09 2d 64 0d 65 65',
'65 6c 54 57 52 4b 55 4b 55 4b 54 64 15 65 65 65',
'64 55',
'0d 0a'
]
data = hex_str_to_bytes(hex_data_list)
separator = data.find(b'rn')
if separator != -1:
data = data[separator+2:]
else:
pass
if data.endswith(b'rn'):
data = data[:-2]
try:
result = unmarshal1(data)
for k, v in result.items():
try:
v_str = v.decode('utf-8')
print(f"{k}: {v_str}")
except UnicodeDecodeError:
v_hex = v.hex()
print(f"{k}: 0x{v_hex}")
except Exception as e:
print(f"Error during decoding: {e}")
另外,这里针对全双工和半双工模式,加密代码是一样的,唯一的区别在于content-type,如果是全双工:
以全双工为例,根据content-type进行判断,进到processDataBio:
加密方式和客户端是一致的,看下newStatus:
Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.1.2.3
CheckConnectMode发送的第一个http请求中,content-type为application/plain
testTunnel发送的第二个http请求中:
response均包含:
在CheckConnectMode过程中,request发送一个大于32字节的随机值,response认证后,返回requestbody的前32字节。
0x03:心跳包(ActionHeartbeat)
在编写脚本过程中,发现无法匹配到testTunnel阶段的日志,修改代码如下:
执行结果表明,第一个HTTP请求被正确标记为check_mode,第二个HTTP请求未被重新分析,而是继承了之前响应的response标签,出现了错误。
进一步debug发现是http_message_done的触发有问题,长连接的状态下,response的event可能在request的evnet之前触发,导致在匹配request的body体时为空,检测失败:
解决方案:通过trans_depth来区分一个长连接中的不同流。
从检测的阶段来看,解密的部分有两块,分别为testTunnel的requestbody和testTunnel的responsebody,考虑到requestbody的长度更长,存储更多数据,可能会消耗更多性能,这里依赖于responsebody来作检测。
但是在zeek中直接进行异或计算来解密较为困难,并且性能开销相对较大。
在testTunnel的http请求中,response返回的body内容是固定的:
即,键值对的键为s,值为状态码,这里是actioncreate,值为00,所以response的body解密后的内容恒为s:0x00.在加密方式不变的情况下,密文数据的长度也是固定的,即12字节:
结合解密后键长度(1字节):0x01,键内容('s'):0x73,值长度(4字节): 0x00 0x00 0x00 0x01,值内容(0x00): 0x00
-
第8-11个字节与密钥异或等于0x00, 0x00, 0x00, 0x01(值长度)
可知:第8,9,10,12字节的值等于异或密钥(第5个字节),第6个字节的值和第11个字节的值相同(均为01XOR密钥)
-
使用了eventhttp_entity_data,每次遇到body都会触发
-
添加严格的前置匹配条件,不满足直接过滤或者删除session
-
先通过body长度筛选,如果满足条件,只储存需要匹配的最少字节数(32)
-
只检测stage2的流量(相比stage1更特殊,足够作为判断依据)
-
只检测response中前12字节是否符合特征(如果检测长度为12,需要接收到所有的responsebody,如果body过大很影响效率)
然后启动suo5client,修改默认ua,禁用心跳包,禁用gzip,修改默认本地端口,修改默认POST方法(改为GET):
表明在第一个http请求过程中,http_message_done事件,response先于request触发
原文始发于微信公众号(Crush Sec):攻防演练新利器:Suo5工具的深度分析与检测技巧
评论