5714 字
29 分鐘
手写 TCompactProtocol:一次移动端私有 RPC 协议的完整逆向实践

目录

  1. 1. 背景与目标
  2. 2. 环境与工具链
  3. 3. 第一阶段:静态侦察
  4. 4. 第二阶段:关键突破——thrift 协议
  5. 5. 第三阶段:手写 TCompactProtocol
  6. 6. 第四阶段:空凭证验证法
  7. 7. 第五阶段:凭证获取
  8. 8. 第六阶段:只读能力扩展
  9. 9. 第七阶段:写操作与同步语义
  10. 10. 第八阶段:工程化
  11. 11. 防守视角:这套体系该怎么加固
  12. 12. 总结

关键词:Android 逆向 / Thrift Compact Protocol / 协议还原 / 自动化客户端 / 安全研究

本文记录一次完整的、以学习为目的的 App 私有接口分析过程:从拿到 APK 开始,到最后用自己的 Python 客户端稳定调用目标服务并完成数据同步。目标对象是一款头部的语言学习类 App(下称 V-App),所有敏感信息(域名、包名、产品名、账号数据)均已做脱敏处理,文中出现的域名均为虚构。

合规声明:本文仅为协议学习与技术研究。分析只使用本人小号、只读接口优先、不触碰任何支付/会员权益逻辑、未进行任何形式的数据分发。复现前请确认目标软件的服务条款,后果自负。


1. 背景与目标 {#section-1}#

V-App 是一款装机量很大的语言学习类应用,核心功能是“背单词”。它的内容体系(词书、配图、音频、学习进度)都在服务端,客户端每次学习都要和服务端交互。

动机:我希望在电脑上也能背单词,并且和手机上的学习进度互通(电脑背的记录手机能看到,反之亦然)。官方没有提供 Web 端,那么理论上只要搞清楚它的接口协议,就可以自己写一个客户端。

目标拆解:

  1. 拿到学习数据:我正在学的词书、每个词的内容(释义/音标/音频/配图)

  2. 拿到学习进度:手机端已经学过哪些词、学到什么程度

  3. 上报学习进度:在电脑上背完的词,能同步回手机端

红线(先给自己画好):

  • 只用本人的小号

  • 只读接口优先,写操作必须经过充分验证

  • 绝不触碰支付、会员、越权等任何权益相关逻辑

  • 不做分发,纯自用

2. 环境与工具链 {#section-2}#

工欲善其事,先交代环境(这也是后面所有操作的前提):

| 项 | 说明 |

| --- | --- |

| OS | Windows 10 22H2 |

| 网络 | 有代理(Clash 类,TUN + fakeip 模式)——这一点后面会多次埋下伏笔 |

| Python | 3.9(客户端只用标准库:struct / urllib / http.client,零第三方依赖) |

| JDK | Temurin 17(跑 jadx/apktool) |

| jadx | 1.5.6,dex → Java 源码 |

| apktool | 2.9.3,资源/Manifest/smali 还原 |

| sqlite3 | 随 platform-tools 附带 |

| 抓包工具 | 备用(本次实际未作为主力,原因见第五节) |

工作区结构(按样本/工具/笔记分离,方便归档和回溯):

proj/
apk/ # 样本(记录版本与 SHA256)
tools/ # jdk / jadx / apktool / platform-tools
notes/ # 侦察笔记、接口清单、协议还原记录
client/ # 最终产物:Python thrift 客户端 + 本地 HTTP 服务

Windows 上的两个编码坑先记下来(本文所有涉及中文内容的脚本都因此调整过,这类细节决定自动化脚本的稳定性):

  1. .cmd 批处理按 ANSI(GBK) 解析,交付用的启动脚本只写 ASCII

  2. PowerShell 5.1 把无 BOM 的 UTF-8 当 GBK 读;Get-Content/Set-Content 读写一次中文文件就可能毁掉编码。涉及中文的文件一律用 .NET API 显式指定编码:

    [IO.File]::WriteAllText($p, $text, (New-Object Text.UTF8Encoding($false)))

3. 第一阶段:静态侦察 {#section-3}#

3.1 拿样本#

从应用商店渠道下载 APK,第一件事是记录指纹——样本即快照,App 后续更新导致接口变更时,可以按指纹回溯对比:

SHA256: 〔已脱敏〕
versionCode / versionName: 〔已脱敏〕
minSdk 24 / targetSdk 34
size: 355 MB

355MB 对于这个体量的 App 明显偏大,里面一定打包了重型资源(后面证实:内置词典数据库 + 游戏引擎 so)。

3.2 apktool 解包,先看 Manifest#

toolsapktool.cmd d -f apkbase.apk -o apktool_out

Manifest 里几个关键信息:

  • 包名 com.vendor.vocab(已脱敏)

  • 入口 SplashActivity / MainTabActivity,application 类 VAppApp

  • 未见加固特征:没有 pairip / 360 / 腾讯乐固的壳,dex 可直接反编译

  • 第三方 SDK 清单:微信/QQ 登录支付、友盟统计、个推、Bugly、数美(设备指纹)

  • usesCleartextTraffic="true" + 自定义 network_security_config —— 调试遗留,说明客户端侧并没有认真做传输层加固

  • 一个有意思的 deeplink scheme:vapp://com.vendor.vocab/word/lookup 等,全部 routing 映射到内部 Activity(后期做深链唤起有用,本次未深入)

3.3 从代码目录反推技术栈#

lib/arm64-v8a/ 下的 so 文件给出技术栈画像:

| so | 推断 |

| --- | --- |

| libcocos.so (45MB) | Cocos2d-x,承载内置的单词小游戏模块 |

| libmsaoaid*.so | 数美设备指纹 SDK(风控存在,但强度未知) |

| 没有 libflutter.so / libapp.so | 不是 Flutter,是原生 Kotlin/Java + Compose |

结论:不需要 blutter(Flutter 逆向工具),jadx 直接反编译 dex 即可。这一步的判断能省掉大量无效劳动。

3.4 顺手捡到的资产:内置词典库#

res/lexicon.db 有 18.5MB,拖出来是标准 SQLite:

$ sqlite3 lexicon.db ".tables"
dict_a_b dict_main dict_c ... topic_book_map ...
dict_main: 52,012 条,字段 word / accent(音标) / mean_cn(释义) / freq(词频)
全部词表合计:约 10.9 万词条

topic_book_map(52,012 行)把单词映射到词书——这个 topic_id 后来被证明和服务端协议用的是同一套 ID 体系,先记住这个伏笔。

4. 第二阶段:关键突破——thrift 协议 {#section-4}#

4.1 jadx 全量反编译,先挖域名#

toolsjadx-cli.cmd --no-res -j 4 -d jadx_out apkbase.apk

约 10MB 的 dex 反编译完(有 979 个 error,多为 Kotlin 元数据问题,不影响)。先 grep 域名常量,命中一个 Thrift 配置类,域名体系一览无余:

| 用途 | 域名(脱敏后) |

| --- | --- |

| 账号/认证 | passport.example.com |

| 学习服务 | learn.example.com |

| 学习 H5 | learn.example.com/{en,fr,ko,es}-mode/(App 内大部分学习页其实是 WebView) |

| 资源 | resource.example.com |

| 单词书 | booklist.example.com |

| 老 API | www.example.com/api/...?access_token= |

| CDN | cdn.example.com(图片/音频,相对路径直拼) |

4.2 命中要害:/rpc/* + thrift#

继续在反编译代码里搜路径常量,拿到这样一张表:

/rpc/users /rpc/words /rpc/studys
/rpc/user_study /rpc/user_book /rpc/resource_api
/rpc/unified_user_service ...

对应的 Java 包 com.vendor.online.* 下的类,全是 Apache Thrift 生成的桩代码(识别特征:*_args / *_result 内部类、StandardScheme、writeFieldBegin(new TField(...)))。也就是说:

服务端接口用的是 Thrift over HTTP,而且客户端里带着完整的接口契约。

这是本次分析最大的成本变量:Thrift 生成代码是“自描述”的——每个方法名、每个结构体的 field id 和类型都以字面量形式存在。不需要抓一个包,协议就还原了 90%。

4.3 还原传输层#

三个关键类的信息拼出完整传输模型:

  1. URL 模板(thrift/a.java):"%s%s/%s/%d" → POST https://learn.example.com/rpc/user_study/{method}/{当前毫秒时间戳}

  2. 消息格式(thrift/k.java):

    okhttp3.l.a aVarA = new okhttp3.l.a()
    .a("Accept", "application/x-thrift")
    .a("User-Agent", "vapp_app/android/" + version)
    .a("Cookie", strA); // ← Cookie 里带凭证
  3. 凭证载体(另一处请求封装实锤):

    map.put("Cookie", "access_token=" + userRecordQ.getToken());

鉴权模型结论:所有业务请求只需要一个 HTTP 头 Cookie: access_token=<token>。没有签名、没有 pinning、没有时间戳校验。防护等级一目了然。

4.4 从生成代码提取 IDL#

以最核心的学习进度上报为例,生成代码里的 write() 方法直接给出字段表:

oprot.writeFieldBegin(new TField("word_topic_id", TType.I64, (short) 1));
oprot.writeFieldBegin(new TField("current_score", TType.I32, (short) 2));
oprot.writeFieldBegin(new TField("span_days", TType.I32, (short) 3));
...

翻译成 IDL:

struct UserDoneWordRecord {
1: i32 word_topic_id 8: i32 tag_id
2: i32 current_score 9: i32 spell_score
3: i32 span_days 10: i32 listening_score
4: i32 used_time 11: i32 chn_score
5: i32 done_times 12: i32 review_round
6: i32 wrong_times
7: i32 is_first_do_at_today
}
service UserStudyApiService {
i32 update_done_data(1: i64 last_sync_at,
2: list<UserDoneWordRecord> arr_done_records,
3: i32 current_word_level_id,
4: bool is_today_completed);
UserBasicInfoPlusV2 user_basic_info_v2();
list<UserLearnedWordInfo> get_learned_words_list(1: i32 book_id);
list<UserRoadMapElementV2> roadmap_by_word_level_v2(1: i32 book_id);
list<SelectBookPlanInfo> get_all_selected_book_plan_info();
...
}

服务与 host 的映射也一并确认:user_study → learn / resource_api → resource / unified_user_service → passport,且每个都有国际站备份域名。

到这一步,协议还原的情报工作已经完成,剩下的全部是工程问题。

5. 第三阶段:手写 TCompactProtocol {#section-5}#

Thrift 的 Compact Protocol 本身是个很紧凑的二进制协议,选择自己写而不是引库,理由是:引库要按 IDL 生成代码,而这个项目的 IDL 有上百个结构体;而 Compact 协议本身只有一页规则,写一个 300 行的通用编解码器,比给这个 IDL 搭脚手架更快,后续加接口也只是拼字段。

5.1 规则速查#

Compact Protocol 的核心规则(对照实现看最直观):

  1. varint:每个字节 7 bit 有效位,MSB=1 表示后续还有字节

  2. zigzag:有符号整数先 zigzag 再 varint。zigzag32(n) = (n<<1) ^ (n>>31)

  3. struct:字段序列,每个字段头 1 字节 (delta<<4)|type,delta = fid - last_fid;delta ≤ 0 或 > 15 时写成 type 字节 + zigzag varint 的 fid;struct 以 0x00(STOP) 结束

  4. bool:在 struct 内直接编码进 type nibble(1=true,2=false),不占 value 字节

  5. list:1 字节 (size<<4)|elem_type;size ≥ 15 时用 0xF0|elem_type + varint size

  6. double:小端 8 字节

  7. binary/string:varint 长度 + 原始字节

5.2 核心实现(Python,零依赖)#

先写 primitives:

def _uvarint(n: int) -> bytes:
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def _zigzag32(n): return ((n << 1) ^ (n >> 31)) & 0xFFFFFFFF
def _zigzag64(n): return ((n << 1) ^ (n >> 63)) & 0xFFFFFFFFFFFFFFFF

消息头 + framed 传输:

# 一条 CALL 消息 = [0x82][0x21][varint seqid][string method][args...]
# 外层 framed:4 字节大端长度前缀
w = Writer()
w.byte(0x82) # protocol id
w.byte((1 << 5) | 1) # version 1 | CALL(1) << 5
w.varint(1) # seqid
w.string('user_basic_info_v2')
frame = w.raw()
body = struct.pack('>I', len(frame)) + frame

发起调用(注意这里做了代理环境 TLS 抖动的自动重试,第 10 节会展开):

POST https://learn.example.com/rpc/user_study/user_basic_info_v2/{ms}
Accept: application/x-thrift
Cookie: access_token=<token>
User-Agent: vapp_app/android/<version> os_version/<os-version>

响应解析:剥 framed 头 → 校验 0x82 → 读 (type<<5)|version(REPLY=2)→ seqid → method name → 进入 result struct。result struct 的约定是:

  • field 0 = success

  • field 1 = SystemException{1: from_service, 2: from_method, 3: code, 4: message}

  • field 2 = LogicException{同上}

5.3 写的时候踩的最大的坑:list header#

我第一版把 list header 写成了“varint(size) + byte(type)”——这是从 Thrift 的非 compact 协议记忆里带过来的错误。实际 compact 是单字节 (size<<4)|elem_type:

# ❌ 错误版(第一次实现)
self.varint(len(items)); self.byte(elem_ct)
# ✅ 正确版
n = len(items)
if n < 15:
self.byte((n << 4) | elem_ct)
else:
self.byte(0xF0 | elem_ct)
self.varint(n)

这个 bug 的表现非常隐蔽:所有不携带 list 的接口(比如只读用户信息)一切正常,直到第一次解析带 list 的响应才炸——cannot skip ct=20(非法的 compact type)。

调试过程值得一提:我先用“逐字段打印”定位到是 list header 错位,再对照协议规范重写。这类“协议魔数错位”问题的通用排查法:

  1. 把响应原始 hex dump 出来

  2. 和预期字段逐个字节比对

  3. 找到第一处语义不合法的字节(本例中是 type nibble=20,合法值只有 0-12)

修复后一条命令直接跑通——这也是自己的协议栈相对黑盒抓包的最大优势:任何一个字节的偏差都有明确的责任人。

5.4 通用结构解析器#

为了不用给上百个结构体逐个写解析代码,再加一个“泛型解析”:struct → {field_id: value} 的嵌套 dict,遇到未知字段按类型跳过:

def generic(self, ct):
if ct == CT_TRUE: return True
if ct == CT_FALSE: return False
if ct == CT_I32: return self.zigzag(32)
if ct == CT_BINARY:
b = self.binary()
return b.decode('utf-8', 'replace') # 尽力还原文本
if ct == CT_STRUCT:
out = {}
self.struct(lambda fid, ct: out.__setitem__(fid, self.generic(ct)) or True)
return out
if ct in (CT_LIST, CT_SET):
n, ect = self.list_header()
if n == 0: return []
return [self.generic(ect) for _ in range(n)]
...

这一个函数后面承载了所有 detail 类接口的解析,字段 id 语义在上层按需解释。

6. 第四阶段:空凭证验证法 {#section-6}#

协议栈写完,还没有 token。此时最便宜有有效的验证不是去抓包,而是空跑(dry run):

call('user_basic_info_v2', args=b'x00') # 不带任何凭证

第一次真实响应:

LogicException(code=8): token is empty [study-api.user_basic_info_v2]

这一行信息量巨大,三段全部验证通过:

  1. framed 传输对(能解出 struct)

  2. compact 编解码对(能读出 code 和 message)

  3. 服务端路由认识我(from_service=study-api,说明方法名、URL 模板、Host 选择全部正确)

这种“空跑验证”是私密协议分析的通用技巧:在没有凭证的前提下,先证明“服务端听得懂你在说什么”,把协议问题和凭证问题解耦。如果这一步返回的是 404、乱码或者连接重置,就要回头查 URL/编码,而不是浪费时间去搞账号。

7. 第五阶段:凭证获取 {#section-7}#

接口清单里有一个统一用户服务(unified_user_service),提取它的 args 结构:

struct PhoneLoginRequest {
1: string phone
2: string verify_code
3: string device
}
UserLoginResult login_with_phone(1: PhoneLoginRequest request);
// UserLoginResult { 1: string access_token, 2: i32 is_new_user, ... 7: string phone }
void send_sms_verify_code(1: string phone, 2: i32 verify_type); // 5 = 登录

流程就是最常规的手机号 + 短信验证码。先用一个明显非法的手机号试探,验证协议不用打扰真人:

send_sms_verify_code("<invalid-test-number>", 5)
→ LogicException(code=6): 手机号格式错误 [account-api.send_sms_verify_code]

再用格式合法但空号的测试号段:

send_sms_verify_code("<valid-format-test-number>", 5)
→ LogicException(code=13): 发送失败,请稍后重试

走到了“真实发送”阶段——说明参数格式被服务端接受,只差真实号码。用自己的小号完成登录,拿到 access_token,写接口全部解锁。

顺带说明:这个 App 的登录接口没有图形验证码、没有风控挑战,属于典型的“凭证获取零成本”。配合第 4 节的无签名传输,整套体系的防护基本处于“门锁了但钥匙挂在门口”的状态。

8. 第六阶段:只读能力扩展 {#section-8}#

有了 token,按“只读优先”原则按顺序打通:

8.1 用户与词书#

user_basic_info_v2()

返回(脱敏后):

{
"user_info": {
"current_word_level_id": 13,
"current_word_level_name": "脱敏-当前词书",
"nickname": "***",
"avatar": "https://cdn.example.com/ugc/.../avatar.jpg"
},
"learn_info": {
"last_sync_done_score_time": 1729603842,
"daily_plan_count": 200,
"review_plan_count": 800
}
}

三个关键数字:正在学习的词书(word_level_id=13)、每日新学 200、每日复习 800。

8.2 学习计划与“今天学什么”#

get_all_selected_book_plan_info() # → 12 本在学的书及各自计划
roadmap_by_word_level_v2(13) # → 6,279 个 topic
get_learned_words_list(13) # → 2,665 条已学记录

研究的核心结论(这也解释了为什么“网页版背单词”在架构上是可行的):

服务端不下发“今天该学哪些词”。它只给学习计划、roadmap(全部词条)和已学记录;“未学 / 今日新学 / 待复习 / 已掌握”四个分组完全由客户端本地计算:

record == null 或 score == -1024 → unlearned
todayNew == true → todayLearned
!todayNew 且 score ∈ [0,4] → unreviewed(待复习)
score > 4 → reviewed(已掌握)

这意味着:网页端可以完全本地驱动学习流程 UI,只在答题后把结果批量上报——和 App 的行为模型一致。上报就是 update_done_data,我把它证明是安全的之后才接进页面。

8.3 单词详情#

get_topic_resource_v2(TopicKey{topic_id, word_level_id, tag_id},
channel=STUDY, with_dict=True, with_media=True)

一个词的结构(脱敏样本,public 一词):

{
"word_basic_info": {
"topic_id": 17234,
"word": "public",
"accent_us": "/ˈpʌblɪk/", "accent_uk": "/ˈpʌblɪk/",
"audio_us": "/r/us_public_20231101...mp3",
"audio_uk": "/r/uk_public_20220421...mp3",
"mnemonic": "publ人民 + ic...的 → public 公开的,公众的"
},
"chn_means": [{"type": "adj.", "mean": "公用的,公共的"}, "..."],
"sentences": [{"en": "There is a non-smoking sign in the public place.",
"cn": "公共场所有一块禁烟标志。",
"img": "/r/1691006137...jpeg", "audio": "/r/um2tz....mp3"}]
}

其中音视频都是相对路径,前端拼 https://cdn.example.com 直连即可(实测无防盗链、200)。

词库侧:App 自带的 lexicon.db(10.9 万词,第 3.4 节)提供了 topic_id ↔ 单词的本地索引,与服务端 topic_id 同体系,可用于离线补全与查询。

9. 第七阶段:写操作与同步语义 {#section-9}#

写操作只有真正的一个:update_done_data。在设计验证实验之前,先从代码和文档两个角度推演它的语义:

本地记录 → 上报记录的映射(来自 LearnRecordManager 的映射代码):

word_topic_id <- topicId
current_score <- topicScore
span_days <- topicDay
wrong_times <- errNum
done_times <- doNum
used_time <- totalTime
is_first_do_at_today <- isTodayNew

关键发现:App 不是答完一题就上报一题,而是维护一张 syncing 表,积压 50 条或触发时机(打卡前/切书前)才批量上传一次(≤800 条),成功后从表内删除。这是典型的“本地队列 + 最终一致”模型。

我设计了一个最小副作用实验来验证:取远端已有的一条记录(topic_id=4, done_times=6),原样构造一条上报:

update_done_data(int(time.time()),
[{"word_topic_id": 4, "current_score": 4, "span_days": 2,
"used_time": 0, "done_times": 6, "wrong_times": 0,
"is_first_do_at_today": 0, "tag_id": 0, ...}],
book_id=13)

结果:

上报前 remoteSyncVer = 1729603842
→ syncVersion = 0, remoteSyncVer = 1790167952 # 服务端接受了
上报后该词:done_times: 6 → 12, update_days: 739 → 2

服务端语义实锤:上报是“增量累加”而非“覆盖”。这条经验直接决定了客户端的正确设计:

本地维护:
- total 表:每词全量状态(分数/次数/耗时) ← App 同款
- syncing 表:尚未上报的增量队列
答题 → 累加进 syncing → 达到阈值/退出时批量 update_done_data → 成功后清队列

如果用“覆盖”的错觉去设计,每次同步都会把远端计数器翻倍——这种错误在只读文档推演时根本发现不了,必须靠一次有对照的实测。

至此,双向同步的三块拼图齐了:

| 方向 | 接口 | 机制 |

| --- | --- | --- |

| 电脑 → 手机 | update_done_data | 增量队列,成功后清空 |

| 手机 → 电脑 | user_basic_info_v2 + get_learned_words_list | 比版本号 last_sync_done_score_time,落后才拉取合并 |

| 收藏同步 | PUT/DELETE /book/{id}/word/{topic_id}(user_book 域) | 单词本读写 |

10. 第八阶段:工程化 {#section-10}#

协议能跑通和“能用”之间隔着一整套工程问题。这一节的问题全部来自真实环境约束。

10.1 慢:链路本身 3 秒/次#

实测单条 thrift 调用 3 秒,PowerShell 发同样的请求甚至 18 秒。定位后发现不是协议问题:thrift 响应头是 Connection: keep-alive,但代理环境下 TLS 握手反复重建,纯链路成本。

解法一套组合拳:

服务端内存缓存 + stale-while-revalidate

def cached(key, ttl, fn, stale=0):
# fresh 命中:直接返回
# fresh 过期但 stale 期内:立即返回旧值 + 后台线程刷新
# 全过期:阻塞刷新(预热保证此路径极少走到)

用户信息/词书计划 fresh 60s + stale 900s;词详情 24h 缓存(内容几乎不变)。

写后精确失效:上报成功立刻清除 learned/queues 缓存,不靠自然过期

批量 + 并行:/api/words?ids=a,b,c 一次请求,线程池 10 worker 并行拉 N 个词详情,前端一次预取下一批 8 个

后台预热:服务启动/登录后,daemon 线程自动把 plan → queues → 前 64 个词灌进缓存。用户输短信验证码的几十秒正好被利用

最终效果(预热完成后):

plan 3ms queues 28ms words×8 9ms

10.2 稳:代理环境 TLS 抖动#

TUN + fakeip 模式下 SSL EOF 偶发(同一域名反复成功又反复 reset)。客户端加定向重试:仅对 SSL/EOF/handshake 类错误指数退避重试,最多 3 次,其他错误直接抛。

10.3 数据不丢#

答题记录按 App 同款模型落 localStorage(syncing 队列语义),每次变更即持久化、页面隐藏/关闭时 checkpoint;同词多条记录合并上报(后者覆盖前者,与队列去重一致)。

10.4 一个教训:数字 key 的 JSON 陷阱#

thrift 泛型解析产出 {2: {...}, 4: {...}}(field id 为 int key),直接 json.dumps 后 field id 变字符串,前端 ui[2] 全部 undefined——整个页面空白且无报错。修复:在后端加一层适配,把字段 id 翻译成可读字段再输出:

return {
'word': w.get(2), 'accent_uk': w.get(4), 'accent_us': w.get(3),
'audio_us': w.get(5), 'audio_uk': w.get(6),
'means': [{'type': m.get(3), 'mean': m.get(4)} for m in d.get(2, [])],
'sentences': [{'en': s.get(4), 'cn': s.get(5), 'img': s.get(7), 'audio': s.get(8)}
for s in d.get(4, [])],
...
}

这类“协议层 ID 语义”和“表现层字段语义”的撕裂,是所有做协议适配的前端/后端都会踩的坑,值得早早在架构里隔一层。

11. 防守视角:这套体系该怎么加固 {#section-11}#

站在防御方角度复盘,这套体系为什么“一捅就破”,以及业界标准的加固阶梯(这也是我在实战中同步总结的):

| 层级 | 手段 | 本次现状 |

| --- | --- | --- |

| 0 | HTTPS | ✅ 有,但只防信道不防客户端 |

| 1 | 隐藏密钥 | ❌ Manifest 明文 apiKey |

| 2 | token 传递方式 | ❌ URL query / Cookie 裸传,轻量 replay 即可 |

| 3 | 请求签名 | ❌ 无 sign,参数可任意构造 |

| 4 | SSL Pinning / 代理检测 | ❌ 无 |

| 5 | 加壳/虚拟化 | ❌ 无壳,dex 直接可读 |

| 6 | 设备指纹 + 行为风控 | ⚠️ 有数美 SDK,但接口层无风控拦截 |

| 7 | 服务端权威 | ⚠️ 数据绑账号,但学习进度“上报即信” |

如果要对标金融级客户端,成本收益排序应该是:服务端权威 > 请求签名 > 设备 attestation > 行为风控 > Pinning > 加壳。壳是性价比最低的一层——它抬高的是“读代码”的成本,而协议在双方内存里必须是明文的,接口与协议的暴露无法靠壳解决。真正难处理的从来是“合法客户端的凭证被复用”这个根问题,业界目前也没有银弹,只有不断地抬高成本(风控/attestation)和及时止损(熔断/封禁)两条路。

12. 总结 {#section-12}#

这次实践的完整链路:

apk 样本 → apktool/jadx 静态侦察(1 小时)
命中 thrift 生成代码 → 还原 IDL + URL 模板 + 鉴权模型(1 小时)
手写 TCompactProtocol 编解码(半天,含 list header 排错)
空跑验证协议(一条命令)
小号短信登录拿 token
只读打通:词书 / roadmap / 已学 / 词详情 / CDN(半天)
写操作验证:update_done_data 增量语义实锤(关键实验)
工程化:SWR 缓存 / 并行 / 预热 / 断点续传(半天)

几点可迁移的经验:

  1. 先判断协议类型再决定工具。有代码生成机制的协议(thrift/protobuf)里,客户端就是接口字典;Flutter/自研二进制协议才需要重工具链,判断错方向会白烧几倍时间。

  2. 空跑验证协议,把“协议对不对”和“凭证有没有”解耦,这是调试私密协议最快的路径。

  3. 写操作先做最小副作用实验:取远端已有数据原样回传,观察服务端语义(累加 or 覆盖),比读十遍文档可靠。

  4. 一切以样本快照为基线(版本+SHA256),分析结论要归档,App 更新后能 diff。

  5. 研究边界要在动手前画好:本人账号、只读优先、不碰支付权益、不分发。协议研究需要可复核的证据,也需要对每一次写操作保持克制。


本文仅用于协议学习与技术研究,所有操作基于本人账号的小号环境,不涉及任何支付/会员/越权行为,未对任何服务造成滥用。复现风险自负。

手写 TCompactProtocol:一次移动端私有 RPC 协议的完整逆向实践
https://blog.ouneed.tech/posts/tcompactprotocol-mobile-rpc-reverse-engineering/
作者
winter
發佈於
2026-09-23
許可協議
CC BY-NC-SA 4.0