文章首发于现在社区:https://xz.aliyun.com/news/91645
项目地址:https://github.com/onedays12/BeaconCascading
不知道各位师傅是否对Beacon的级联感到好奇呢?Beacon级联(Beacon Cascading)是现代C2框架(如Cobalt Strike、Sliver、Havoc等)中最精妙的设计之一,在边缘出网主机上建立一个pivot(支点,其实对于第一个支点我更习惯称为网关),之后并从这个支点向内网纵深,扩大战果,继续建立支点,直至将内网不出网主机级联形成树状的控制代理链。其实Beacon的级联它对比于socks代理、端口转发那样作为一个独立的服务组件存在,它更像是从网络隧道概念中移植一小部分功能融入到整个Beacon的生命周期,让Beacon本身成为一个应用层路由器。
一、基础概念
1.1 级联协议
当然并非所有的通信协议协议都能承当级联网络的基底,在现代C2框架中常常将TCP、SMB作为Windows内网域中的级联协议。
TCP Beacon之所以成为通用选择,不仅因为其全双工特性,更在于它的协议无状态性为封装提供了最大自由度,充当着不同协议的通用胶合层,适用在不同的操作系统。具体体现为当父Beacon作为gateway,将Team Server的HTTP/DNS流量转换为内部私有协议,再通过TCP socket连接透传给子Beacon。除此之外,在AD域中也存在TCP+SMB的组合形成级联。
SMB Beacon(命名管道)的价值在于利用Windows域的信任基础设施,与TCP一样,SMB也可以充当着不同协议的通用胶合层,但它更多的是用在Windows上。在AD环境中,SMB流量比异常的TCP端口连接更”正常”——文件共享、组策略更新、认证票据交换都依赖445端口。更关键的是,SMB级联支持无端口监听:子Beacon通过连接父Beacon暴露的命名管道实现反向级联,这在主机层不留TCP监听端口,规避了端口扫描的风险。
本人将以对网络代理一窍不通的小白身份初步学习网络代理方面的编程知识,力求以最简单有效的代码还原出级现代C2的级联功能。
1.2 特定术语
为了使级联网络易于维护和管理,它应该满足两条规则,请记住两条规则是整个网络运行的底层根基!
- 规则1:一个节点有且只有一个父节点
- 规则2:一个节点可以有多个子节点
看着这两条规则,是否能让各位想到数据结构上的树呢?没错最初的思路就起源于树的拓扑结构。为什么是树而非图呢?这很好理解:路由算法的恶梦,由于图允许节点拥有多个父节点,导致server与目标节点的路径不唯一,需要的路由算法代码量和复杂度高了好几倍,我只是学习一下级联网络而已没必要引入图这种复杂的数据结构。
由上面的两条规则很容易就构造出一个级联网络拓扑图出来。注意:在实际的C2服务器编写中,监听器(listener)才是与各个gateway保持着物理连接的角色,为了描述方便我下图中的拓扑结构忽略了监听器。

从图片中可以找到以下路径
- 路径1:server<->getway1<->pivot1<->pivot4
- 路径2:server<->getway1<->pivot2
- 路径3:server<->getway2<->pivot3
- 路径4:server<->getway3
其中路径1是非常典型的三级网络,路径3是典型的二级网络,下文将围绕这两条路径编写代码和运行过程的分析,不过在进行代码编写之前,有必要说几个定义:
(一)Gateway (网关节点):与 Teamserver 建立直连连接的 Agent,作为整个代理树的根锚点,所有来自子树的流量必须经过Gateway上报。
(二)Pivot (跳板节点):通过父节点间接注册到 Server 的 Agent,处于代理链中间层或末端
(三)寻路算法(路由算法):基于源路由 思想的逐跳封装,即从目标节点从下往上寻找父亲节点,直到寻找到Gateway节点
(四)连接上游(Upstream):维护与父节点的唯一连接(很好的体现了规则1),作为控制通道和数据出口,主要负责接收来自上游的数据、识别指令以及下行转发
(五)管理下游 (Children):维护到多个子节点的连接池(体现了规则2),实现代理链的纵向扩展,主要负责 上行封装 以及 本地自治
(六)级联网络(多级代理):一种基于树形拓扑构建的分层代理架构,通过严格遵循”单父多子”的约束规则,将原本直接暴露的C2通信链路重构为逐跳封装的代理链。
二、TCP级联
2.1 CS的Connect命令
在本篇文章我不会详细的介绍代码的编写,更侧重于流程分析数据流向,具体的源码就从github获取。
TCP是C2常见的一种控制协议,在《C2通信协议解析(一):HTTP(s)、mTLS、WebSocket、DNS-先知社区》我就详细的介绍了TCP协议的流量分帧、命令下发、命令执行和结果回显等C2协议中必备的流程,而在本文中会介绍一种更高级的玩法——TCP级联。
TCP级联与socks代理的实现非常相似,都具备数据转发的能力。对于TCP级联,sever知道全局拓扑,而对于socks代理,每个节点相当于一个代理服务器且只负责数据的透明传输,当然这两者于我而言并没有太大的差异。
为了引出TCP级联的相关概念,又要请出安全界的大杀器——CobaltStrike了,它有一个神奇的命令 connect,可以通过tcp连接将处在隔离网段的目标形成级联网络,查看命令帮助。
1 | beacon> help connect |
靶机环境
①靶机1
- 网段1(出网网段):192.168.3.0/24
- 网段2: 192.168.52.0/24

②靶机2
网段2:192.168.52.0/24
网段3:192.168.93.0/24
③靶机3
网段3:192.168.93.0/24
网段4:192.168.174.0/24

步骤
(一)生成三个监听器:①http类型的监听器,server监听192.168.3.1:4444;②tcp bind类型监听器,beacon监听器0.0.0.0:4444;③tcp bind类型的监听器,beacon监听器0.0.0.0:5555

(二)生成三个beacon,并放置到相应的靶机上:http类型的beacon放置到靶机1中,监听4444端口的beacon靶机放置到靶机2中,监听5555端口的beacon放置到靶机2中
(三)级联操作
当靶机1上线到CS后,输入命令:connect 192.168.52.3 4444
靶机2上线到了CS,可以看到明显的级联标志。

在靶机2上,输入命令:connect 192.168.93.3 5555

此时他们的网络拓扑如下,箭头的指向可以清晰的看出是谁主动连接谁的。

上述的展示体现了TCP可以将不同隔离网络段的主机级联起来,还有也说明TCP确实可以充当不同C2协议类型的Beacon之间级联的桥梁,即server(listener)<->HTTP/DNS Beacon<-通过TCP socket连接->TCP Beacon。
2.2 数据结构定义
(一)server
如果对C2框架编写熟悉的师傅可能会了解到Teamserver结构体通常会定义一个键为agentid,值为Agent实例的map。而Agent实例里包含了很多Agent的信息,比如连接信息,目标主机的信息,进程的信息等等,这些信息都是为了Teamserver能够更好的管理、控制和维护agent,为了能够构造出级联网络,并根据路由算法找到目标节点,有必要在Agent结构体里增加一个 Pivots 类型的成员。
Pivots 结构体包含了一个节点的父亲节点和所有子节点。这样Server就可以凭借着存放所有Agent对象引用的优势,并进一步根据 Pivots 结构体知晓各个Agent的关系从而得到整张网络拓扑。
具体在 server.go 中定义如下的结构体
1 | // --- Metadata 结构 --- |
Teamserver:通过map包含了所有agent的信息,其主要作用是C2服务器的结构体会有一个健为agentid,值为agent的map。当agent不在map中,就注册;如果有就下发相应的任务给agentAgent:包含了全局唯一标识、元数据、支点信息和物理传输连接,其中仅Gateway持有与server的物理传输连接,而支点的物理传输连接为空,这并不意味着Agent不持有物理连接,只是对于server.go中的Agent结构体不拥有物理连接,而pivot.go的Agent是包含了上游物理连接和下游物理连接,这一切的原因都是取决与你是Server 视角还是Agent 视角。Pivots:Parent *Agent是一个指针,只能指向一个上级 Agent。当Server需要向某个深层 Pivot 发送命令时,通过Parent指针回溯到 Gateway,构建源路由路径,并根据路径打包任务包。Links []*Agent是一个切片,可以存储多个下级 Agent。Links的作用主要是当gateway掉线时,递归清理子树,保证路由通畅
在Server 视角的Agent结构体各字段的取值
| 字段 | Gateway(网关) | Pivot(跳板) | Leaf(叶子) |
|---|---|---|---|
AgentID |
自生成 uint32 | 自生成 uint32 | 自生成 uint32 |
Conn |
✅ 持有TCP连接 | ❌ nil(通过父转发) | ❌ nil |
Pivots.Parent |
nil(根节点) |
指向父Agent指针 | 指向父Agent指针 |
Pivots.Links |
包含所有子节点 | 可能包含子节点 | 空切片 [] |
总结起来,server.go 上的结构体定义能做到agent的注册与管理、级联网络维护、任务下发和结果处理,因为缺少具体的例子,在这里说的可能有点抽象难懂,结合下文的通过 2.4 通过Connect命令级联的流程分析 和 2.5 通过Exec命令分析数据流向 的流程分析能更好的理解这些结构体的作用。
(二)agent
在 pivot.go 文件中,定义了一个 Agent 结构体,作为一个支点,它应当持有上游物理连接和下游所有直连的子节点的物理连接,这样才能符合树的定义,所以需要在 Agent 结构体定义 Upstream net.Conn 和 Children sync.Map // Key: uint32 (RouteID) 保存 net.Conn 类型的连接。设计实现了 “每个节点只维护自己的邻居” 的分层路由,符合网络协议的分层思想(数据链路层只关心下一跳)。
1 | type AgentMetadata struct { |
其中 Children sync.Map // Key: uint32 (RouteID) 令人感到好奇,为什么出现了RouteID并且以RouteID作为键呢?
RouteID是一个很重要的概念,由父节点生成,作为 Children sync.Map 的键。我们不能用Agentid作为 Children sync.Map 的键,原因是父节点不能(不应该)直接解析子节点的metadat获取到agentid,任何来自下游节点的数据只做转发操作,具体注册以及网络拓扑的维护交由server来处理。
其server.go中的 RegisterPivot 方法完成了routeid和agentid的映射。

比如agent1的agentid = 10000,agent2的agentid = 10001,当agent1通过connect命令连接agent2后,在本地生成routeid = 86697,当agent2完成上线server注册后,此时Server知道 10001 ↔ 86697 的映射,Agent1只知道 86697 → 与agent2的conn 的映射,权限分层是非常清晰的。

当然也不是绝对不能获取到agentid,只是要改一下addpivot的代码,具体来说就是agent1连接到agent2后,agent2立刻发送携带自己agentid的数据包给agent1,agent1以此id作为map存储conn,但是这样做会产生 时序问题,即阻塞等待,存在窗口期,而以routeid作为键在建立连接后就具备数据转发的能力。
以routeid作为键
1 | func (a *Agent) AddPivot(addr string) { |
以agentid作为键
1 | func (a *Agent) AddPivot(addr string) { |
两个方案孰优孰劣就由各位师傅评说了。
2.3 数据包构造规则
在我的前几篇文章就说过,整个C2控制流程中会产生各种类型的数据包,agent上线会发送一个注册包,server会给agent下发任务包,agent执行完命令回返回结果包
在本文我们简化一下数据包的构造,定义一个非常经典的TLV自定义协议结构
然后统一使用下面的代码代码构造与读取。
1 | // --- 协议工具 --- |
在这里补充一下协议常量(CMD_TYPE),代表着控制命令,每个控制命令是如何运转的下文会重点分析。
1 | // --- 协议常量 --- |
2.4 通过Connect命令级联的流程分析
server的大致执行流程
1 | main |
agent的大致执行流程
1 | main |
再次拿上文的图来作为示例分析

- 路径1:server<->gatetway1<->pivot1<->pivot4
- 路径3:server<->gatetway2<->pivot3
2.4.1 二级网络级联
二级级联是三级级联的基础,三级级联的内容衔接二级级联的后面。
(一)gatetway注册和pivot1启动监听
我们分别运行server.go、gateway.go和pivot1.go
当gateway主动连接服务器的8888端口时后,接下来根据 register 方法构造一个注册包给server

register 方法的主要逻辑就是收集metadata,使用 WritePacket 构造注册包并发送注册包。


此时注册包的结构为 [len,占4B][CMD_REGISTER,占1B][data,占lenB]
server的 HandleAgent 方法处理来自Agent的所有请求,此时接收到来自Agent的注册请求,将此Agent(此时扮演gateway)注册到服务器里。

注册的逻辑很简单,首先进行ID冲突检测与处理,创建Agent对象,对于直接连接到Teamserver 的Agent,保留 Conn 用于直接通信,对于非Gateway通过其他Agen 转发的节点,不保留直接连接。

紧着将agent对象存储到ts的map里,最后返回Agent对象引用。

请记住通过 RegisterAgent 注册的Agent称之为gateway(网关),在 HandleAgent 编写一个for循环不断地处理来自网关的所有回传数据,这其中包括了gateway执行命令的结果和来自下游的转发回传给server的数据。

(二)server下发connect命令连接pivot1
在server的控制台处输入命令connect:connect 133002885 127.0.0.1:9999 让gateway主动连接pivot1的9999端口,133002885 是gateway的agentid,你想让那个agent执行命令就输入对应的agentid即可。

接下来通过 SendCommand 方法给gateway发送任务包。

这一部分是路由算法的精髓所在,且听我慢慢道来,首先通过 GetPath 返回一个从目标到gateway的节点链(需要反向遍历,即从父节点向上寻找)。

假设拓扑如下:

- 我需要寻找pivot1到gateway1的路径,则
GetPath返回[pivot1, gateway1] - 我需要寻找pivot4到gateway1的路径,则
GetPath返回[pivot4, pivot1, gateway1] - 我需要寻找pivot3到gateway2的路径,则
GetPath返回[pivot3, gateway2]
在本此调试中,我们需要给gateway发送任务包,则 GetPath 返回 [gateway],也就是说任务包没有经过任何转发就到达了目的地。

接下来根据path逐层封装转发包。因为 path = [gateway],不符合循环条件,封装发生的前提是:len(path) >= 2(至少有一个中间跳转节点),所以没有进行如何封装,packet为 [len1][CMD_CONNECT][127.0.0.1:9999]。

server将connect命令包发送到gateway,由gateway的 HandleUpstream 进行处理,进入 CMD_CONNECT 条件分支会给 AddPivot 启用一个 goroutine。

AddPivot 负责与子节点建立TCP连接,并给 handleChild 方法启用一个 goroutine 专门处理来自下游节点的数据。

请注意对于Agent而言,WritePacket 方法都是向上游写数据,而对于Server的 WritePacket 方法就没有这方面的要求。下图就是agent的WritePacket方法。

(三)pivot1注册
当gateway与pivot1建立连接后,pivot1会立刻调用 register 返回一个注册包给gateway。
继续跟进 handleChild 方法,在前面我说过,任何Agent接收到来自下游子节点的数据都不应该解析,而应该转发给上游父亲节点。构造数据包:
- 最内层packet1:
[len1,占4B][CMD_REGISTER,占1B][data1,占lenB] - 最外层packet2:
[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B][data2],data2 = packet1 - 合起来:
[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B]{[len1,占4B][CMD_REGISTER,占1B][data1,占lenB]}

当gateway转发了pivot1的注册包,则server可以从server与gateway的TCP连接中读取到该注册包,然后交由 processMessage 处理。

注册包的结构是:[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B]{[len1,占4B][CMD_REGISTER,占1B][data1,占lenB]},此时最外层的CMD_TYPE = CMD_FORWARD,则会进入到CMD_FORWARD条件分支。

又因为最内层的数据包为 [len1,占4B][CMD_REGISTER,占1B][data1,占lenB],CMD_TYPE = CMD_REGISTER,进入到了注册分支,然后通过 RegisterPivot 注册到Server里。在这里注册的都是拥有父节点的支点,所以要传入routeID以便支点的agentid与父节点生成routeID建立映射关系,也就是说子节点知道自己在父节点的“门牌号”,当然我这里说的知道是server这一侧的Agent视角。

RegisterPivot 的主要逻辑是验证父节点存在(Pivot必须依附于已存在的Agent),检查 AgentID冲突,创建Pivot Agent对象,更新父节点的子节点列表,最后注册到server的全局map里。

至此二级网络的级联流程就分析完了,总结一下数据流向:
- gateway向server发生注册包:
[len,占4B][CMD_REGISTER,占1B][data,占lenB] - server发送connect任务包给gateway:
[len1][CMD_CONNECT][127.0.0.1:9999] - gateway向pivot1建立连接后,pivot1发送注册包给gateway:
[len1,占4B][CMD_REGISTER,占1B][data1,占lenB] - gateway转发pivot1的注册包给server,需要进行封装:
[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B]{[len1,占4B][CMD_REGISTER,占1B][data1,占lenB]} - server收到gateway的数据包后,逐层解封,最终完成pivot1的注册。
2.4.2 三级网络级联
对于三级网络级联,其中前三步骤的分析与上文的二级网络级联一模一样,即gatetway注册和pivot1启动监听、server下发connect命令连接pivot1和pivot1的注册的与上文介绍的流程一致。
在这里只分析 pivot2启动监听和server下发connect命令连接pivot4 和 pivot2注册。
(一)pivot2启动监听和server下发connect命令连接pivot2
在server的控制台处输入命令connect:connect 211881096 127.0.0.1:10010 让pivot1主动连接pivot2的10010端口,211881096 是pivot1的agentid。
SendCommand 方法给要给pivot1发送任务包,首先需要知道gateway通往pivot1的路由。

通过调用GetPath获取到pivot1到达gateway的路径,该路径为:path = [pivot1,gateway]

紧接着进行封装。

第一层:原始包packet1 = [len,占4B][CMD_CONNECT,占1B][data,占lenB]
第二层:i = 1,packet2 = [len,占4B][CMD_FORWARD,占1B][RouteID,占4B][packet1]。
i = 2,不满足循环条件,所以server发给pivot1的任务包只需要经过一跳就到达目的地。
server将connect命令包发送到gateway,由gateway的 HandleUpstream 进行处理,因为最外层的 CMD_TYPE = CMD_FORWARD,这意味着数据包会交由 HandleUpstream 的 CMD_FORWARD 的分支处理。

进入到 CMD_FORWARD 的分支前,因数据包被剥离了一层,只剩下 [RouteID,占4B][packet1]。前面说过RouteID是由父节点生成,用于标识与子节点的物理连接,此时父节点为gateway,子节点为pivot1,也就是说gateway可通过 Children.Load(routeID) 找到与pivot1建立的conn,并通过conn将 packet1 = [len,占4B][CMD_CONNECT,占1B][data,占lenB] 转发到了pivot1。
pivot1接收到gateway发来的packe1,因CMD_TYPE = CMD_CONNECT,故通过给AddPivot启动一个 runtime。

pivot1调用 AddPivot 方法与pivot2建立连接,并生成RouteID标识这个连接(即 a.Children.Store(routeID, conn)),之后启动 handleChild 专门处理来自pivot2的数据。
(二)pivot2注册
当pivot1与pivot2建立连接后,pivot2立即发送自己的注册包。此时注册包的结构为packet1 = [len1,占4B][CMD_REGISTER,占1B][data,占lenB]

pivot1接收到了pivot2发来的注册包,再进行封装得到: [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B]{[len1,占4B][CMD_REGISTER,占1B][data,占lenB]},简化为packet2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1]
pivot1将封装后的数据包发送给gateway后,再进行封装得到packet3 = [len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B]【[len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B]{[len1,占4B][CMD_REGISTER,占1B][data,占lenB]}】,简化得到packet3 = [len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2]。

gateway将packet3转发给server后,因最外层的CMD_TYPE = CMD_FORWARD,由processMessage方法的 CMD_FORWARD 分支进行处理。
- 第一次进入到
CMD_FORWARD分支,此时数据包的结构为:[RouteID2,占4B][packet2]。 - 因为packet2的CMD_TYPE = CMD_FORWARD,所以第二次调用processMessage方法并进入
CMD_FORWARD分支进行处理,此时数据包结构为:[RouteID1,占4B][packet1]。

因packet1 = [len1,占4B][CMD_REGISTER,占1B][data,占lenB],CMD_TYPE = CMD_REGISTER,会进入到 CMD_FORWARD 分支下的 CMD_REGISTER 分支注册pivot2。请注意,我们只需要pivot1生成的RouteID1来标识与pivot2的连接,不需要gateway生成的RouteID2。RouteID2只在Gateway转发给数据 Pivot1时用到,对Pivot1的子节点管理完全透明,颇有种 “我的附庸的附庸,不是我的附庸” 的感觉?

至此三级网络的级联流程就分析完了,总结一下数据流向:
- gateway向server发生注册包:
[len,占4B][CMD_REGISTER,占1B][data,占lenB] - server发送connect任务包给gateway:
[len1][CMD_CONNECT][127.0.0.1:9999] - gateway向pivot1建立连接后,pivot1发送注册包给gateway:
[len1,占4B][CMD_REGISTER,占1B][data1,占lenB] - gateway转发pivot1的注册包给server,需要进行封装:
[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B]{[len1,占4B][CMD_REGISTER,占1B][data1,占lenB]} - server收到gateway的数据包后,逐层解封,最终完成pivot1的注册。
- server要发送connect任务包给pivot1:
[len,占4B][CMD_FORWARD,占1B][RouteID,占4B][127.0.0.1:10010] - pivot1向pivot2建立连接后,pivot2发送注册包给pivot1:packet1 =
[len1,占4B][CMD_REGISTER,占1B][data,占lenB] - pivot1转发给gateway前再封装:packet2 =
[len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1] - gateway转发给server前再封装:packet3 =
[len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2]。 - server收到gateway的数据包后,逐层解封,最终完成pivot2的注册。
2.5 通过Exec命令分析数据流向
假设此时有这样的级联结构:server->gataway->pivot1->pivot2。
目标:server给pivot2发送Exec命令。
(一)server构造Exec的任务包
在server的控制台上输入:exec agentid ipconfig server通过 SendCommand 方法构造Exec的任务包


SendCommand 通过GetPath算出 path = [pivot2,pivot1,gateway]
根据path长度来判断是否需要构造 CMD_FORWARD 包。当len(path)>=2时,即server和目标存在中间节点时,则需要构造 CMD_FORWARD 包。
第一层:原始包packet1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB]
第二层:i = 1,packet2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1]。
第三层:i = 2,packet3 = [len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2]
当i = 3时不满足循环条件,所以任务包需要经过2跳就能达到pivot2。

(二)gateway和pivot1转发任务包
gateway收到server发送的数据包,packet3 = [len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2], 因其CMD_TYPE = CMD_FORWARD,故从数据包中获取RouteID2,通过 a.Children.Load(routeID) 拿到gateway与pivot1的conn,之后转发packet2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1] 给pivot1。
pivot1同理转发数据packet1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB] 给pivot2。

(三)pviot2执行命令返回结果包
pivot2解析packet1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB],因其CMD_TYPE = CMD_EXEC,故执行系统命令。执行完后调用 a.WritePacket(CMD_EXEC, []byte(result)) 将结果打包成结果包packet_res1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB],传给pivot1。


(四)中间节点封装结果包并转发给上游
pviot1收到pivot2的结果包,随后封装成 packet_res2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1][packet_res1],传给gateway。
gateway收到pivot1的结果包,随后封装成 packet_res3 = [len2,占4B][CMD_FORWARD,占1B][RouteID2][packet_res2],传给server。

server的HandleAgent收到gateway的数据包,调用processMessage进行处理。
- 第一次进入processMessage的CMD_FORWARD分支:此时innerBody = packet_res1 =
[len1,占4B][CMD_EXEC,占1B][data,占lenB],fromAgentID = gateway的agentid,遍历gateway的子节点列表,直到找到child.RouteID == routeID的子节点,也就是究竟是谁发了数据包给gateway,本例中是pivot1,然后actualSourceID = pivot1的agentid,因actualSourceID不为0,故递归调用processMessage。 - 第二次调用processMessage的CMD_FORWARD分支:此时innerBody = pivot2命令执行的结果, fromAgentID = pivot1的agentid,遍历pivot1的子节点列表,直到找到
child.RouteID == routeID的子节点,也就是究竟是谁发了数据包给pivot1,本例中是pivot2,然后actualSourceID = pivot2的agentid ,因actualSourceID不为0,故递归调用processMessage。 - 第三次调用processMessage,进入到CMD_EXEC分支:fromAgentID = pivot2的agentid,CMD_TYPE = CMD_EXEC,打印结果。


TCP级联分析至此结束!
2.6 彩蛋
在已沦陷的目标上创建TCP监听器
不知道各位是否是对已有会话右键后会看到“Pivoting-Listener”的功能感到好奇,这里我先不回答这个功能是干什么的,我想再提另一个问题:在我们日常的Socket编程中,我们有意的将通信双方区分为服务端和客户端,在安全领域中延伸出了正向连接和反向连接的概念。前文中我们都是让子Beacon创建TCP监听器,然后父Beacon去连接子Beacon,这就是正向连接,刚好对应bind tcp类型的监听器,难道就没有在父Beacon上创建TCP监听器,然后子Beacon去连接父Beacon,也就是所谓的反向连接吗?
有的有的,包有的,这就是我在开头提到的“Pivoting-Listener”的功能,这样正向连接和反向连接都有了,才能说的上真正的级联网络,CS的强大无需多言,不过现在要让我实现,我只能说精力有限,实在没空弄了,这篇文章都是我墨迹了几个月才写的。


三、SMB级联
3.1 基本概念
SMB(Server Message Block)是Windows网络环境中用于文件共享、打印机共享和进程间通信的核心协议,默认运行在445端口。在AD域环境中,SMB流量是”正常”的——文件共享、组策略更新、认证票据交换都依赖这个协议,这使得基于SMB的隐蔽通信具有天然的优势。
TCP通过Socket进行通信,那么SMB又是通过什么通信的呢?这里不得不提及 命名管道(Named Pipe) 的概念。SMB Beacon的核心机制是命名管道(Named Pipe),这是Windows进程间通信(IPC)的一种机制,命名规则为:\\.\pipe\ + 任意可见字符,比如:\\.\pipe\agent,看起来奇奇怪怪的,搞不懂为什么windows要这么命名。
1 | \\.\pipe\agent |
| 组件 | 含义 | 类比 |
|---|---|---|
\\ |
UNC路径起始 | 像http:// |
. |
本地计算机 | 像localhost |
pipe |
保留关键字,指向NPFS | 像/dev/设备目录 |
agent |
用户定义的管道名 | 像文件名 |
父Beacon创建管道(服务端)
1 | // 在内存中创建命名管道实例 |
子Beacon连接管道(客户端)
1 | // 通过SMB连接到远程命名管道 |
大部分情况下,beacon由C/C++编写,这意味着我们可以方便的调用WindowsAPI创建、打开、读取、写入、关闭命名管道,无论是访问本地命名管道还是远程,都可以使用同一套API。
- 服务器端创建管道:CreateNamedPipe。
- 服务器端等待连接:ConnectNamedPipe。
- 客户端打开/连接管道:CreateFile
- 读写操作:ReadFile/WriteFile
- 关闭管道:CloseHandle
但是也有少部分C2会采用其他高级语言编写,比如Silver的implant采用go语言,iom的malefic采用rust,这些语言调用WindowsAPI还是有很大的区别的。对于C/C++,API调用可以通过直接调用Windows.h,原生类型匹配,直接映射,而对于Go和Rust,如果使用 syscall 或者 golang.org/x/sys/windows 里的windows API时总是遇到一些奇奇怪怪的问题,比如明明向管道写了数据,却读不出等等,光是排查的时间就花费了几天,最后忍无可忍换成了 github.com/Microsoft/go-winio,这个库的api用法很像net包,且实现 net.Conn 接口,复用Go成熟的网络编程模型,如果对Go的net包的设计哲学感兴趣,可以去看看这篇iom的M09ic写的攻防场景中的网络侧对抗革命 (上)—rem。
举一个简单的伪代码例子。
服务端
1 | // 创建监听器,类似于 net.Listen |
客户端
1 | // 连接服务器的命名管道 |
3.2 CS的SMB级联
CobaltSrike的SMB级联通常配合AD域的横向移动使用,即利用ntlm hash + psexec + smb协议进行横向移动,当然CS也将SMB的级联功能独立出来,做成一个单独的命令 link。

帮助信息解释的很清楚了:连接到一个 SMB Beacon,重新建立对它的控制。连接成功后,所有对该 Beacon 的请求都将通过当前这个 Beacon 转发。

不过我并未使用link命令成功级联,具体原因未知,我也查阅了网上的很多资料,大多都是通过 spawn 命令选择smb监听器派生出一个会话。

级联成功


我又实验了在放置http beacon的目标主机上放置smb beacon,然后通过已经建立的http会话去连接smb beacon,结果也是成功的。

然后我在同网段的另一个主机上放置smb beacon,却始终连不上。

实在搞不懂,我也不想再研究了,生气了(#`д´)ノ,当然可能是系统环境的问题,我的代码是成功实现了smb级联,感兴趣的可以去看看。
3.3 设计构思
一般我们都是通过HTTPS的Beacon上线到CS的服务器,建立初始立足点,然后就开始了信息收集,如果该环境存在域,则可以各种域的攻击手段dump出ntlm hash,并使用pth将SMB Beacon传输到目标主机上,之后就建立起:server <-http<-> HTTPS Beacon <-> 命名管道 <-> SMB Beacon的级联网络。
对于SMB级联,我没有在数据结构上做出太大的变化,甚至我们还能套用TCP级联中的代码编写逻辑,真正实现一套框架走天下的原则。
我重写设计了gateway,它不想TCP级联那样,每个agent完全复用一套代码。作为唯一与外界通信的立足点,smb级联中的gateway通过HTTP协议与作为上游的服务器通信,而gateway与下游的agent通信的方式却是smb协议,这也导致了gateway不能与smb agent在代码编写中保持一致性。
现在想想,TCP级联中的gateway也应当通过HTTP协议与服务器通信,而TCP协议只作为不同类型Beacon(HTTP、DNS、WebSocket等)内置的功能,比如在CS中,每个Beacon都有Connect命令,这也意味着不同类型的gateway beacon能够通过tcp和smb协议级联下游beacon,从而实现多级代理链,这正是现代C2框架设计的精髓所在——将传输层与协议层解耦,实现”协议无关的级联能力”,所以我的TCP级联设计是不太符合实战角度。
除了上面所说的 协议无关的级联能力 外,我认为每个agent还应当设计一个“缓冲区队列”专门用来存储下游节点发送的数据。我说过:任何agent发送的数据通过层层向上转发,最终转发到server,并由server来处理结果。但是由于睡眠混淆技术的出现,又给级联网络带来了时间差的问题,导致异步通信的混乱
比如说存在这样的类型的smb级联网络
- server <-http<-> HTTPS Beacon(gateway) <-> 命名管道 <-> SMB pivot1 <-> 命名管道 <-> SMB pivot2。
- server <-http<-> HTTPS Beacon(gateway) <-> 命名管道 <-> SMB pivot3。
当pivot1和pivot3同时主动向gateway发送数据,但gateway只能转发其中一个节点的数据给server,那么另一个节点的数据包就会被gateway丢弃。
这时你会想到用互斥锁,这能解决一部分时间差的问题,但是当父节点从睡眠中苏醒,接收到了多个下游节点发送的数据包,即使加了锁,同一时间抢不到锁的下游节点数据依然会被抛弃,或者导致下游节点被阻塞挂起
缓冲区队列就像一个蓄水池,当父节点处于长睡眠状态时,下游多个子Pivot节点可能已经完成了多个任务(比如截屏、传文件)。父节点醒来后,需要一个队列来按顺序批量读取这些缓存的数据。
我在代码中使用了 QueuePacket 将数据放到队列里。

在 ConnectPivot 的读管道循环中,一旦下游有任何数据,立刻将其推入 OutBuffer 缓冲队列;而在 CheckIn 轮询时,直接加锁、一次性读取并清空缓存区 (a.OutBuffer.Reset()),然后将其打包进一个 HTTP POST 请求发送。这完美解决了由于睡眠时间差导致的下游数据堆积和并发冲突问题。


不过代码中我只给gateway增加了缓冲队列,而普通的pivot没有增加,因为在写到这里的时候我并没有意识到下游节点也会使用睡眠混淆技术,也会产生时间差问题,进一步地,tcp级联也需要缓冲队列,然后我也懒得加补全代码,各位师傅感兴趣可以自己加上。
3.4 三级网络级联
在上文就详细介绍过TCP级联的大致流程,smb级联没有太大的区别,下面我就以文字叙述整个流程。
- gateway通过HTTP向server发生注册包:
[len,占4B][CMD_REGISTER,占1B][data,占lenB]。之后gateway通过轮询的方式向server请求任务。 - server发送link任务包给gateway:
[len1][CMD_SMB][\\.\pipe\pivot1],让gateway主动去连接pivot1创建的命名管道。 - gateway向pivot1建立连接后,pivot1发送注册包给gateway:
[len1,占4B][CMD_REGISTER,占1B][data1,占lenB] - gateway转发pivot1的注册包给server,需要进行封装:
[len2,占4B][CMD_FORWARD,占1B][RouteID,占4B]{[len1,占4B][CMD_REGISTER,占1B][data1,占lenB]} - server收到gateway的数据包后,逐层解封,最终完成pivot1的注册。
- server要发送connect任务包给pivot1:
[len,占4B][CMD_FORWARD,占1B][RouteID,占4B][127.0.0.1:10010] - pivot1向pivot2建立连接后,pivot2发送注册包给pivot1:packet1 =
[len1,占4B][CMD_REGISTER,占1B][data,占lenB] - pivot1转发给gateway前再封装:packet2 =
[len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1] - gateway转发给server前再封装:packet3 =
[len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2]。 - server收到gateway的数据包后,逐层解封,最终完成pivot2的注册。
gateway上线server

pivot1上线server

pivot2上线server

网络拓扑

3.5 通过EXEC分析数据流向
假设此时有这样的级联结构:server->gataway->pivot1->pivot2。
目标:server给pivot2发送Exec命令。
①server 构造Exec的任务包
第一层:原始包packet1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB]
第二层:i = 1,packet2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1]。
第三层:i = 2,packet3 = [len3,占4B][CMD_FORWARD,占1B][RouteID2,占4B][packet2]
②gateway接收到server的数据包,发现是 CMD_FORWARD,故根据RouteID2找到与子节点pivot1的连接,并将 [len2,占4B][CMD_FORWARD,占1B][RouteID1,占4B][packet1] 发送给pivot1。
③pivot1接收到gateway的数据包,发现是 CMD_FORWARD,故根据RouteID1找到与子节点pivot2的连接,并将 [len1,占4B][CMD_EXEC,占1B][data,占lenB] 发送给pivot2。
④pviot2执行命令返回结果包,封装成packet_res1 = [len1,占4B][CMD_EXEC,占1B][data,占lenB],传给pivot1。
⑤中间节点封装结果包并转发给上游
pviot1收到pivot2的结果包,随后封装成 packet_res2 = [len2,占4B][CMD_FORWARD,占1B][RouteID1][packet_res1],传给gateway。
gateway收到pivot1的结果包,随后封装成 packet_res3 = [len2,占4B][CMD_FORWARD,占1B][RouteID2][packet_res2],传给server。
让某个agent执行命令的效果图

后记
本来也没想写这么多,真正动笔写的时候才发现可以写这么多内容,算是写嗨了,大篇幅的自言自语,就是不知道各位师傅能否看懂呢?让我评价本文,它可以排在SRDI的后面,位于我心目中第二的位置。
这篇文章的主题是从2025年9月开始确立,但动笔写大纲是2026年1月28日,然后从2026年1月30日开始动笔写,期间经过了年前两个星期的加班,一个普通的春节,最终摸到了2月底才写完。
级联网络这个主题可以说非常具体挑战性的,无论是从最初的拓扑网络的建设还是路由算法的建立,亦或是连接的管理与维护,都对笔者在分布式系统基础、网络协议栈细节以及数据流向等方面的理解提出了极高的要求,不过随着AI越来越强大了,帮助我完成了很多不可能完成的挑战。
最后的最后,如果这篇文章对你有所启发,别忘了点赞、收藏和关注啊啊啊啊!
参考资料
- CobaltStrike
- sliver:BishopFox/sliver: Adversary Emulation Framework
- havoc:Havoc/payloads/Demon/src/core/Pivot.c at main · HavocFramework/Havoc
- AdaptixC2:Adaptix-Framework/AdaptixC2: AdaptixC2 is a highly modular advanced redteam toolkit
- Merlin:Ne0nd0g/merlin: Merlin is a cross-platform post-exploitation HTTP/2 Command & Control server and agent written in golang.
- Stowaway: ph4ntonn/Stowaway: 👻Stowaway – Multi-hop Proxy Tool for pentesters