前言
前段时间一直有个想法,做个内网多级代理的工具,更方便突破网络限制,然后就开始物色各种代理工具,如frp、nps等等,frp的稳定性很出色,他的代码结构和实现细节也很值得借鉴,但缺点也很明显,他并不是为了渗透而生的,所以在功能上有许多不太符合之处,比如不支持正向代理,代理转发等配置不支持热启动,不支持级联等等。然后有朋友推荐了stowaway,看介绍是venom的改进版,我测试了下功能,确实蛮符合渗透要求的,多级代理、上传下载文件、热启动正反向端口转发及socks代理,很灵活,当然要投入实战的话,还有不少需要改进的地方,所以就有了后面的改造计划。
代码分析
在做改造之前,先简单分析下他的代码,方便后续的改造。
项目地址:https://github.com/ph4ntonn/Stowaway
工具分为agent和admin,admin是一个console交互式程序,用于管理agent。
agent比frp小很多,也是一个好的点。具体功能可以参考项目readme,写的蛮详细的。
代码目录如下,admin和agent分为单独的目录实现功能
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | ├─admin │ ├─cli │ ├─handler │ ├─initial │ ├─manager │ ├─printer │ ├─process │ └─topology ├─agent │ ├─handler │ ├─initial │ ├─manager │ └─process ├─crypto ├─global ├─protocol ├─release ├─script ├─share ├─tools └─utils |
admin
目录
- cli: console交互函数
- handler: 消息处理函数,包括发送消息和接收消息处理(可看作任务)
- initial: 参数解析以及连接初始化
- manager:管理handler包的处理函数,用于协程间通信以及任务分发
- printer: log打印
- process: 主控程序,启动各个消息处理函数以及console函数,接收下游节点信息。
连接分成两个阶段,初始化和监听阶段,都在main.go
初始化阶段:
根据当前模式,是主动连接还是被动监听,发起密钥交互(我后面多加了一个websocket头部交互和tls封装),然后返回conn。
初始化函数放在initial包里。
initial包里有参数解析和认证。
监听阶段:
然后是最下面的admin.Run()
,启动各种处理函数
admin/process/process.go
其中go admin.handleMessFromDownstream(console)
主要用于下游agent消息接收,然后把接收信息通过channel传递给各个以Dispatch开头的消息处理函数。
这些消息处理函数主要发送消息给下游。
console.Run()
也会用于消息发送,是一个交互式shell用于操作。
所以看到这个结构。
admin.handleMessFromDownstream用于下游消息接收
Dispatch消息处理函数和console.Run()用于发送消息给下游,处理函数统一放在handler包里。
所以websocket的心跳包也在这里设计,添加一个DispatchKeepMess处理函数,用于定期发送数据给下游,保持会话。
而Dispatch处理函数又由manager包进行管理,通过该包进行协程间通信以及任务处理
agent
目录结构
其实和admin的目录结构差不多
- handler: 消息处理函数,在节点间发送信息
- initial: 参数解析以及连接初始化
- manager:管理handler包的处理函数,用于协程间通信以及任务分发
- process: 主控程序,运行各个消息处理函数以及接收节点信息。
和admin逻辑差不多。
启动管理端以及各个消息处理函数,最后运行handleDataFromUpstream处理上游数据
短连接接收数据错误
在增加功能前,我在测试功能时发现个问题,socks、正向端口转发、反向端口转发,在遇到http、redis爆破等各种短连接时,会出现用户端数据接收不完整的问题,比如web访问有的页面文件加载不出来,爆破无效果等,而如果是rdp等长连接却没有这个问题。
这个问题很影响代理,所以必须优先解决。
测试环境PC(192.168.111.112)、admin(192.168.111.1)、agent(192.168.100.18)、web(192.168.100.1)
socks代理,F12调试的时候发现无法加载的文件,提示都是ERR_CONTENT_LENGTH_MISMATCH,啥意思,就是响应包里的长度和body不一致。
点开一个查看,这里响应头是完整的,还有body长度
但body部分确实空的,这是怎么回事
抓包查看本地和socks之间的请求,响应确实只有header
客户端请求正常发送,但最后确实由服务端主动发送FIN请求,从而断开连接
然后看了其他正常接收的数据包,同样也是服务端发送的FIN请求,所以一个资源加载时灵时不灵,可能就和这个有关了。
我们来看下不过socks代理,正常的请求包里,FIN是由客户端主动发起,从而断开连接,所以问题很可能就在于因为连接是服务端主动断开的,而不是客户端控制的,导致数据未接收完整。
接着我测试了frp,frp是稳定正常的,并且和不挂socks一样的过程,FIN是由客户端发起断开的,那么这里其实很明显了,stowaway的连接机制有问题。
那么具体问题,我们跟踪下代码看看
admin/handler/socks.go#handleSocks
conn是和客户端的链接
读取客户端数据,这里conn.Close()是后来注释掉的
写入数据发送给客户端,这里调试的时候发现,agent完整传回数据给admin了,但这里写入居然报错,发现再写入最后数据时,conn已关闭。
然后找admin上关闭conn的位置,handlesocks里有几处close的地方,我注释掉了,但仍然还是有问题,就进一步跟踪conn。
在启用handleSocks前,conn存储在SocksTask结构体里,并传输给mgr.SocksManager.TaskChan
,
最后定位到admin/manager/socks.go#closeTCP
这里会关闭conn,closeTCP由谁调用呢
run()里,如果接收到agent发送的SocksTCPFin信号,那么就会强制关闭conn,那么后续就无法写入数据给客户端了。
梳理下通信过程,大致如下,
PC----socks---->admin----tcp---->agent----http---->web
agent(192.168.100.18)和web(192.168.100.1)之间,可以看到是正常的客户端发起FIN,但因为这里是最早结束请求断开连接的,那么agent会发送TCP FIN信号给admin,让admin也断开连接,这时admin接收到的web数据可能还没来得及返回给PC,就因为TCP FIN信号断开和PC的链接,导致数据接收不完整。
所以我将这段代码注释掉,由PC主动和admin断开连接,而不是agent通知。
而handleSocks里的conn.Close改成defer,编译测试,一切都正常了。
然后抓了PC到admin之间的socks流量,可以看到这里TCP FIN就正常了,由PC 192.168.111.112主动发起,而不像原来是由admin发起的。
总结下来就是,agent和目标先一步交互完数据并断开连接,这时目标返回的数据会通过agent发送给admin,同时agent还会发送一个TCP FIN信号给admin,此时就可能出现admin先一步处理了TCP FIN信号,断开了和PC之间的链接,导致数据无法返回给PC。
那么forward和backward应该也有一样的。
admin/handler/forward.go#DispatchForwardMess
修改后
backward是从agent端主动发起的请求,所以这里应该改的是agent端。
PS: 从上面的可以看出TCP FIN是双向都会发送的,调整都是根据请求方向,在请求侧做优化。
agent/handler/backward.go#DispatchBackwardMess
这里也是一样的现象,只不过是变成agent发起而已。
修改后
短连接接收数据错误-续
我将该问题、解决方案和作者沟通了下,作者给出了另一种方案,我觉得更合适点。
上面的操作是通过注释发送端的FIN信号,让请求者自己断开,但直接注释会导致不调用closeTCP,这样里面的channel不会关闭,导致无法释放
所以还是需要FIN信号,但如上在closeTCP里不调用conn.Close()
closeSocks里也注释掉
那么再哪里关闭呢,既然是因为接收后写给客户端不完整导致的,那么在如下位置关闭即可。
tcpDataChan在上面关闭了,但由于是一个非阻塞channel,那么如果还有数据会继续接收,直到为空后才会为false,接着就关闭conn了。这个思路更巧妙一点。
其他模块如上修改即可。
流量全加密
bug修复后,就可以开始改造了,作者在readme里说到该工具数据传输是通过AES加密的,所以就抓包看了下流量。
实际上该工具的流量只加密了payload部分,而header部分是明文的,比如THREREISNOROUTE
等等
这个问题其实好解决,只需要在原来的Conn外封装一层tls即可,这样其实payload都无需加密了。
这个参考frp修改即可,frp本身也有一个tls_enable的选项,他就是这个思路。
找到node之间连接的代码,搜索net.Dail或Accept(),如下在原来建立成功的conn对象后面,判断是否启动tls,然后调用WrapTLSClientConn封装即可。(为啥还要搞个选项,为了调试方式,不然全是密文,流量侧不好调试)
这里tlsConfig暂时没传递证书,可以改成自定义证书,防止tls指纹,最后一个options.Connect是sni。
1 2 3 4 5 6 7 8 9 10 | if proxy == nil && options.TlsEnable { // TODO: options.Connect不准确 tlsConfig, err := transport.NewClientTLSConfig("", "", "", options.Connect) if err != nil { printer.Fail("[*] Error occured: %s", err.Error()) conn.Close() continue } conn = net2.WrapTLSClientConn(conn, tlsConfig) } |
WrapTLSClientConn内部只是调用官方库的tls.Client来封装原来的Conn对象
1 2 3 4 5 6 7 8 9 | func WrapTLSClientConn(c net.Conn, tlsConfig *tls.Config) (out net.Conn) { out = tls.Client(c, tlsConfig) return } func WrapTLSServerConn(c net.Conn, tlsConfig *tls.Config) (out net.Conn) { out = tls.Server(c, tlsConfig) return } |
上面代码只是针对client主动发起连接,如果是listen的方式,代码有些许不同,在Accept()监听到连接后,需调用tls.Server来封装。
测试代码
1 2 3 4 5 6 7 8 9 | if Args.TlsEnable { tlsConfig, err := transport.NewServerTLSConfig("", "", "") if err != nil { printer.Fail("[*] Error occured: %s", err.Error()) conn.Close() continue } conn = net2.WrapTLSServerConn(conn, tlsConfig) } |
有多处需要封装的,如下是所有位置
修改后编译测试一波,可以看到与上面相比,流量全加密了
当然调用了tls加密,付出的代价就是文件比原来大了1兆多。这个是没法避免的,因为不止是为了加密,后面做过cdn也是需要tls,所以这个步骤是必须的。
admin如何置于后台
这里做个小tips,因为基本admin会被放在vps上,而vps大多是选择linux,所以就涉及一个问题,admin是console交互式,ssh连接退出就会影响admin运行,所以需要用到screen。
PS: screen不会直接把程序放到后台,而是先进入交互,手动置于后台。
输入screen,会直接进入一个新的bash交互
执行admin,进入admin交互界面
1./admin -l 9999或者跳过第一步,直接再screen后台跟命令也行
1screen -S ./admin -l 9999切换到后台
1ctrl+ad查看screen托管的隐藏进程
1screen -ls从screen中切换到某进程的前台
1screen -r 3721screen 进程树
编码处理
stowaway还有一个功能,shell执行系统命令,但就如上面的图显示存在乱码,这是因为go里面,默认是utf8,而windows是gbk。
方案一:
在admin上修改
shell在此处转换编码即可,或mgr.ShellManager.ShellMessChan发送处
在这个只是处理了在admin上显示的问题,如果admin输入带中文,agent上把UTF-8当成GBK执行就会乱码,无法操作中文路径等等。
方案二:
在执行的agent上修改,这样就能控制输入转换成gbk,而发送给admin的从GBK转换成UTF-8,admin上显示既不会乱码,agent执行的时候也能正常解析中文路径。
agent模块
parser.go增加字符集参数,除了自动识别,也可以手动指定。
如果没通过参数指定或者输入是错误字符集,则根据OS自动获取。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | // charset parser autoCharset := false if Args.Charset == "" { autoCharset = true } else { for _, i := range charsetSlice { if Args.Charset == i { goto manual } } autoCharset = true manual: } if autoCharset { switch utils.CheckSystem() { case 0x01: Args.Charset = "GBK" // cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} // If you don't want the cmd window, remove "//" default: Args.Charset = "UTF-8" } } |
agent/process/process.go
然后在分发函数这,将选项传入处理函数,这里其实就参考第一个处理函数才决定使用options操作,所以可以在做一些改动前,看看之前是怎么实现的,这样保证代码设计一致性。
agent/handler/shell.go
admin传入命令转换成设定编码
执行结果发送给admin前,将指定编码转换成UTF-8
注意count即接收字节大小也需要改动,否则会出现丢字符串的情况。
效果如下
这个操作其实没那么重要处理,因为命令执行在代理工具里不应该有,会增加特征导致被杀,命令执行就交给更专业的C2来实现。
这里只是实验性质的,用于后续其他处理函数需要做编码转换来做准备。
PS: 编码转换包后面换成了官方提供的golang.org/x/text/encoding/simplifiedchinese,这个打包出来会比gcharset小很多。
数据压缩
这个其实是想到frp也有这么一个功能,并且压缩数据对于传输来说很有意义,提高传输速度,尤其是一些大文件的传输。
这个修改其实很简单。
因为原来不是有一个数据加密吗,用AES对data进行加解密,而有了tls加密,这里的aes就无关紧要了,那么我们只需要替换这个加解密的位置,把数据从加解密变成解压缩就成了。
定位到加密位置
protocol/raw.go#ConstructData
替换成gzip压缩
解密位置
protocol/raw.go#DeconstructData
替换成gzip解压
至于gzip的实现,很简单,调用内置库gzip即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 | func GzipCompress(src []byte) []byte { var in bytes.Buffer w := gzip.NewWriter(&in) w.Write(src) w.Close() return in.Bytes() } func GzipDecompress(src []byte) []byte { dst := make([]byte, 0) br := bytes.NewReader(src) gr, err := gzip.NewReader(br) if err != nil { return dst } defer gr.Close() tmp, err := ioutil.ReadAll(gr) if err != nil { return dst } dst = tmp return dst } |
然后测试下压缩率
ipconfig /all: 6410->1136 17.7%
dir c:\windows\system32: 252599->51928 20.6%
fscan.exe(16M): 16539136->5855251 35.4%
测试压缩率还不错,总比没压缩的强上许多。
PS: 这个压缩是不包含header字段的,当然这个字段撑死也就是几十字节,1K都不到,不影响的。
总结
stowaway作为一个专门为渗透设计的代理工具,有很多方便的功能,本次改造通过代码分析、短连接bug修复、流量全加密、数据压缩等各方面进行讲解,也进一步熟悉了这款工具的实现逻辑,也为后续重构打下基础。
后续还会增加CDN穿透、多startnode功能、内联命令等等。
说个题外话,这次挑选了一款代理工具做改造,其实市面上有各种各样的代理工具,要寻找到一款趁手的工具,其实不太容易,所以就很需要自己做一些二开改造,有人可能觉得重复造轮子浪费时间,这个嘛,主要看工作需要,看你的方向,如果仅仅需要一个能用就行的代理工具,那其实有很多,而需要一个好用的工具,那么就需要根据自身需求做一些开发,一方面对工具的实现更透彻,另一方面不也是为了提升工作效率嘛,没有最好的,只要更适合自己的,这不仅仅局限于代理工具。在渗透整个攻击链上,其实各个环节,总有一个环节需要你去打造自己的工具的,关注漏洞利用,你自己编写一个脚本或者利用工具,会对漏洞成因更理解,并且能更深入优化漏洞的利用链,否则拿一个现成脚本跑一下,可能明明有漏洞而你却利用不成功,就像shiro被大家玩出花来了,而weblogic流传的一些工具其实也是有蛮多问题,诸如此类的太多了;又或者专门做漏洞挖掘,那你肯定会需要打造一个属于自己的审计工具,市面上的,不管是商用的还是开源的,总有不如人意的地方,总会需要编写自己的"codeql脚本"(这里具象举例而已),合入自己经验上的sink、source。一个合格的安全研究者应该也要是一个合格的开发。