日二节奏启用国服上传体验

2026-05-06 · 10 min read · 2.7k words

Table Of Contents

  1. 1. 概述
  2. 2. 发现过程
    1. 2.1. Step 1:从 RTTI 字符串入手
    2. 2.2. Step 2:跟踪字符串引用
    3. 2.3. Step 3:定位 ClientUpload 类的虚表
    4. 2.4. Step 4:找到实际的发送函数
    5. 2.5. Step 5:确认调用约定
  3. 3. 代码逻辑结构
    1. 3.1. 整体上传架构
    2. 3.2. UpsertUserAll 的数据结构
      1. 3.2.1. 增量 vs 全量上传
    3. 3.3. 分片上传机制
    4. 3.4. sendUpsertUserAll 内部逻辑
    5. 3.5. 调用链总览
  4. 4. Patch 分析
    1. 4.1. Patch 目标
    2. 4.2. 为什么选这个函数
    3. 4.3. Patch 内容
      1. 4.3.1. 原始字节
      2. 4.3.2. Patched 字节
      3. 4.3.3. 反编译对比
      4. 4.3.4. 为什么是 retn 8 而不是 retn
      5. 4.3.5. 残留字节处理
  5. 5. 总结

概述

本文记录了如何定位并 patch 掉成绩上传函数 projClient::ClientUpload::sendUpsertUserAll使游戏在一局结束后成绩上传直接失败

所有分析基于 X-VERSE-X 的 chusanApp.exebase address 0x400000

仅供学习参考

发现过程

Step 1从 RTTI 字符串入手

第一步是在 IDA Pro 的 string window 里搜索已知的关键字目标是找到和成绩上传相关的类和方法upsertUserAllClientUpload

0x18B8B74: "upsertUserAll"
0x18B40BC: "clientUpload"
0x1C1C1BC: ".?AVUpsertUserAll@projClient@@"
0x1C1BC8C: ".?AVClientUpload@projClient@@"

RTTI 字符串暴露了 projClient 命名空间下的两个类UpsertUserAllClientUpload这说明游戏用的是 C++ 类继承体系上传逻辑封装在 ClientUpload 类里

Step 2跟踪字符串引用

"upsertUserAll"0x18B8B74做 xref 分析找到它在 0x9D7F00 处被引用反编译这个函数

unsigned int __thiscall sub_9D7F00(int this, int a2) {
    // ...
    sub_410D48("userId", 6u);
    sub_40EE12(this, v6);

    sub_410D48("segaIdAuthKey", 0xDu);
    sub_468688((void *)(this + 8), (int)v6);

    sub_410D48("upsertUserAll", 0xDu);
    sub_430134(this + 32, v4);
    // ...
}

这是一个 JSON 键值构建函数往某个数据结构里写入 userIdsegaIdAuthKeyupsertUserAll 三个字段这很像是构建 HTTP 请求体的过程 —— upsertUserAll 是发送给服务端的 API 端点名或者 payload 键名

Step 3定位 ClientUpload 类的虚表

"clientUpload"0x18B40BC做 xref找到 0x9989E0这个函数构建了包含 clientUpload 字段的请求体它是上层调度逻辑负责组装整个上传请求的元数据

进一步对 0x9989E0 做 xref发现它通过 thunk 0x45B6FE 被调用0x45B6FE 的调用者是 0x99CE100x99CE10 是一个较大的函数它负责

  1. 把游戏数据分片chunk size = 10240 字节
  2. 对每个分片调用 sub_45B6FEClientUpload 调度器
  3. 组装成带 orderIddivNumberdivLength 的上传包

这说明上传机制是分片上传 —— 大的成绩数据被拆成多个 10KB 的 chunk 依次发送

Step 4找到实际的发送函数

顺着 0x99CE10 的调用链往上追经过 thunk 0x45FDA8找到 0x99CC50这个函数是分片上传的协调器它管理一个数据缓冲区this + 4 指向 bufferthis + 12 是总大小this + 16 是当前 chunk index每次调用时读取下一片数据并发送

而真正把序列化后的数据通过 HTTP 发送出去的函数需要从 ClientUpload 的虚表里找我注意到 0x18B3EA0 处有一个 vtable其中第二个条目指向 0x420AD6这是一个跳转到 0x98C1D0 的 thunk0x98C1D0 又跳转到 0x995050

反编译 0x995050

char __thiscall sub_995050(_DWORD *this, int a2, int a3) {
    sub_47007C(v8);                                    // 初始化某种缓冲区
    sub_42E5DC(v9, *(this + 24), *(this + 25), a2, a3); // 序列化 UserAll 数据
    v4 = sub_458E31();                                  // 获取 HTTP 客户端实例
    v5 = sub_461856(v4, v9, 0, 2);                     // 发送 HTTP 请求
    // ... cleanup ...
    return v5;
}

这就是 sendUpsertUserAll —— 它把完整的 UserAll 数据序列化后通过 HTTP 发送到服务端函数返回一个 charbool表示上传成功或失败

Step 5确认调用约定

sendUpsertUserAll 是一个 __thiscall 虚函数通过 vtable 调用this 指针在 ecx另有 2 个显式参数在栈上a2a3因此 caller 通过栈传递了 8 字节的参数函数退出时需要 retn 8 来清理

这可以通过栈帧布局验证

arg_0 @ offset 0x44 (a2)
arg_4 @ offset 0x48 (a3)
__return_address @ offset 0x40
__saved_registers @ offset 0x3c  (ebp push)

this 不在栈上ecx栈上只有 2 个 DWORD = 8 字节确认 retn 8 正确

代码逻辑结构

整体上传架构

CHUNITHM 的成绩上传采用分片 JSON 上传机制整个流程可以分成 4 层

Layer 1: 序列化层 (Serialization)
    UpsertUserAll::serialize() @ 0x9D6C40
    │  将 27+ 种玩家数据序列化为 JSON
    │
Layer 2: HTTP 请求构建层 (Request Builder)
    UpsertUserAll::buildRequest() @ 0x9D7F00
    │  写入 userId / segaIdAuthKey / upsertUserAll 字段
    │
Layer 3: 分片上传调度层 (Chunked Upload Dispatcher)
    ChunkedUpload::dispatchNext() @ 0x99CC50
    ChunkedUpload::sendChunk() @ 0x99CE10
    ClientUpload::buildMetadata() @ 0x9989E0
    │  把大数据拆成 10240 字节的分片
    │  为每片附加 orderId / divNumber / divLength 元数据
    │
Layer 4: HTTP 发送层 (Network Transport)
    ClientUpload::sendUpsertUserAll() @ 0x995050  ← 我们 patch 的目标
    HTTPClient::send() @ 0x996DC0
    │  通过 WinHTTP 发送 POST 请求
    │  返回 bool 表示成功/失败

UpsertUserAll 的数据结构

sub_9D6C40 @ 0x9D6C40 是序列化函数它把一个巨大的聚合数据对象拆成 27+ 个子字段逐个序列化完整的字段列表

Index 字段名 序列化函数
0 userData sub_46B6F3
1 userGameOption sub_41B81A
2 userCharacterList sub_40AFC9
3 userItemList sub_40ECFF
4 userMusicDetailList sub_41FA46
5 userActivityList sub_40E12E
6 userRecentRatingList sub_42700C
7 userPlaylogList sub_44493B
8 userChargeList sub_43EF40
9 userCourseList sub_42CFF7
10 userDuelList sub_4625D5
11 userCMissionList sub_424EEC
12 userTeamPoint sub_462B3E
13 userRatingBaseHotList sub_460BD1
14 userRatingBaseList sub_460BD1
15 userRatingBaseNextList sub_460BD1
16 userRatingBaseNewList sub_460BD1
17 userRatingBaseNewNextList sub_460BD1
18 userLoginBonusList sub_449D6E
19 userMapAreaList sub_44836F
20 userOverPowerList sub_42C70F
21 userNetBattlelogList sub_433DCA
22 userEmoneyList sub_40C248
23 userNetBattleData sub_417F2B
24 userFavoriteMusicList sub_46A7BC
25 userUnlockChallengeList sub_45FE98
26 userLinkedVerseList sub_465AFF
27-31 isNewCharacterList sub_468688

其中 userMusicDetailList 包含每首歌的成绩记录每条含 scorejudgemaxCombo 等userPlaylogList 包含游玩日志userRatingBase*List 包含 Rating 计算所需的 Hot/Best/New 列表

增量 vs 全量上传

序列化函数有两条路径

  • 增量路径this[n] != this[n+1]检查每个子列表的脏标记只序列化变化过的数据每个子对象用一对指针表示范围begin/end如果 begin == end 说明没有变化跳过
  • 全量路径byte at a2+52 == 0无条件序列化所有 27+ 个字段用于首次上传或强制同步

分片上传机制

sub_99CC50 @ 0x99CC50 是分片协调器推断出来的对象布局

struct ChunkedUpload {
    /* +0  */ byte  enabled;        // 是否启用上传
    /* +4  */ void* buffer;         // 序列化后的数据 buffer
    /* +8  */ int   bufferSize;     // buffer 总大小
    /* +12 */ int   totalChunks;    // 总分片数 (bufferSize / 10240)
    /* +16 */ int   currentChunk;   // 当前分片 index
    // ...
};

核心逻辑

if (!this->enabled) {
    // 返回空字符串<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>跳过上传
    return empty_response;
}

if (this->buffer && this->currentChunk < this->totalChunks) {
    offset = 10240 * this->currentChunk;
    remaining = this->bufferSize - offset;
    chunkSize = min(10240, remaining);

    chunk = make_slice(this->buffer + offset, chunkSize);
    result = SendChunk(this, chunk);  // -> 0x99CE10
    this->currentChunk++;
    return result;
} else {
    return empty_string;  // 所有分片发送完毕
}

每次调用处理一个 10KB 分片由上层循环驱动直到所有分片发完

sendUpsertUserAll 内部逻辑

函数projClient::ClientUpload::sendUpsertUserAll @ 0x995050

原始反编译结果patch 前

char __thiscall sendUpsertUserAll(_DWORD *this, int a2, int a3) {
    int v4;
    char v5;

    sub_47007C(v8);                                         // 初始化局部缓冲区
    sub_42E5DC(v9, *(this + 24), *(this + 25), a2, a3);    // 序列化完整 UserAll 数据
    v4 = sub_458E31();                                      // 获取全局 HTTP 客户端单例
    v5 = sub_461856(v4, v9, 0, 2);                         // 发送 HTTP POST 请求
    // ... cleanup SSO strings ...
    return v5;                                              // 返回 true/false
}

关键子调用

函数 地址 作用
sub_47007C 0x47007C 初始化一个 std::string 或 buffer
sub_42E5DC 0x42E5DC 核心序列化调用 0x9E3D50把 UserAll 聚合对象序列化为 JSON 字符串
sub_458E31 0x458E31 返回全局 HTTP 客户端单例&dword_1C7462C
sub_461856 0x461856 HTTP 发送 thunk跳转到 0x996DC0

sub_996DC0 @ 0x996DC0 是实际的 HTTP 传输函数约 1098 字节

  1. 检查连接状态this + 64 处的 flag如果为 1 直接返回 0
  2. 序列化请求体# 分隔符
  3. 计算 HMAC 或签名sub_45B717使用 this + 96this + 100 处的密钥
  4. 通过 WinHTTP 发送请求
  5. 返回成功/失败标志

调用链总览

从游戏结束到成绩上传的完整调用链

游戏结束
  → PlaySM::postResultDecision()
    → ChunkedUpload::dispatchNext() @ 0x99CC50
      → ChunkedUpload::sendChunk() @ 0x99CE10
        → ClientUpload::buildMetadata() @ 0x9989E0
          → [构建 orderId / divNumber / divLength / clientUpload 元数据]
        → ClientUpload vtable[1]() @ 0x995050  (sendUpsertUserAll)
          → UpsertUserAll::serialize() @ 0x9E3D500x9D6C40
            → [序列化 27+ 种玩家数据为 JSON]
          → HTTPClient::getInstance() @ 0x9E3530
          → HTTPClient::send() @ 0x996DC0
            → [签名 → WinHTTP POST → 返回结果]

Patch 分析

Patch 目标

函数projClient::ClientUpload::sendUpsertUserAll @ 0x995050

这是成绩上传流程中最关键的一环 —— 它是虚表中唯一负责把完整 UserAll 数据通过网络发出去的入口在它之上是分片调度和元数据构建在它之下是 WinHTTP 传输层patch 掉这一层上面的所有逻辑都变成了无用功

为什么选这个函数

它是 ClientUpload 虚表的第 2 个条目vtable slot 1所有成绩上传请求最终都经过这个函数不存在绕过它的旁路

并且它只影响 UpsertUserAll 这个请求类型其他网络功能认证匹配下载等走的是不同的 vtable slot 或完全不同的类不受影响

Patch 内容

原始字节

995050: 55                 push    ebp
995051: 8B EC              mov     ebp, esp
995053: 6A FF              push    0FFFFFFFFh
995055: 68 70 C1 59 01     push    offset SEH_995050
99505a: 64 A1 00 00 00 00  mov     eax, large fs:0
...

原始函数有完整的 SEH prologue局部变量初始化序列化调用HTTP 发送cleanup 等等

Patched 字节

995050: 33 C0              xor     eax, eax
995052: C2 08 00           retn    8
原始: 55 8B EC 6A FF ...
补丁: 33 C0 C2 08 00 ...

只有 5 字节前两条原始指令push ebp + mov ebp, esp共 3 字节被覆盖外加第 3 条指令 push -1 的前 2 字节

反编译对比

// BEFORE:
char __thiscall sendUpsertUserAll(_DWORD *this, int a2, int a3) {
    sub_47007C(v8);                                         // 初始化缓冲区
    sub_42E5DC(v9, *(this + 24), *(this + 25), a2, a3);    // 序列化 UserAll
    v4 = sub_458E31();                                      // 获取 HTTP 客户端
    v5 = sub_461856(v4, v9, 0, 2);                         // 发送请求
    // ... cleanup ...
    return v5;
}

// AFTER:
int __stdcall sendUpsertUserAll(int a1, int a2) {
    return 0;
}

函数入口直接返回 0false不做任何操作

为什么是 retn 8 而不是 retn

sendUpsertUserAll 是通过虚表调用的 __thiscall 方法

  • thisecx不需要栈清理
  • 2 个显式参数 a2a3 在栈上各 4 字节 = 8 字节

栈帧验证

__saved_registers @ ebp+0x3c   (pushed ebp)
__return_address @ ebp+0x40    (return address)
arg_0            @ ebp+0x44    (a2, 4 bytes)
arg_4            @ ebp+0x48    (a3, 4 bytes)

调用者 push 了 8 字节callee 必须通过 retn 8 来清理如果用裸 retnC3栈会不平衡调用者会崩溃

残留字节处理

0x995055 之后的原始字节68 70 C1 59 01 ...在 patch 后变成不可达的死代码不需要 NOP 它们 —— 执行流从 0x995052retn 8 直接返回永远不会碰到那些字节

IDA 的分析可能会对残留的 SEH_995050 引用报警告因为 SEH setup 被跳过了但 .pdata 里还有条目这纯粹是外观问题运行时没有任何影响 —— SEH handler 只在异常发生时才会被查找而这个函数根本不会触发异常

总结

这个 patch 的核心思路极其简单在成绩上传函数的入口处放一个 return false

最终结果是 5 字节的 patch精确阻断单个 API不影响任何其他功能

和之前的自检跳过 patch 一样修改函数入口比修改中间逻辑更可靠 —— 你不需要理解函数内部做了什么只需要知道它返回什么调用者如何处理返回值xor eax, eax; retn N 是最通用的 "kill switch" 模式