前言
上一次就做了一些基础优化,这次需要改造的点复杂了一点,需要对项目代码解析更透彻点,话不多说,开始动手。
http(失败)
为了实现代理工具过CDN隐藏IP,先使用http封装来测试,最终失败了,这里记录下测试过程中的一些细节。
修改protocol包,分开为client和server。
修改后,还有一处问题,最开始有个预认证,主要是判断双方的key是否一致。
share/preauth.go
可以看到传入key,计算md5,取前16位判断。
这里仔细想了想,之前frp封装失败了,本以为是会话问题,但其实可能是这个流量模式问题,有时候会从服务端主动推流量给客户端,这样可能会导致cdn不转发流量,从而客户端无法接收。
而cs为什么可以做到正常通信,那是因为他没有服务端主动的行为,都是通过客户端定期心跳请求服务端来拉去指令,这样就和一个正常的http请求流程是一样的。
而frp的websocket模式能正常运行,可能还是websocket和http有所区别,服务端能主动请求客户端。
补充:后续查阅资料,websocket是双工模式,可以双向主动通信,而http只是单工模式,只能由client主动往server发送请求。
上面这些只是一些猜想,在该工具中还需进一步测试来确定。
通过nginx测试,服务端在建立后会返回两次响应包,第二个响应包发送回去因为ACK没变,判断为重传,导致报错。
这是没过nginx正常通信的数据包,确实两次
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是分为正向连接和反向连接,即listener和connect,在处理的时候需要考虑这两种场景。
这里就讲第一个节点和admin交互,其他基本类似。
admin/initial/method.go
admin也分为被动监听和主动连接两种情况。
说个题外话,admin一般都是监听,而主动连接什么时候用呢,比如一种完全不出网的场景,通过neoreg等web正向代理工具先进行第一层代理,后续如果需要进行内网多级代理,那么就需要agent监听,而admin连接上,从而组建代理网络。
这边先测试NormalPassive
在监听获取到agent发起的连接后,会通过PassivePreAuth进行密钥交互,那么在这之间就可以用来插入websocket第一次交互数据,并且套上tls。
这里是参考frp的,获取连接后,判断是否配置了tls,然后封装tls,接着根据协议,原先有raw/http,其实http没卵用,后续考虑删除,接着判断封装什么头部。
tcp就默认,websocket在进一步处理,为啥这样设计,因为后续可能加入kcp等等(这里其实考虑不周,udp的监听和这个conn根本不是一回事,后续再改呗)
补充:修复个bug,太傻逼了,需要改动成如下
1 | conn = WrapTLSServerConn(conn, tlsConfig) |
WebsocketListener代码逻辑比较简单,既然接收到连接,那么进一步就是获取agent发送的websocket头部,然后判断是否合法,合法就返回一个websocket响应头,这样就建立好websocket连接了,要wss就套一层tls就好了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 | defer conn.SetReadDeadline(time.Time{}) conn.SetReadDeadline(time.Now().Add(10 * time.Second)) // 接收数据 result := bytes.Buffer{} buf := make([]byte, 1024) for { count, err := conn.Read(buf) if err != nil { // 如果报错,判断错误类型,如果是超时则关闭连接返回err,如果是EOF则写入最后接收到的数据跳出循环。 if err == io.EOF && count > 0 { result.Write(buf[:count]) } else if timeoutErr, ok := err.(net.Error); ok && timeoutErr.Timeout() { conn.Close() return nil, err } break } if count > 0 { result.Write(buf[:count]) // 因为headers最终都是以双换行结束,所以判断到这个就直接break。 if bytes.HasSuffix(buf[:count], []byte("\r\n\r\n")) { break } } } // 解析请求头 req, err := http2.ParseRequest(result.String()) if err != nil { conn.Close() return nil, err } // 进行websocket协议校验。 key := req.Header.Get("Sec-Websocket-Key") if key == "" { conn.Close() return nil, errors.New("Sec-Websocket-Key is not in header") } // 生成nonce expectedAccept, err := getNonceAccept([]byte(key)) if err != nil { conn.Close() return nil, err } // 发送响应头部。 respHeaders := fmt.Sprintf(`HTTP/1.1 101 Switching Protocols Connection: upgrade Upgrade: websocket Sec-WebSocket-Accept: %s `, expectedAccept) respHeaders = strings.ReplaceAll(respHeaders, "\n", "\r\n") conn.Write([]byte(respHeaders)) return conn, nil |
BTW,CDN自然少不了domain参数,参考frp里说明,那么请求客户端自然需要带上。
agent/initial/method.go
agent也是一样的思路,找到密钥交互前的部分,插入头部。
这里就有个小坑了,conn获取后应该在第一时间判断是否封装tls,然后再交互协议头,不然tls就少封装第一次交互。
ConnectWebsocketServer实现代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | func ConnectWebsocketServer(addr string, tlsConfig *tls.Config, domainAddr string) (net.Conn, error) { // 生成 nonce nonce := generateNonce() expectedAccept, err := getNonceAccept(nonce) if err != nil { return nil, err } addrSlice := strings.SplitN(addr, ":", 2) if len(addrSlice) < 2 { return nil, errors.New("addr is error") } host := domainAddr + ":" + addrSlice[1] // 发起连接 conn, err := net.DialTimeout("tcp", addr, 10 *time.Second) if err != nil { return nil, err } if tlsConfig != nil { conn = WrapTLSClientConn(conn, tlsConfig) } // 发送websocket头 wsHeaders := fmt.Sprintf(`GET %s HTTP/1.1 Host: %s Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: %s Origin: http://192.168.111.1:8000 Sec-WebSocket-Version: 13 `, WebsocketPath, host, nonce) wsHeaders = strings.ReplaceAll(wsHeaders, "\n", "\r\n") defer conn.SetReadDeadline(time.Time{}) conn.SetReadDeadline(time.Now().Add(10 * time.Second)) conn.Write([]byte(wsHeaders)) // 接收server数据 result := bytes.Buffer{} buf := make([]byte, 1024) for { count, err := conn.Read(buf) if err != nil { if err == io.EOF && count > 0 { result.Write(buf[:count]) } else if timeoutErr, ok := err.(net.Error); ok && timeoutErr.Timeout() { conn.Close() return nil, err } break } if count > 0 { result.Write(buf[:count]) if bytes.HasSuffix(buf[:count], []byte("\r\n\r\n")) { break } } } // 校验server的websocket响应头是否有效。 resp := result.String() if !strings.Contains(resp, "Upgrade: websocket") || !strings.Contains(resp, "Connection: upgrade") || !strings.Contains(resp, "Sec-WebSocket-Accept: " + string(expectedAccept)) { conn.Close() return nil, errors.New("not websocket protocol") } return conn, nil } |
最终测试效果如下
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
测试成功
nginx前的数据包,加密没问题
nginx后的数据包
所以实际上实现websocket,从而达到过nginx等反代服务器的目的,也是蛮简单的。
当然测试看起来蛮正常的,却还有一个小bug需要解决,像CDN或者nginx服务器,对于长连接肯定是会有连接空闲超时限制的,这也是为了防止过多连接生成,而stowaway在保持长连接的过程中,只要不做任何操作,是没有任何流量的,这个作者也是为了降低流量侧的动静。但这也造成如果中间有一层反代的场景,就会在空闲过程中,判断client和server中间没有通信流量,超时自动断开长连接。
所以需要做一个心跳包,让反代服务器判断长连接一直在工作,而不会因空闲而断开连接。
遗留问题:
- 长连接要保持,是必须要有心跳包的,所以这里没有心跳包,存在超时断开的问题,待解决。
心跳包
基本心跳功能已实现,测试nginx反代场景不再存在超时断开问题。
这里心跳包目前设计是由admin定期发送给agent,也无需响应,emmm至于为啥这么设计,其实方法很多,这个也只是其中一个测试方法,后续待商榷。
admin/process/process_win.go的Run方法里添加一个新的消息处理函数,并且只有在开启了ws之后才使用。
for循环,默认每个10秒发送一次,判断conn有效,然后封装到Message结构体里,最后发送。
这里其实可以看出来通信协议的格式,header部分是固定的格式,而消息部分可以自定义任意结构体或者[]byte,这里先随便写个结构体,后续考虑是否扩展。
PS: isPass用于判断是否跳过结构体解析以及加密,直接以[]byte来传输。
绝大部分情况是false,表示不跳过处理。
如果不跳过,就会根据header.MessageType判断怎么序列化数据,在这里由于我没写Keeep的序列化方式,所以虽然传进来了data,但实际并没有传输,也就是说只传输了头部。
最后通过sMessage.SendMessage()发送数据
agent部分接收
agent/process/process.go
protocol.DestructMessage这里其实除了反序列化数据,还会进行数据接收,也就是将接收和反序列化一起处理了。
通过header.MessageType判断消息类型,选择怎么处理数据,比如SHELLCOMMAND就是将数据发送到channel里,给manager以及消息处理函数进行下一步操作。
而Keep这是暂时没做任何处理,只做接收。
并且这里可以看到判断header.MessageType之前,会先判断UUID是否和当前节点UUID一致,如果不一致则发送给其他节点。
如果是发送给其他节点,这里直接message.([]byte),转换成[]byte,emmm,上面不是解析成message结构体了吗,这边不会报错吗。
实际跟进DestructMessage,这里在根据DataLen读取到dataBuf后,会判断数据是否是发送给该节点的,如果不是则直接返回dataBuf,而不需要做解析。
心跳包目前实现就做了这些。
这里通过心跳包的实现总结下其他数据结构的协议交互逻辑。
- 发送方通过Dispatch消息处理函数序列化数据(protocol.ConstructMessage)发送消息(message.SendMessage),接收方通过handleDataFromUpstream或handleDataFromDownstream进行监听接收,调用protocol.DestructMessage进行接收和反序列化处理,然后判断消息类型,写入channel,由相应的接收方消息处理函数进行处理并响应。
实战测试
vps上admin监听444 wss端口
1 | ./linux_x64_admin -l 444 -tls -down ws |
agent发送wss请求到CDN提供的域名
1 | windows_x64_agent.exe -c xxxxxx.com:443 -tls -up ws |
效果如下,admin可以正常管理agent了,并测试shell、socks功能均正常,后续多投入一些实战项目中测试下。
多个startnode
原先作者只允许admin有一个startnode,其他agent是挂在第一个agent下,而不能实现同时有多个agent直连到admin,因为上面做了代理穿透CDN,所以必然会有多个startnode连接到admin,不然也不可能每一个startnode都单独设置一个CDN,那成本就太大了。
这个问题起初感觉很麻烦,因为和现在的设计逻辑不一样,现在是admin先监听等待,直到接收到连接,才会启动后续的各种处理函数。
重新梳理了一下现在的逻辑
- 监听agent连接
- 接收后将conn存储到全局
- 通过goroutine启动处理函数和console
那么修改的话,就需要将第一步的监听放到goroutine里循环接收,并且conn不能再存放全局了,得在Admin或Manager结构体里存储,否则多个startnode会冲突。
最后处理函数,如果在多startnode情况下,要处理各个startnode之间的消息,担心会冲突,因为原始的设计只考虑一个startnode,那么就把处理函数也放到监听的goroutine里,每次收到一个新的链接,单独启动这一个startnode的处理函数,每个startnode之间的处理函数都是完成独立的,就不用担心会出现问题了。
admin里增加一个admin监听函数,做个for循环,每次接收到连接后和之前一个startnode操作一样,并且把conn、uuid、token等放到当前startnode的manager对象里,方便调用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | func (admin *Admin) DispatchAdminListenMess() { var listener net.Listener switch admin.options.Mode { case initial.NORMAL_PASSIVE: printer.Warning("[*] Waiting for new connection...\r\n") listenAddr, _, err := utils.CheckIPPort(admin.options.Listen) if err != nil { printer.Fail("[*] Error occured: %s", err.Error()) os.Exit(0) } listener, err = net.Listen("tcp", listenAddr) if err != nil { printer.Fail("[*] Error occured: %s", err.Error()) os.Exit(0) } defer func() { listener.Close() // don't forget close the listener }() } for { var conn = new(net.Conn) var uuid string // 判断连接模式,发起认证请求 // TODO: 其他链接模式优化 switch admin.options.Mode { case initial.NORMAL_ACTIVE: initial.NormalActive(admin.options, admin.Topology, nil) case initial.NORMAL_PASSIVE: *conn, uuid = initial.NormalPassive(listener, admin.options, admin.Topology) case initial.PROXY_ACTIVE: proxy := share.NewProxy(admin.options.Connect, admin.options.Proxy, admin.options.ProxyU, admin.options.ProxyP) initial.NormalActive(admin.options, admin.Topology, proxy) default: printer.Fail("[*] Unknown Mode") } // TODO: mgr销毁 mgr := manager.NewManager(share.NewFile(conn, admin.options.Secret, uuid)) mgr.InitComponent(conn, admin.options.Secret, uuid, admin.options.Token) admin.Managers[uuid] = mgr go admin.handleMessFromDownstream(mgr) if admin.options.Downstream == "ws" { go handler.DispatchKeepMess(mgr, admin.options) } //go handler.DispatchAdminListenMess(admin.mgr, admin.options, admin.Topology) // run a dispatcher to dispatch different kinds of message go mgr.Run() go handler.DispatchListenMess(mgr, admin.Topology) go handler.DispatchConnectMess(mgr) go handler.DispathSocksMess(mgr, admin.Topology) go handler.DispatchForwardMess(mgr) go handler.DispatchBackwardMess(mgr, admin.Topology) go handler.DispatchFileMess(mgr) go handler.DispatchSSHMess(mgr) go handler.DispatchSSHTunnelMess(mgr) go handler.DispatchShellMess(mgr) go handler.DispatchInfoMess(mgr, admin.Topology) go DispatchChildrenMess(mgr, admin.Topology) } } |
Run里注释掉之前的处理函数,只留一个admin监听方法,当然console还是全局的,需要留给用户来交互操作。
由于每个处理函数的管理函数都是放在manager里的
所以manager也需要每个startnode独立,这里用map存储,uuid来区分保证唯一。
上述大致就完成了多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通信的,这里也是
这个函数会删除节点和子节点,并强制关闭每个节点的连接。
这里还增加了一个mgr.Clear()
为每个模块的管理函数都增加了一个Clear(),用于退出他们各自对应的处理函数
处理函数基本上都是一个for死循环,然后等待接收消息channel,那么我们只需要关闭这个消息channel,然后就不再等待阻塞直接返回nil,只需要判断message是否为nil,然后直接退出就行了,如下消息处理函数就是,其他基本一样。
那么Clear()只需要关闭channel即可退出。
最后就是在managers里删除该mgr。
这里应该是12的。
连接后
断开连接后,恢复到原始的goroutine,说明都释放了。
多测试了几次一样的结果。
emmm,做到这里,我想到另外一个点子,既然已经把listener做成多startnode,那么可否进一步做成热启动的方式来开启监听端口,在shell界面里通过指令开启多个端口监听或connect,来对接agent,反正都是相互隔离的,只有console是共享的,console再做成result api,通过web来交互就更方便了,其实感觉像cs了,但马的功能是不会扩展了。
非交互式shell
有一个半交互式shell命令了,为啥还扩展一个非交互式shell,主要是因为原来的shell命令有太多问题,容易导致卡死,而且作为一个代理工具,shell命令的存在是不可取的,从以上两点,我就砍掉这个功能了,那么遇到一些特殊情况还是需要执行命令,以备不时之需,所以做了一个非交互式run指令 ,当然也是了二次校验,用于提示操作存在风险。
在新增这个扩展指令的同时,算是把stowaway的指令处理逻辑搞清楚,跳过中间繁琐的代码分析过程,我把分析的结果整理了下,最终流程如下
添加一个新的指令,需要改动的动地方比较多
admin
- 新增收发结构体
protocol/protocol.go
protocol/proto/run.go
- 结构体的序列化和反序列化。
protocol/raw.go
PS: 这一块作者实现其实太冗余了,每个数据结构体都单独实现这个过程,我看了他通信的数据结构,其实格式都比较固定,写一个通用的序列化和反序列化函数,通过反射获取结构体中的tags即可,这个在protobuf里也是差不多的操作,1000多行的代码最终优化到200行了。
- 添加管理结构体
admin/handler/run.go
- 添加到主manager里,注意clear也得添加
admin/manager/manager.go
- 消息处理函数编写
上面是发送函数,下面是接收处理函数
发送函数可能通过接收函数调用或者console调用
接收函数一般是for循环,等待管理函数里的channel分发消息
handler/run.go
- 分发result消息到channel里
admin/process/process.go
- 调用收发函数
admin/process/process.go
消息发送函数这里是发在指令里调用。
admin/cli/interactive.go
- 注册指令
admin/cli/cli.go
agent
同理,但结构体已经写好,跳过这一步,从管理函数开始
- 创建管理函数
agent/manager/run.go
- 主manager里添加
agent/manager/manager.go
- 添加消息处理函数,这里的发送函数就是在分发函数里调用的
agent/handler/run.go
接收服务端指令
agent/process/process.go
- 调用分发函数
agent/process/process.go
最终测试如下
总结
这次改造实现了代理穿透CDN,并将原来的单startnode改造成多startnode,并尝试新增了一个指令,通过本次改造也是对http和websocket有了了解,并且对stowaway的指令处理流程也清晰了,而如果需要增加其他指令,也可以效仿这次的扩展,包括内联指令、强制重连等等操作。
这两次改造看下来可能觉得篇幅很长,这个主要出于个人习惯记录测试分析过程,所以看起来较长,但一些细节还是值得记录的,不然回头可能就不知道这个地方为什么这样改了。而且这些改造过程中涉及大量代码分析,虽然我说起来简单,但实际上像这种代理工具的代码逻辑还是需要花时间才能理清楚的,如果有师傅也觉得这个工具很nice,推荐自己动手改一改,然后可以参考下我这两篇文章,不然我感觉换一个没接触过这个工具的,看完这两篇文章也是一脸懵逼。这就和漏洞分析类似,没有自己动手分析过或者没有类似分析经验,是很难get到一些点,产生共鸣。当然如果大家觉得这些改造有什么新的思路,可以一起讨论讨论。