基于宿主-客户端架构的反作弊完整性校验绕过与DLL转发实现

发布时间:2026/9/14 3:05:26
基于宿主-客户端架构的反作弊完整性校验绕过与DLL转发实现 简介面向XignCode3反作弊安全研究的本地模拟绕过方案针对Wellbia XignCode3的完整性校验机制采用宿主程序加载XignCode3并执行分析、客户端劫持其导出函数并通过本地套接字转发校验请求的方式实现对原始进程的免检测运行可供逆向工程、游戏安全分析与反作弊对抗研究者参考。压缩包为RAR格式共45个文件约3.89MB以Visual Studio工程.sln/.vcxproj、C源码.cpp/.h、编译中间文件.obj/.pch/.tlog/.log及生成的DLL、PDB调试符号为主目录包含Release与Debug配置便于直接编译或跟踪调试。已有1397人下载学习。通过这份资源可完整获取宿主/客户端双进程架构的实现源码、工程配置与编译产物重点学习XignCode3导出表劫持、本地socket通信和校验响应生成等关键环节对理解商业反作弊系统的检测流程及设计基础绕过框架有直接的参考价值。1. XignCode3 的完整性校验恰恰是它的软肋XignCode3 从设计上就比传统反作弊多走了一步它不光扫目标进程的内存和窗口句柄还对自己加载的那个x3.xem做签名完整性验证signcode check任何对这个文件的字节级改动都会触发检测。可问题是这套完整性分析跑在哪个进程里最终结果就由那个进程自己来汇报——这就是本次分享的项目bypass-signcode-x3.xem能成立的根基。它的思路不复杂让 XignCode3 在一个宿主进程里完成全部 anti-hack 分析并拿到通过的验证响应再让目标进程TheClient通过本地 socket 把校验请求转发过去接收响应。对做反作弊机制研究、驱动级安全分析和授权环境渗透测试的人来说这套 Host–Client 拆分结构比直接 hook API 更值得拆开看因为它在进程边界上绕开了 XignCode3 对自身状态的篡改检测。项目本身是一个 VS2019vc142的 DLL 工程包含完整的 Host 初始化逻辑、客户端转发逻辑和构建产物。下面按检测原理、通信协议、DLL 劫持实现、构建与验证四个层面拆开讲每一步都给出可复现的代码和参数。2. XignCode3 的检测执行链与签名检查机制2.1 检测模型签名、区段哈希与跨进程句柄审计XignCode3 的 Ring3 部分加载x3.xem后至少会做三件事这三件事也是本篇绕过方案必须逐一对付的检测点对受保护进程的.text区段做运行时哈希比对周期性地和初始快照比较发现改写就判定为注入。检查进程里是否有可疑的调试器句柄、窗口类名和已知安全工具的驱动名。对自己输出的验证响应做签名校验这是 signcode check 环节——响应数据必须带一个由 XignCode3 内部生成的签名伪造响应等于伪造一个不存在的密钥体系。这三层检测有一个共同前提它们都认为反作弊模块和目标进程在同一个地址空间里协作。一旦把 XignCode3 的初始化挪到宿主进程中执行目标进程里就没有真正的检测逻辑只剩一个转发层。2.2 为什么独立宿主进程比进程内 hook 更稳定直接在当前进程里 hook XignCode3 的导出函数会触发第 3 类检测因为它会校验自身代码页的哈希。而本项目换了一种做法让宿主进程完整加载并初始化 XignCode3XignCode3 在宿主进程里做完所有 anti-hack 分析后会把验证响应通过内部 socket 发给客户端。客户端里运行的是被劫持的x3.xem导出表所有对外暴露的接口都指向了宿主进程。这样做有一个容易被忽略的好处宿主进程里跑的是干净的 XignCode3它既不认为宿主是被保护的进程也不存在被篡改的自身模块所以它给出的验证响应一定是合法的。客户端拿到这些响应后当作自己进程内的校验结果汇报给上层应用上层应用根本区分不了响应来自本进程还是跨进程。2.3 导出函数才是 XignCode3 的校验入口x3.xem对外暴露的导出量并不多通常就是初始化、启动检测、心跳、获取验证状态这几个。项目代码里劫持的就是这批导出函数初始化类函数宿主进程真正调用客户端只转发。心跳/状态查询函数客户端向宿主发起 socket 请求宿主返回上一次校验的签名结果。清理类函数客户端直接丢弃宿主进程退出时统一回收。这个设计把检测行为和检测结果彻底分离了也是后面 DLL 转发层实现的核心逻辑。3. Host–Client 通信架构与本地 socket 协议3.1 两个进程的职责边界Host 进程负责加载真正的 XignCode3 运行时维护一个本地 TCP serverClient 进程即 TheClient.exe 本身通过注入的 DLL 劫持导出表并把 API 调用转换成 socket 请求。之所以用 TCP 而不是共享内存是因为 XignCode3 的验证响应带签名且长度不固定共享内存需要额外的锁和事件同步TCP 的流式语义更省事而且127.0.0.1回环地址受防火墙影响最小。3.2 socket 请求/响应格式与参数通信协议按定长头加变长体设计。头 8 字节固定偏移长度字段说明02magic固定0x5858用于区分是否为本项目流量22opcode1Init2Heartbeat3GetStatus44body_len小端序表示后续 body 的字节数响应格式同样带 magic但 opcode 回显请求值body 里放 XignCode3 的签名验证结果。客户端拿到 body 后不做任何校验直接当作x3.xem的返回值交给上层调用方。Host 端监听本地随机端口但为了让客户端免配置我一般固定绑定127.0.0.1:65000。如果端口被占用用 Windows 的GetExtendedTcpTable枚举当前占用进程被占用时向后顺延一个端口并写入注册表HKCU\Software\XignCode3Host\port客户端动态读取。// host_socket.cpp - 宿主端简单响应循环 SOCKET listen_sock socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_LOOPBACK); addr.sin_port htons(65000); bind(listen_sock, (sockaddr*)addr, sizeof(addr)); listen(listen_sock, SOMAXCONN); for (;;) { SOCKET client accept(listen_sock, nullptr, nullptr); // 每连接一个线程避免 Clock 请求阻塞心跳 std::thread([client]() { uint8_t header[8]; recv(client, (char*)header, 8, MSG_WAITALL); uint16_t opcode *(uint16_t*)(header 2); uint32_t body_len *(uint32_t*)(header 4); std::vectoruint8_t body(body_len); recv(client, (char*)body.data(), body_len, MSG_WAITALL); // body 内容是 XignCode3 内部状态块的序列化结果 // 这里直接转发给 XignCode3 的验证函数 std::vectoruint8_t resp xigncode_validate(body, opcode); uint16_t resp_header[3] {0x5858, opcode, (uint16_t)resp.size()}; send(client, (char*)resp_header, 6, 0); send(client, (char*)resp.data(), resp.size(), 0); closesocket(client); }).detach(); }代码里xigncode_validate是假设的直接调用入口实际项目中对应的是 XignCode3 的XingCode_VerifyStatus之类接口。整个循环的精髓在于每次校验请求都在宿主进程内完成客户端的响应延迟取决于回环 TCP 的往返时间通常在 1ms 级不会触发 XignCode3 自身的超时检测。3.3 转发超时与降级策略这里有个坑XignCode3 在客户端进程中的某个线程会周期性调用校验函数如果 socket 请求在短时间内没有响应上层可能走初始化失败分支。所以客户端转发时至少要设 50ms 超时超时后返回上一次缓存的响应而不是返回错误码。// forwarder.cpp - 客户端转发函数 bool forward_request(uint16_t opcode, const uint8_t* body_in, uint32_t body_len, uint8_t* resp_out, uint32_t* resp_len) { SOCKET s socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_LOOPBACK); addr.sin_port htons(host_port); // 从注册表读取 if (connect(s, (sockaddr*)addr, sizeof(addr)) ! 0) return false; uint16_t header[4] {0x5858, opcode, static_castuint16_t(body_len)}; send(s, (char*)header, 8, 0); send(s, (char*)body_in, body_len, 0); // 50ms 等待超时就返回缓存 timeval tv{0, 50000}; setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)tv, sizeof(tv)); uint16_t resp_header[3]; if (recv(s, (char*)resp_header, 6, MSG_WAITALL) ! 6) { closesocket(s); return use_cached(resp_out, resp_len); } uint32_t rlen resp_header[2]; recv(s, (char*)resp_out, rlen, MSG_WAITALL); closesocket(s); return true; }注意setsockopt的SO_RCVTIMEO在 Windows 上单位是毫秒和 Linux 的微秒不同传 50000 就是 50ms。这条如果不注意按 Linux 习惯写就会得到 50 秒的超时整个绕过链路会假死。4. DLL 劫持与导出转发的实现细节4.1 dllmain.cpp 中的加载路径判断从项目文件列表能看到这是一个标准 DLL 工程核心逻辑集中在dllmain.cpp里。DLL_PROCESS_ATTACH时首先要判断当前进程是宿主还是客户端。判断依据是进程名宿主可执行文件命名为XignCode3Host.exe客户端是TheClient.exe。// dllmain.cpp - 模式分派逻辑 BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) { if (reason DLL_PROCESS_ATTACH) { wchar_t path[MAX_PATH]; GetModuleFileNameW(nullptr, path, MAX_PATH); std::wstring proc path; if (proc.find(LXignCode3Host.exe) ! std::wstring::npos) { // 宿主模式加载真实 x3.xem启动 socket server start_host_mode(); } else { // 客户端模式建立到宿主的连接池拦截导出 start_client_mode(); // 重写 x3.xem 的加载路径让后续导出解析全部落到本 DLL redirect_xem_imports(); } } return TRUE; }这里最关键的动作是redirect_xem_imports。常规做法是把原始的x3.xem改名成x3_orig.xem再把本项目 DLL 改名为x3.xem放进目标目录TheClient 加载时就会优先加载本 DLL。本 DLL 内部手动加载x3_orig.xem持有它真实的导出地址但把对外提供的响应替换成来自宿主 socket 的结果。注意这个替换只能发生在数据层面不能让 TheClient 感知到模块路径差异否则 XignCode3 的模块列表扫描会暴露。4.2 导出转发手动转发器 vs .def 文件用过.def文件做转发的人会知道#pragma comment(linker, /EXPORT:FuncNameDllName.FuncName)可以做到模块级转发。但对 x3.xem 这种加壳模块不好使因为加壳后导出地址是运行时动态计算的。所以项目里使用的是手动转发器即每个导出项写一个桩函数桩函数内部把参数序列化后走 socket。// export_stubs.cpp - 导出桩函数示例 extern C __declspec(dllexport) int XignCode_Initialize(HWND hwnd, void* reserved) { uint8_t body[32]; uint32_t body_len 0; // 把 hwnd 和 reserved 打包进 body *(HWND*)(body) hwnd; body_len sizeof(HWND); *(void**)(body sizeof(HWND)) reserved; body_len sizeof(void*); uint8_t resp[512]; uint32_t resp_len 0; if (!forward_request(1, body, body_len, resp, resp_len)) { return -1; // 宿主不可用时不能假装成功 } return static_castint(*resp); }这种手动转发的优点是参数完全可控它不追求通用性——因为 XignCode3 的导出函数数量本身很少通常不超过 10 个每个手写桩函数成本很低。项目中Release/Dll1.dll就是这个转发层的最终产物。4.3 vc142 工具链下的 Release 构建参数从项目文件列表里有vc142.pdb、Dll1.vcxproj.FileListAbsolute.txt、Dll1.Build.CppClean.log可以看出编译用的是 VS2019 v142 工具集。DLL 工程有几个参数必须确认配置项推荐值原因运行库多线程/MT避免目标机器缺 vcruntime140.dll字符集UnicodeGetModuleFileNameW依赖此设置调用约定__cdeclXignCode3 导出桩大多使用 cdecl优化/O2减少转发函数体积降低模块内存特征预处理宏WIN32_LEAN_AND_MEAN缩短编译时间并减少第三方头文件冲突/MT和/MD的选择在 Release 下尤其敏感/MD依赖的vcruntime140.dll如果宿主环境没有DLL 直接加载失败连 dllmain 都进不了。VC 工具链的工程文件里默认是/MD换成/MT后务必在Dll1.log里确认链接参数生效。预编译头在这里也不是摆设。项目里有pch.h和pch.cpp把windows.h、winsock2.h、queue、thread全放进去能显著缩短每次改dllmain.cpp的编译时间。但要注意如果dllmain.cpp里的某个头文件改变了 Winsock 的宏定义顺序比如先包含windows.h再包含winsock2.h就会出现WSANOTINITIALISED这类诡异错误所以 pch 中应固定为先 winsock2.h 后 windows.h。5. 构建产物、排错方法与会话级验证技巧5.1 从构建产物反查问题根因Release 目录里的Dll1.Build.CppClean.log和Dll1.vcxproj.FileListAbsolute.txt这两个文件很多人不重视但它们能直接反映编译配置。FileListAbsolute.txt里保存了所有编译产物的绝对路径如果路径中有中文或空格部分老版本 MSBuild 会把/MP多核编译的 PDB 写串导致vc142.pdb里符号路径错乱。遇到调试时命不中断点但 Release 功能正常的情况优先看这个文件路径是否含非 ASCII 字符。Dll1.tlog目录里是链接器的跟踪日志其中的CL.command.1.tlog记录了编译参数。查看它就能确认实际生效的优化级别和预处理器宏。如果vc142.idb出现在 Debug 目录说明增量编译被启用了分析无误。完整判断链路是否生效应该按下面的顺序来检查Dll1.dll是否被复制为目标目录下的x3.xem。启动宿主进程XignCode3Host.exe再启动 TheClient。在管理员权限的 cmd 里执行netstat -ano | findstr 65000确认宿主端口在监听。执行dumpbin /exports Dll1.dll查看导出表确认桩函数齐全。5.2 dumpbin 和端口侦听的组合验证dumpbin的输出重点关注导出函数名的修饰规则。XignCode3 的导出函数如果原名是XignCode_Init而你的 DLL 导出的可能是XignCode_Init8stdcall 修饰TheClient 通过GetProcAddress按名字查找会失败。所以导出时建议用.def文件的EXPORTS段保证符号名原样导出EXPORTS XignCode_Initialize XignCode_Heartbeat XignCode_GetStatus最后再检查本地 socket 链路的完整请求流。用 Procmon 或裸的TCPView都行重点是确认 TheClient 在启动后是否有到127.0.0.1:65000的 TCP 连接且连接是持续存在的。如果连接建立后立刻断开通常是宿主进程的响应格式不对直接去 Host 端看recv返回的字节数是否和body_len一致。这里有一个会话级别的小技巧每一条 socket 连接做一个递增的 session id 并回传给客户端客户端侧的cached_response带上这个 id。这样做能区分过期清数据和真死链排错时看日志比读内存快得多。本文还有配套的精品资源点击获取