惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
博客园 - 【当耐特】
S
SegmentFault 最新的问题
阮一峰的网络日志
阮一峰的网络日志
大猫的无限游戏
大猫的无限游戏
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The Cloudflare Blog
Apple Machine Learning Research
Apple Machine Learning Research
小众软件
小众软件
博客园 - 三生石上(FineUI控件)
Martin Fowler
Martin Fowler
云风的 BLOG
云风的 BLOG
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
F
Fortinet All Blogs
Y
Y Combinator Blog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
J
Java Code Geeks
Engineering at Meta
Engineering at Meta
MyScale Blog
MyScale Blog
B
Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
人人都是产品经理
人人都是产品经理

小野博客

Hexo 主题 Win7:把 Windows 7 装进博客 Bandizip 签名程序 RegDll 实现 DLL 白加黑加载 Typora 1.14.9 离线授权机制分析与本地 Hook 实现 nProtect BlackCipher 反作弊分析 Snipaste 2.11.X 离线授权分析 FinalShell 4.6.5离线激活 朱镕基同志逝世 【开源主题】LB_Down下载站-支持1.3.0版本typecho主题 没有信用卡,我为了注册一个20.uy域名,走了一条多长的弯路 QQ音乐播放接口:PHP逆向分析与实现 CTF:最好的语言解密过程 狐蒂云疑似准备跑路:低价云服务器的水有多深 WeMod PRO 会员高级版脚本分享 PowerShell 执行策略被禁止解决方法 一款可以屏蔽国外广告的IP过滤评论插件 当 PCDN 来敲门:一个"小"博客的 200G 流量惊魂记 船新升级!我的“一言”系统支持一键换肤了(内附10套精美皮肤) 再续前缘:利用自建一言Api接口嫁接给typecho 给网页装个“灵魂”:我写了一个超丝滑的【一言】自建语录api 桌面工具-极光便签-随时笔记
跑跑卡丁车国服:从多开到劫持登录的逆向笔记
小野 · 2026-09-19 · via 小野博客
一篇流水账式的实战记录:从"多开游戏"开始,一路挖到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.dllGameToTCG.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 完全关掉):

screenshots.gif

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.exeKartRider.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 imm32jmp dword ptr [addr] 转发桩)不改代码,直接改那个指针槽
A1 imm32 C3mov eax,[global]; ret直接改全局槽里的指针,最干净

第三条是意外收获:Win11 上 GetCommandLineW 就是 A1 xx xx xx xx C3 ——
它只是"返回一个全局指针",想替换返回值,改槽就够了。

顺带纠正一个错误:只改 PEB 里的 CommandLine 没用 ——
kernelbase 在进程启动时就把命令行缓存进自己的全局了,GetCommandLine 读的是缓存。

5.3 调用点定位

_ReturnAddress()(直接调用是系统 DLL 包装层)→
从返回地址槽顺栈扫 + 模块归属过滤(找到真正的业务代码)→
映射成 模块+偏移(跨重启可用)。

5.4 内存取证的两个坑

  • 静态字符串/立即数引用扫描:对 exe 静态段有效;
  • 运行期拼出来的字符串在匿名内存里:找"发现正在使用异常程序"时,
    它出现在 module=(无) 的区域,代码引用扫描结果 0 处,静态定位直接失效。
    这时只能反过来钩它可能经过的 APISetWindowTextWMessageBoxWDrawTextWExtTextOutW…),
    在弹窗那一刻抓调用点 —— 记得顺栈回溯到自己的业务模块帧(直接调用者往往只是 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      ← 主通道被服务端【重定向】到实例服

三点观察:

  1. 服务端权威:状态切换是大块下行在前、客户端小包应答在后 →
    客户端点按钮并不能自己决定场景切换;
  2. 功能分通道:小屋/房间是独立端口(39322);
  3. 会话迁移:进小屋后主通道会被重定向到另一台实例服务器。

另外载荷全是高熵数据4A EB E3 2F DA 4F 46 EE…)→ 协议加密或压缩
想在协议上做文章,第一件事就是先把加解密还原出来。


7. 结论

  1. 多开的地板在反作弊:进程内模块(NGClient.aes / BlackCall.aes)+ 机器级进程(BlackCipher.aes)+ 内核交叉验证。
    客户端侧"钩 API + 谎报"是结构性失败 —— 反作弊抓的是"一致性",不是"两个客户端"。
  2. 多开的上限是账号数:passport 按账号发,且同账号只能一个在线会话。
    所以"双开"能成立的前提是两个账号各一张 passport、各一个客户端,不是把同一个客户端开两份。
  3. passport 是钥匙:拿到 -passport:KartRider.exe 就能脱离 TCGame.exe 自己登录 ——
    实测平台关掉、客户端照样上线。
  4. "把平台当 passport 发放机"是干净的思路:拒绝真启动 → 按钮常蓝 → 一台机器可以给多个账号取 passport。
  5. 本文是一套可复用的逆向方法论:提权注入、序言识别、顺栈调用点回溯、
    匿名内存取证、只读封包记录、窗口自动化。换个目标照样能用。

本文为个人技术研究记录,所有测试均在自有环境、自有账号上进行,未对任何线上服务造成影响。