前言
前段时间一直有个想法,做个内网多级代理的工具,更方便突破网络限制,然后就开始物色各种代理工具,如frp、nps等等,frp的稳定性很出色,他的代码结构和实现细节也很值得借鉴,但缺点也很明显,他并不是为了渗透而生的,所以在功能上有许多不太符合之处,比如不支持正向代理,代理转发等配置不支持热启动,不支持级联等等。然后有朋友推荐了stowaway,看介绍是venom的改进版,我测试了下功能,确实蛮符合渗透要求的,多级代理、上传下载文件、热启动正反向端口转发及socks代理,很灵活,当然要投入实战的话,还有不少需要改进的地方,所以就有了后面的改造计划。
在做改造之前,先简单分析下他的代码,方便后续的改造。项目地址:https://github.com/ph4ntonn/Stowaway
工具分为agent和admin,admin是一个console交互式程序,用于管理agent。agent比frp小很多,也是一个好的点。具体功能可以参考项目readme,写的蛮详细的。
代码目录如下,admin和agent分为单独的目录实现功能
├─admin
│ ├─cli
│ ├─handler
│ ├─initial
│ ├─manager
│ ├─printer
│ ├─process
│ └─topology
├─agent
│ ├─handler
│ ├─initial
│ ├─manager
│ └─process
├─crypto
├─global
├─protocol
├─release
├─script
├─share
├─tools
└─utils
目录结构
连接分成两个阶段,初始化和监听阶段初始化阶段:根据当前模式,是主动连接还是被动监听,发起密钥交互(我后面多加了一个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包进行管理,通过该包进行协程间通信以及任务处理
目录结构其实和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#handleSocksconn是和客户端的链接读取客户端数据,这里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。
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对象
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来封装。
测试代码
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,所以这个步骤是必须的。
这里做个小tips,因为基本admin会被放在vps上,而vps大多是选择linux,所以就涉及一个问题,admin是console交互式,ssh连接退出就会影响admin运行,所以需要用到screen。PS: screen不会直接把程序放到后台,而是先进入交互,手动置于后台。
输入screen,会直接进入一个新的bash交互
执行admin,进入admin交互界面
./admin -l 9999
或者跳过第一步,直接再screen后台跟命令也行
screen -S ./admin -l 9999
切换到后台
ctrl+ad
查看screen托管的隐藏进程
screen -ls
从screen中切换到某进程的前台
screen -r 3721
screen 进程树
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自动获取。
// 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.goadmin传入命令转换成设定编码
执行结果发送给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即可。
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功能、内联命令等等。