跑跑卡丁车国服:从多开到劫持登录的逆向笔记
一篇流水账式的实战记录:从"多开游戏"开始,一路挖到PASSport机制、平台判定、进程内反作弊,
所有测试在自有环境、自有账号上进行。请勿用于违反服务条款或法律法规的用途。
0. 缘起
手头一个老游戏:跑跑卡丁车,靠天成的 TC 平台(TCGame.exe)启动。
想要双开游戏:以前有脸蛋N开跟PP多开,十年了早就失效了。打算自己动手做一个
结果挖到了三层东西:PASSport凭证 → 平台判定 → 进程内反作弊。下面按挖到的顺序写。
1. 多开这条线:先搞清楚客户端在防什么
1.1 现象
直接第二次双击 KartRider.exe:第二个进程会起来,但还没进登录就自己没了(自毁),
或者起来一小会儿就崩在 KartRider.exe+0x??? 的某个地址上(0xC0000005)。
也就是说:客户端有一套"我已经在运行了"的检测,检测到就自杀。
1.2 处理:马甲 + 挂钩
老办法是给它挂个 dinput8.dll 马甲(DLL 劫持,游戏必然会加载它),在客户端进程里做几件事:
| 手段 | 说明 |
|---|---|
FindWindowW/A / FindWindowExW/A 桩化 | 客户端会按窗口类名找"另一个自己",对外来进程的同名窗口返回 NULL |
| 共享对象句柄处理 | 把其它进程里持有的同名对象句柄关掉 |
| 进程列表侧的处理 | 把兄弟进程在 NtQuerySystemInformation(class=5) 的结果里"改名换姓" |
实测:三项一起上,第二个客户端能起来、能进大厅(两个 PopKart Client 窗口同时存在)。
1.3 被禁止的客户端
游戏本身的客户端已经被禁止使用了。(这时候我还沉浸在一次性开了16个客户端,其实已经无法登录了。不高兴)

2. 进程内的反作弊机制
2.1 先看
打开进程列表和模块列表,能看到:
- 列表里只有一个
BlackCipher.aes进程 —— 两个客户端也只出现一个; - 而
NGClient.aes/BlackCall.aes是加载在游戏进程里的模块(不是独立进程); - 客户端目录里还存在
CrashReporter.dll、GameToTCG.exe套件。
也就是三层叠着站:进程内模块 + 独立进程 + 内核侧交叉验证。
2.2 定位"是谁在查进程列表"
老套路:钩系统调用 + 顺栈回溯,把调用者的模块+偏移打出来。
static LONG NTAPI Hook_NtQuerySystemInformation(ULONG cls, PVOID buf, ULONG len, PULONG ret)
{
LONG st = orig(cls, buf, len, ret);
if (cls == 5 || cls == 64) // 进程列表 / 句柄表
LogCallerAddr("NtQSI", _ReturnAddress(), _AddressOfReturnAddress());
return st;
}LogCallerAddr :把返回地址映射成 模块+偏移;
再从返回地址往上扫 160 个 DWORD,找出落在"游戏侧模块"里
(自动跳过 KERNEL*/ntdll/USER32/msvcrt 和我们自己的劫持 DLL)。
扫出来极其干净:
[caller] NtQSI class=5 (process list) <- KERNEL32.DLL+0x60B32
stack: [KartRider.exe+0x0] [NGClient.aes+0xCC00E] [NGClient.aes+0x5BB334]
[NGClient.aes+0x4FB7] [NGClient.aes+0x11D00]
[caller] NtQSI class=5 (process list) <- KERNEL32.DLL+0x60B32
stack: [BlackCall.aes+0x166B10A] [BlackCall.aes+0x1A4E22D] [BlackCall.aes+0x5AABA]多开检测的是进程内的反作弊模块。
2.3 不要"谎报"
既然判定走进程枚举,第一直觉当然是"钩掉它、把结果改了"。实测结果:
- 第二个客户端的自毁被拦住,两个客户端能同时起来、同时进大厅;
- 维持了30分钟还是被检测到踢出游戏:

结束提示
发现正在使用异常程序,与服务器断开连接。
3. TCGame.exe 判定的规则
想搞明白这个,最直接的办法是注入一个记录 API 调用序列的 spy DLL。抓到的判定流程(简化):
EnumProcesses / NtQuerySystemInformation(class=5) ← 枚举进程
→ 匹配进程名 KartRider.exe
→ NtQueryInformationProcess(h, 43 /* ProcessImageFileNameWin32 */)
→ NtQueryInformationProcess(h, 27 /* ProcessImageFileName */)
→ CreateFile("\??\G:\TCGameApps\kart\KartRider.exe") ← 拿映像路径去核对3.1 踩坑:pid 在偏移 0,不是 8
NtQueryInformationProcess 返回的缓冲里 pid 在偏移 0。
我一开始按 ci+8 读,hook 打出来全是垃圾 pid,排查了很久。
3.2 一条清不掉的状态
本地这条判定线是能骗的(把进程在列表里改名,平台就检测不到了),但实测发现还有第二条线:
- 关句柄、清 CEF 缓存、重启
TCGame.exe、切换平台账号、隐藏窗口 —— 全都清不掉; - 只要这台机器上真的有一个游戏会话在线,平台的「启动」按钮就是灰的。
本地进程扫描解释不了"换账号也不恢复",所以这条是服务端侧的。
这一点直接决定了:"靠清本地痕迹来实现多开"是死路。
3.3 取证
TCGame.exe 是 CEF 界面:主窗口类名 TCMainFrame(标题 TCGames),登录窗口是 tcgLogin(标题 tcLauncher)。
CEF 内容不吃 PrintWindow(截出来是空白),只能屏幕截图;
置顶要 AttachThreadInput + SetWindowPos(HWND_TOP);按钮状态判定用区域裁剪截图(比找控件可靠)。
4. passport:客户端真正的登录凭证
前面三节都在讲"多开被检测",这里讲登录——因为绕到后来你会发现:
客户端能不能进游戏,取决于它启动时有没有被塞一张 passport。
4.1 直启为什么登不上
现象:直接双击 KartRider.exe → 出 Login 窗口 → 输账号密码点登录 → 毫无反应,随后弹"服务器错误"。

掏出抓包工具看它连了哪:
CONNECT -> 109.244.39.49:39311 ret=-1 (不通)
CONNECT -> 109.244.39.50:39311 ret=-1
CONNECT -> 162.14.144.162:39311 ret=0 (通)网络是通的,问题不在连接。答案在平台启动它时的命令行:
X:\游戏路径\KartRider.exe TGC -region:3 -passport:<108 字符 base64>客户端不是自己登录的,它是用了"passport"才登录的。 没有 -passport:,那个 Login 窗口就是个摆设。
4.2 passport 的几个性质
- 108 字符 base64,由
TCGame.exe向服务端申领,点击「启动游戏」按钮的是出来的; - 绑账号;一个账号一个在线会话(同账号第二个客户端会顶掉第一个);
- 不是"抓一次永久复用",但会话结束后原 passport 可能又能用(这点和直觉相反,之前过程中踩过:
拿旧 passport 在"同账号已有在线会话"时启动,客户端认证被拒后直接崩)。
4.3 把 TCGame.exe 当"passport 发放机"
既然 passport 是点「启动」时出的,那我们不需要平台去启动游戏,只需要它生成的一张 passport。
观察:平台启动游戏必然要调 CreateProcessW(或 A)。在平台进程里注入一个小 DLL 钩住它:
typedef BOOL (WINAPI *PFN_CPW)(LPCWSTR, LPWSTR, LPSECURITY_ATTRIBUTES, LPSECURITY_ATTRIBUTES,
BOOL, DWORD, LPVOID, LPCWSTR, LPSTARTUPINFOW, LPPROCESS_INFORMATION);
static PFN_CPW o_cpw;
static BOOL WINAPI H_cpw(LPCWSTR app, LPWSTR cmd, ...)
{
if (cmd && wcsstr(cmd, L"KartRider")) {
WCHAR* p = wcsstr(cmd, L"-passport:");
if (p) SavePassport(p + 10); // 1) 把 passport 抄下来
if (FlagOn("X:\\_exp\\disp\\block.flag")) { // 2) 关键:拒绝这次启动
SetLastError(ERROR_ACCESS_DENIED);
return FALSE; // 不真的起客户端
}
}
return o_cpw(app, cmd, ...);
}抓到的日志:
[cpw#1] cmd=X:\TCGameApps\kart\KartRider.exe TGC -region:3 -passport:**bZuu…Ng7tg=**
[PASSPORT] caught from CreateProcessW
[BLOCK] refusing this launch 拒绝启动这一步是精髓,因为它带来一个很妙的状态:
KartRider clients: (空) ← 没有任何游戏会话
平台按钮 = 「启动游戏」 ← 可以反复点、随便切账号取 passport平台的按钮状态是跟着"有没有游戏在跑"走的:不真起客户端 → 平台认为没人玩游戏 → 按钮一直可点
→ 一台机器上可以给多个账号各取一张 passport。
于是流程变成:
起 TCGame.exe → 注入发放机 → 开 block → 点一次「启动按钮」→ passport 入池
→ 平台切到另一个账号 → 再点一次 → 第二张 passport 入池
4.4 用 passport 自己启动客户端
passport 到手,客户端就是靠命令行登录的,跟平台进程毫无关系:
Start-Process -FilePath 'X:\TCGameApps\kart\KartRider.exe' `
-ArgumentList 'TGC','-region:3',("-passport:" + $pp) `
-WorkingDirectory 'X:\TCGameApps\kart'实测(把 TCGame.exe 完全关掉):

TCGame=0 ← 平台已退出
用抄到的 passport 直接启动 KartRider.exe
→ pid=47480 title='PopKart Client' 646MB
→ 连接 162.14.144.168:39311 / 162.14.144.174:39322 ← 真的上线了结论:KartRider.exe 自己就能当登录器,TCGame.exe 只在"取 passport 那 30 秒"需要开。
注意配合关系:passport 是按账号发的、且同账号只能一个在线会话。
所以"多账号 + 各自一张 passport + 各自一个客户端" = 合法的双开上限;
想要同账号多开,撞的是服务端会话规则,不是客户端。
5. 方法
5.1 32 位提权注入
TCGame.exe 和 KartRider.exe 都是 x86,且跑在提权上下文里 —— 中权限工具会直接 OpenProcess err=5。
要用 32 位的 PowerShell:C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe。
HANDLE h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
LPVOID mem = VirtualAllocEx(h, NULL, len, MEM_COMMIT, PAGE_READWRITE);
WriteProcessMemory(h, mem, dllPath, len, NULL);
CreateRemoteThread(h, NULL, 0,
(LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandleA("kernel32"), "LoadLibraryA"),
mem, 0, NULL);5.2 trampoline 挂钩
| 序言 | 处理方式 |
|---|---|
8B FF 55 8B EC / 55 8B EC 83 EC 等标准函数头 | 复制 N 字节进 trampoline,回填 E9 rel32 |
FF 25 imm32(jmp dword ptr [addr] 转发桩) | 不改代码,直接改那个指针槽 |
A1 imm32 C3(mov eax,[global]; ret) | 直接改全局槽里的指针,最干净 |
第三条是意外收获:Win11 上 GetCommandLineW 就是 A1 xx xx xx xx C3 ——
它只是"返回一个全局指针",想替换返回值,改槽就够了。
顺带纠正一个错误:只改 PEB 里的 CommandLine 没用 ——
kernelbase 在进程启动时就把命令行缓存进自己的全局了,GetCommandLine 读的是缓存。
5.3 调用点定位
_ReturnAddress()(直接调用是系统 DLL 包装层)→
从返回地址槽顺栈扫 + 模块归属过滤(找到真正的业务代码)→
映射成 模块+偏移(跨重启可用)。
5.4 内存取证的两个坑
- 静态字符串/立即数引用扫描:对 exe 静态段有效;
- 运行期拼出来的字符串在匿名内存里:找"发现正在使用异常程序"时,
它出现在module=(无)的区域,代码引用扫描结果 0 处,静态定位直接失效。
这时只能反过来钩它可能经过的 API(SetWindowTextW、MessageBoxW、DrawTextW、ExtTextOutW…),
在弹窗那一刻抓调用点 —— 记得顺栈回溯到自己的业务模块帧(直接调用者往往只是 user32 )。
5.5 只读封包记录
不想碰网卡,就在客户端进程里钩 ws2_32 的 7 个函数:send / recv / sendto / recvfrom / connect / WSASend / WSARecv,
按 时间|方向|socket|长度|hex+ascii 落盘。只读、不改一个字节,
比网卡抓包多一个天然好处:带进程上下文,还能顺手带上调用点。
6. 顺手抓到的协议结构
用 5.5 的记录器跑一轮"登录 → 大厅 → 进小屋 → 回房间":
07:48:03.793 CONNECT -> 162.14.144.162:39311 ← 主通道
07:48:03.896 上行 132B ← 认证握手
07:48:05.368 上行 655B ← 登录请求(带 passport)
07:48:12.2xx 上行 199/200/346/622B ← 大厅进入的一串请求
07:48:13.9~14.4 下行 16384/15088/15184B ← 服务端一次性推下来的状态
07:48:41.433 下行 16384B ← 状态切换:服务端先推
07:48:41.5xx 上行 87/64/67/496B ← 客户端随后应答
07:48:44.755 CONNECT -> 162.14.144.174:39322 ← 小屋/房间走【另一条通道】
07:49:05.902 CONNECT -> 162.14.144.167:39311 ← 主通道被服务端【重定向】到实例服三点观察:
- 服务端权威:状态切换是大块下行在前、客户端小包应答在后 →
客户端点按钮并不能自己决定场景切换; - 功能分通道:小屋/房间是独立端口(39322);
- 会话迁移:进小屋后主通道会被重定向到另一台实例服务器。
另外载荷全是高熵数据(4A EB E3 2F DA 4F 46 EE…)→ 协议加密或压缩。
想在协议上做文章,第一件事就是先把加解密还原出来。
7. 结论
- 多开的地板在反作弊:进程内模块(
NGClient.aes/BlackCall.aes)+ 机器级进程(BlackCipher.aes)+ 内核交叉验证。
客户端侧"钩 API + 谎报"是结构性失败 —— 反作弊抓的是"一致性",不是"两个客户端"。 - 多开的上限是账号数:passport 按账号发,且同账号只能一个在线会话。
所以"双开"能成立的前提是两个账号各一张 passport、各一个客户端,不是把同一个客户端开两份。 - passport 是钥匙:拿到
-passport:,KartRider.exe就能脱离TCGame.exe自己登录 ——
实测平台关掉、客户端照样上线。 - "把平台当 passport 发放机"是干净的思路:拒绝真启动 → 按钮常蓝 → 一台机器可以给多个账号取 passport。
- 本文是一套可复用的逆向方法论:提权注入、序言识别、顺栈调用点回溯、
匿名内存取证、只读封包记录、窗口自动化。换个目标照样能用。
本文为个人技术研究记录,所有测试均在自有环境、自有账号上进行,未对任何线上服务造成影响。
图很详细