关键词:Android 逆向 / Thrift Compact Protocol / 协议还原 / 自动化客户端 / 安全研究
本文记录一次完整的、以学习为目的的 App 私有接口分析过程:从拿到 APK 开始,到最后用自己的 Python 客户端稳定调用目标服务并完成数据同步。目标对象是一款头部的语言学习类 App(下称 V-App),所有敏感信息(域名、包名、产品名、账号数据)均已做脱敏处理,文中出现的域名均为虚构。
合规声明:本文仅为协议学习与技术研究。分析只使用本人小号、只读接口优先、不触碰任何支付/会员权益逻辑、未进行任何形式的数据分发。复现前请确认目标软件的服务条款,后果自负。
1. 背景与目标
V-App 是一款装机量很大的语言学习类应用,核心功能是“背单词”。它的内容体系(词书、配图、音频、学习进度)都在服务端,客户端每次学习都要和服务端交互。
动机:我希望在电脑上也能背单词,并且和手机上的学习进度互通(电脑背的记录手机能看到,反之亦然)。官方没有提供 Web 端,那么理论上只要搞清楚它的接口协议,就可以自己写一个客户端。
目标拆解:
- 拿到学习数据:我正在学的词书、每个词的内容(释义/音标/音频/配图)
- 拿到学习进度:手机端已经学过哪些词、学到什么程度
- 上报学习进度:在电脑上背完的词,能同步回手机端
红线(先给自己画好):
- 只用本人的小号
- 只读接口优先,写操作必须经过充分验证
- 绝不触碰支付、会员、越权等任何权益相关逻辑
- 不做分发,纯自用
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 上的两个编码坑先记下来(本文所有涉及中文内容的脚本都因此调整过,这类细节决定自动化脚本的稳定性):
.cmd批处理按 ANSI(GBK) 解析,交付用的启动脚本只写 ASCII- 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 内部类、StandardScheme、writeFieldBegin(new TField(...)))。也就是说:
服务端接口用的是 Thrift over HTTP,而且客户端里带着完整的接口契约。
这是本次分析最大的成本变量:Thrift 生成代码是“自描述”的——每个方法名、每个结构体的 field id 和类型都以字面量形式存在。不需要抓一个包,协议就还原了 90%。
4.3 还原传输层
三个关键类的信息拼出完整传输模型:
- URL 模板(
thrift/a.java):"%s%s/%s/%d"→
POST https://learn.example.com/rpc/user_study/{method}/{当前毫秒时间戳} - 消息格式(
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 里带凭证 - 凭证载体(另一处请求封装实锤):
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 的核心规则(对照实现看最直观):
- varint:每个字节 7 bit 有效位,MSB=1 表示后续还有字节
- zigzag:有符号整数先 zigzag 再 varint。
zigzag32(n) = (n<<1) ^ (n>>31) - struct:字段序列,每个字段头 1 字节
(delta<<4)|type,delta =fid - last_fid;delta ≤ 0 或 > 15 时写成type字节 + zigzag varint 的 fid;struct 以0x00(STOP) 结束 - bool:在 struct 内直接编码进 type nibble(
1=true,2=false),不占 value 字节 - list:1 字节
(size<<4)|elem_type;size ≥ 15 时用0xF0|elem_type+ varint size - double:小端 8 字节
- 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= successfield 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 错位,再对照协议规范重写。这类“协议魔数错位”问题的通用排查法:
- 把响应原始 hex dump 出来
- 和预期字段逐个字节比对
- 找到第一处语义不合法的字节(本例中是 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]
这一行信息量巨大,三段全部验证通过:
- framed 传输对(能解出 struct)
- compact 编解码对(能读出 code 和 message)
- 服务端路由认识我(
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 握手反复重建,纯链路成本。
解法一套组合拳:
-
服务端内存缓存 + 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. 防守视角:这套体系该怎么加固
站在防御方角度复盘,这套体系为什么“一捅就破”,以及业界标准的加固阶梯(这也是我在实战中同步总结的):
| 层级 | 手段 | 本次现状 |
|---|---|---|
| 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 缓存 / 并行 / 预热 / 断点续传(半天)
几点可迁移的经验:
- 先判断协议类型再决定工具。有代码生成机制的协议(thrift/protobuf)里,客户端就是接口字典;Flutter/自研二进制协议才需要重工具链,判断错方向会白烧几倍时间。
- 空跑验证协议,把“协议对不对”和“凭证有没有”解耦,这是调试私密协议最快的路径。
- 写操作先做最小副作用实验:取远端已有数据原样回传,观察服务端语义(累加 or 覆盖),比读十遍文档可靠。
- 一切以样本快照为基线(版本+SHA256),分析结论要归档,App 更新后能 diff。
- 研究边界要在动手前画好:本人账号、只读优先、不碰支付权益、不分发。协议研究需要可复核的证据,也需要对每一次写操作保持克制。
本文仅用于协议学习与技术研究,所有操作基于本人账号的小号环境,不涉及任何支付/会员/越权行为,未对任何服务造成滥用。复现风险自负。