stowaway改造计划2-websocket

    工具 lz520520 2年前 (2022-03-20) 1005次浏览

    前言

    上一次就做了一些基础优化,这次需要改造的点复杂了一点,需要对项目代码解析更透彻点,话不多说,开始动手。

    http(失败)

    为了实现代理工具过CDN隐藏IP,先使用http封装来测试,最终失败了,这里记录下测试过程中的一些细节。

    修改protocol包,分开为client和server。

    修改后,还有一处问题,最开始有个预认证,主要是判断双方的key是否一致。

    stowaway改造计划2-websocket

    share/preauth.go

    可以看到传入key,计算md5,取前16位判断。

    stowaway改造计划2-websocket

    这里仔细想了想,之前frp封装失败了,本以为是会话问题,但其实可能是这个流量模式问题,有时候会从服务端主动推流量给客户端,这样可能会导致cdn不转发流量,从而客户端无法接收。

    而cs为什么可以做到正常通信,那是因为他没有服务端主动的行为,都是通过客户端定期心跳请求服务端来拉去指令,这样就和一个正常的http请求流程是一样的。

    而frp的websocket模式能正常运行,可能还是websocket和http有所区别,服务端能主动请求客户端。

    补充:后续查阅资料,websocket是双工模式,可以双向主动通信,而http只是单工模式,只能由client主动往server发送请求。

    上面这些只是一些猜想,在该工具中还需进一步测试来确定。

    通过nginx测试,服务端在建立后会返回两次响应包,第二个响应包发送回去因为ACK没变,判断为重传,导致报错。

    stowaway改造计划2-websocket

    这是没过nginx正常通信的数据包,确实两次

    stowaway改造计划2-websocket

    emmm,如果是这样,我在想是否可以使用chunked编码,让nginx以为是分段传输,后续再测试。

    chunked测试失败。

    http策略取消。

    websocket

    可能也是之前cs的cdn方案影响,老是想着用http来实现,结果就拉了,像frp和stowaway都是全双工通信工具,不能使用http这种单工通信协议,但frp里通过websocket能实现过nginx等反向代理服务器,那么stowaway自然也可以。

    简单来说websocket是基于http改进的一种通信方式,只在第一次交互时携带http头部,后续通信直接传输数据就好,是一种长连接双向通信的方案,也可以说是所有双向通信工具的封装方案,现在CDN和云函数都支持websocket的,使得代理工具也能隐藏IP。

    http封装里说到第一次请求会有一个密钥交互过程,那么在这之前插入一个websocket是不是就ok了。

    先抓包看了下frp里的效果,其实说实话frp的整理码质量比stowaway高,解耦和模块化都很好,所以后续的改动就参考frp里的代码实现了。

    这里看到其实就是一个简单的http交互,只是多了一个key的校验。frp里调用的是golang.org/x/net/websocket库那做的,还有一个更完善的库github.com/gorilla/websocket,但因为stowaway在原始的raw已经做了不少处理了(如长连接、重连等等),就不需要这么完整的库来做,只需要简单处理下头部就好了。

    stowaway改造计划2-websocket

    值得注意的是,stowaway是分为正向连接和反向连接,即listener和connect,在处理的时候需要考虑这两种场景。

    这里就讲第一个节点和admin交互,其他基本类似。

    admin/initial/method.go

    admin也分为被动监听和主动连接两种情况。

    stowaway改造计划2-websocket

    说个题外话,admin一般都是监听,而主动连接什么时候用呢,比如一种完全不出网的场景,通过neoreg等web正向代理工具先进行第一层代理,后续如果需要进行内网多级代理,那么就需要agent监听,而admin连接上,从而组建代理网络。

    这边先测试NormalPassive

    在监听获取到agent发起的连接后,会通过PassivePreAuth进行密钥交互,那么在这之间就可以用来插入websocket第一次交互数据,并且套上tls。

    stowaway改造计划2-websocket

    这里是参考frp的,获取连接后,判断是否配置了tls,然后封装tls,接着根据协议,原先有raw/http,其实http没卵用,后续考虑删除,接着判断封装什么头部。

    tcp就默认,websocket在进一步处理,为啥这样设计,因为后续可能加入kcp等等(这里其实考虑不周,udp的监听和这个conn根本不是一回事,后续再改呗)

    补充:修复个bug,太傻逼了,需要改动成如下

    stowaway改造计划2-websocket

    WebsocketListener代码逻辑比较简单,既然接收到连接,那么进一步就是获取agent发送的websocket头部,然后判断是否合法,合法就返回一个websocket响应头,这样就建立好websocket连接了,要wss就套一层tls就好了。

    BTW,CDN自然少不了domain参数,参考frp里说明,那么请求客户端自然需要带上。

    agent/initial/method.go

    agent也是一样的思路,找到密钥交互前的部分,插入头部。

    stowaway改造计划2-websocket

    这里就有个小坑了,conn获取后应该在第一时间判断是否封装tls,然后再交互协议头,不然tls就少封装第一次交互。

    stowaway改造计划2-websocket

    ConnectWebsocketServer实现代码

    最终测试效果如下

    admin: listener 192.168.111.130:8082

    agent: connect 192.168.111.1:8443

    nginx: 监听ssl 192.168.111.1:8443,转发到ssl 192.168.111.130:8082

    测试成功

    stowaway改造计划2-websocket

    nginx前的数据包,加密没问题

    stowaway改造计划2-websocket

    nginx后的数据包

    stowaway改造计划2-websocket

    所以实际上实现websocket,从而达到过nginx等反代服务器的目的,也是蛮简单的。

    当然测试看起来蛮正常的,却还有一个小bug需要解决,像CDN或者nginx服务器,对于长连接肯定是会有连接空闲超时限制的,这也是为了防止过多连接生成,而stowaway在保持长连接的过程中,只要不做任何操作,是没有任何流量的,这个作者也是为了降低流量侧的动静。但这也造成如果中间有一层反代的场景,就会在空闲过程中,判断client和server中间没有通信流量,超时自动断开长连接。

    所以需要做一个心跳包,让反代服务器判断长连接一直在工作,而不会因空闲而断开连接。

    遗留问题:

    1. 长连接要保持,是必须要有心跳包的,所以这里没有心跳包,存在超时断开的问题,待解决。

    心跳包

    基本心跳功能已实现,测试nginx反代场景不再存在超时断开问题。

    这里心跳包目前设计是由admin定期发送给agent,也无需响应,emmm至于为啥这么设计,其实方法很多,这个也只是其中一个测试方法,后续待商榷。

    admin/process/process_win.go的Run方法里添加一个新的消息处理函数,并且只有在开启了ws之后才使用。

    stowaway改造计划2-websocket

    for循环,默认每个10秒发送一次,判断conn有效,然后封装到Message结构体里,最后发送。

    这里其实可以看出来通信协议的格式,header部分是固定的格式,而消息部分可以自定义任意结构体或者[]byte,这里先随便写个结构体,后续考虑是否扩展。

    PS: isPass用于判断是否跳过结构体解析以及加密,直接以[]byte来传输。

    绝大部分情况是false,表示不跳过处理。

    stowaway改造计划2-websocket

    如果不跳过,就会根据header.MessageType判断怎么序列化数据,在这里由于我没写Keeep的序列化方式,所以虽然传进来了data,但实际并没有传输,也就是说只传输了头部。

    stowaway改造计划2-websocket

    最后通过sMessage.SendMessage()发送数据

    agent部分接收

    agent/process/process.go

    protocol.DestructMessage这里其实除了反序列化数据,还会进行数据接收,也就是将接收和反序列化一起处理了。

    通过header.MessageType判断消息类型,选择怎么处理数据,比如SHELLCOMMAND就是将数据发送到channel里,给manager以及消息处理函数进行下一步操作。

    而Keep这是暂时没做任何处理,只做接收。

    stowaway改造计划2-websocket

    并且这里可以看到判断header.MessageType之前,会先判断UUID是否和当前节点UUID一致,如果不一致则发送给其他节点。

    如果是发送给其他节点,这里直接message.([]byte),转换成[]byte,emmm,上面不是解析成message结构体了吗,这边不会报错吗。

    stowaway改造计划2-websocket

    实际跟进DestructMessage,这里在根据DataLen读取到dataBuf后,会判断数据是否是发送给该节点的,如果不是则直接返回dataBuf,而不需要做解析。

    stowaway改造计划2-websocket

    心跳包目前实现就做了这些。

    这里通过心跳包的实现总结下其他数据结构的协议交互逻辑。

    1. 发送方通过Dispatch消息处理函数序列化数据(protocol.ConstructMessage)发送消息(message.SendMessage),接收方通过handleDataFromUpstream或handleDataFromDownstream进行监听接收,调用protocol.DestructMessage进行接收和反序列化处理,然后判断消息类型,写入channel,由相应的接收方消息处理函数进行处理并响应。

    实战测试

    vps上admin监听444 wss端口

    stowaway改造计划2-websocket

    agent发送wss请求到CDN提供的域名

    效果如下,admin可以正常管理agent了,并测试shell、socks功能均正常,后续多投入一些实战项目中测试下。

    stowaway改造计划2-websocket

    多个startnode

    原先作者只允许admin有一个startnode,其他agent是挂在第一个agent下,而不能实现同时有多个agent直连到admin,因为上面做了代理穿透CDN,所以必然会有多个startnode连接到admin,不然也不可能每一个startnode都单独设置一个CDN,那成本就太大了。

    这个问题起初感觉很麻烦,因为和现在的设计逻辑不一样,现在是admin先监听等待,直到接收到连接,才会启动后续的各种处理函数。

    stowaway改造计划2-websocket

    重新梳理了一下现在的逻辑

    1. 监听agent连接
    2. 接收后将conn存储到全局
    3. 通过goroutine启动处理函数和console

    那么修改的话,就需要将第一步的监听放到goroutine里循环接收,并且conn不能再存放全局了,得在Admin或Manager结构体里存储,否则多个startnode会冲突。

    最后处理函数,如果在多startnode情况下,要处理各个startnode之间的消息,担心会冲突,因为原始的设计只考虑一个startnode,那么就把处理函数也放到监听的goroutine里,每次收到一个新的链接,单独启动这一个startnode的处理函数,每个startnode之间的处理函数都是完成独立的,就不用担心会出现问题了。

    admin里增加一个admin监听函数,做个for循环,每次接收到连接后和之前一个startnode操作一样,并且把conn、uuid、token等放到当前startnode的manager对象里,方便调用。

    Run里注释掉之前的处理函数,只留一个admin监听方法,当然console还是全局的,需要留给用户来交互操作。

    stowaway改造计划2-websocket

    由于每个处理函数的管理函数都是放在manager里的

    stowaway改造计划2-websocket

    所以manager也需要每个startnode独立,这里用map存储,uuid来区分保证唯一。

    stowaway改造计划2-websocket

    上述大致就完成了多startnode,就是mgr都得调整下成独立的,以前调用全局GComponent的地方,都得改成调用manager里存储的。

    然后还有一个问题,监听函数每次接收到连接后用goroutine启动的处理函数,如果网络波动等原因和agent断开连接了,这个时候怎么退出这些函数来回收资源。

    PS: 有个重点需要注意,UUID,managers(map)里的key是UUID,这个UUID是startnode的UUID而不是原来的ADMIN_UUID,并且mgr.GComponent.UUID也是startnode,需要区分一下,startnode的父节点UUID都是ADMIN_UUID,可以以此来区分该节点是否是startnode。

    先定位到退出的位置,handleMessFromDownstream,把原来的os.Exit注释掉,然后原来在nodeoff后,会调用admin/process/children.go里的nodeOffline,handler和manager之间都是通过channel通信的,这里也是

    stowaway改造计划2-websocket

    这个函数会删除节点和子节点,并强制关闭每个节点的连接。

    stowaway改造计划2-websocket

    这里还增加了一个mgr.Clear()

    为每个模块的管理函数都增加了一个Clear(),用于退出他们各自对应的处理函数

    stowaway改造计划2-websocket

    处理函数基本上都是一个for死循环,然后等待接收消息channel,那么我们只需要关闭这个消息channel,然后就不再等待阻塞直接返回nil,只需要判断message是否为nil,然后直接退出就行了,如下消息处理函数就是,其他基本一样。

    stowaway改造计划2-websocket

    那么Clear()只需要关闭channel即可退出。

    stowaway改造计划2-websocket

    最后就是在managers里删除该mgr。

    这里应该是12的。

    stowaway改造计划2-websocket

    连接后

    stowaway改造计划2-websocket

    断开连接后,恢复到原始的goroutine,说明都释放了。

    stowaway改造计划2-websocket

    多测试了几次一样的结果。

    stowaway改造计划2-websocket

    emmm,做到这里,我想到另外一个点子,既然已经把listener做成多startnode,那么可否进一步做成热启动的方式来开启监听端口,在shell界面里通过指令开启多个端口监听或connect,来对接agent,反正都是相互隔离的,只有console是共享的,console再做成result api,通过web来交互就更方便了,其实感觉像cs了,但马的功能是不会扩展了。

    非交互式shell

    有一个半交互式shell命令了,为啥还扩展一个非交互式shell,主要是因为原来的shell命令有太多问题,容易导致卡死,而且作为一个代理工具,shell命令的存在是不可取的,从以上两点,我就砍掉这个功能了,那么遇到一些特殊情况还是需要执行命令,以备不时之需,所以做了一个非交互式run指令 ,当然也是了二次校验,用于提示操作存在风险。

    在新增这个扩展指令的同时,算是把stowaway的指令处理逻辑搞清楚,跳过中间繁琐的代码分析过程,我把分析的结果整理了下,最终流程如下

    添加一个新的指令,需要改动的动地方比较多

    admin

    1. 新增收发结构体

    protocol/protocol.go

    stowaway改造计划2-websocket

    protocol/proto/run.go

    stowaway改造计划2-websocket

    1. 结构体的序列化和反序列化。

    protocol/raw.go

    stowaway改造计划2-websocket

    stowaway改造计划2-websocket

    PS: 这一块作者实现其实太冗余了,每个数据结构体都单独实现这个过程,我看了他通信的数据结构,其实格式都比较固定,写一个通用的序列化和反序列化函数,通过反射获取结构体中的tags即可,这个在protobuf里也是差不多的操作,1000多行的代码最终优化到200行了。

    1. 添加管理结构体

    admin/handler/run.go

    stowaway改造计划2-websocket

    1. 添加到主manager里,注意clear也得添加

    admin/manager/manager.go

    stowaway改造计划2-websocket

    1. 消息处理函数编写

    上面是发送函数,下面是接收处理函数

    发送函数可能通过接收函数调用或者console调用

    接收函数一般是for循环,等待管理函数里的channel分发消息

    handler/run.go

    stowaway改造计划2-websocket

    1. 分发result消息到channel里

    admin/process/process.go

    stowaway改造计划2-websocket

    1. 调用收发函数

    admin/process/process.go

    stowaway改造计划2-websocket

    消息发送函数这里是发在指令里调用。

    admin/cli/interactive.go

    stowaway改造计划2-websocket

    1. 注册指令

    admin/cli/cli.go

    stowaway改造计划2-websocket

    stowaway改造计划2-websocket

    agent

    同理,但结构体已经写好,跳过这一步,从管理函数开始

    1. 创建管理函数

    agent/manager/run.go

    stowaway改造计划2-websocket

    1. 主manager里添加

    agent/manager/manager.go

    stowaway改造计划2-websocket

    1. 添加消息处理函数,这里的发送函数就是在分发函数里调用的

    agent/handler/run.go

    stowaway改造计划2-websocket

    接收服务端指令

    agent/process/process.go

    stowaway改造计划2-websocket

    1. 调用分发函数

    agent/process/process.go

    stowaway改造计划2-websocket

    最终测试如下

    stowaway改造计划2-websocket

    总结

    这次改造实现了代理穿透CDN,并将原来的单startnode改造成多startnode,并尝试新增了一个指令,通过本次改造也是对http和websocket有了了解,并且对stowaway的指令处理流程也清晰了,而如果需要增加其他指令,也可以效仿这次的扩展,包括内联指令、强制重连等等操作。

    这两次改造看下来可能觉得篇幅很长,这个主要出于个人习惯记录测试分析过程,所以看起来较长,但一些细节还是值得记录的,不然回头可能就不知道这个地方为什么这样改了。而且这些改造过程中涉及大量代码分析,虽然我说起来简单,但实际上像这种代理工具的代码逻辑还是需要花时间才能理清楚的,如果有师傅也觉得这个工具很nice,推荐自己动手改一改,然后可以参考下我这两篇文章,不然我感觉换一个没接触过这个工具的,看完这两篇文章也是一脸懵逼。这就和漏洞分析类似,没有自己动手分析过或者没有类似分析经验,是很难get到一些点,产生共鸣。当然如果大家觉得这些改造有什么新的思路,可以一起讨论讨论。


    Security , 版权所有丨如未注明 , 均为原创丨
    转载请注明原文链接:stowaway改造计划2-websocket
    喜欢 (42)