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

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

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

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


1. 背景与目标

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

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

目标拆解

  1. 拿到学习数据:我正在学的词书、每个词的内容(释义/音标/音频/配图)
  2. 拿到学习进度:手机端已经学过哪些词、学到什么程度
  3. 上报学习进度:在电脑上背完的词,能同步回手机端

红线(先给自己画好):

  • 只用本人的小号
  • 只读接口优先,写操作必须经过充分验证
  • 绝不触碰支付、会员、越权等任何权益相关逻辑
  • 不做分发,纯自用

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. 第一阶段:静态侦察

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 协议

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 内部类、StandardSchemewriteFieldBegin(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

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. list1 字节 (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. 第四阶段:空凭证验证法

协议栈写完,还没有 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. 第五阶段:凭证获取

接口清单里有一个统一用户服务(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. 第六阶段:只读能力扩展

有了 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. 第七阶段:写操作与同步语义

写操作只有真正的一个: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. 第八阶段:工程化

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

10.1 慢:链路本身 3 秒/次

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

解法一套组合拳:

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

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

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

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

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

  4. 后台预热:服务启动/登录后,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. 防守视角:这套体系该怎么加固

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

层级 手段 本次现状
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. 总结

这次实践的完整链路:

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

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

下一篇
} });