我花两个通宵,把 Soul App 的协议逆成了纯 Python
这篇文章记录我最近做的一次完整逆向:把 Soul(Android 6.37.0)的网络协议,从抓包一路打到不依赖手机、不依赖 so 的纯 Python 客户端。
我尽量把每一步"当时是怎么想的、为什么这么干、踩了什么坑、怎么从错误判断里爬出来"都写出来——网上讲"结果"的文章很多,讲"过程"的太少,而逆向最有价值的恰恰是过程。
先说清楚:全程只用我自己的账号、自己的 root 设备(Pixel 6,AOSP on Oriole),授权研究环境。本文仅作技术交流,请勿用于任何违规用途。
目录
- 为什么选这个硬骨头
- 第一步:把入口找出来
- App 根本不让注入
- 抓包这件事也能踩三个大坑
- “so 的代码在磁盘上根本不存在” —— 我被打脸的时刻
- 第一场胜利:IM 帧解密
- cs 签名:一场五幕的攻坚战
- IM 消息签名,和一次教科书级的 dex 注入
- 服务端给我上的一课
- 收尾:剩下的全是口径
- 复盘:这次我学到了什么
0. 为什么选这个硬骨头
我给自己定的目标很明确:协议完全复现,客户端零设备依赖。不是"能发请求就行",是签名字段也要纯 Python 算出来——因为只要签名还依赖跑在手机上的 so,这套东西就永远受制于那台设备。
动手之前我扫了一遍这个 App 的防护面,当时心里就有数了:这不是常规难度的活儿。
| 环节 | 防护 | 对我意味着什么 |
|---|---|---|
| 抓包 | TLS + 私有 HTTPDNS 域(公网根本不解析) | 常规代理直接"服务器连接失败" |
| 抓包 | 主进程是 32 位 | 我最想用的 eCapture(eBPF,无注入)直接出局 |
| 注入 | 反 Frida 双层检测 | attach 即死 |
| 静态分析 | JNI 全部 RegisterNatives 动态注册,无 Java_* 导出 | 符号表里搜不到入口 |
| 静态分析 | so 核心代码磁盘上是密文 | IDA 打开就是噪声 |
| 算法还原 | 常量静态加密、GOT 静止态是密文 | 反汇编读到的"常数"是假的 |
| 复现验证 | 服务端强校验,差一字节就拒 | 不能"差不多对" |
但有一件事当时鼓舞了我:这类防护的本质是提高你的时间成本,不是数学上不可解。只要我能看到代码、拿到输入输出对,就没有逆不出来的道理。事实证明这个判断是对的——只是代价是两个通宵。
最后交付的东西:一个 40 行的核心签名算法(cs_offline.py,标准库零依赖),加一套完整客户端 soul_client/——短信登录、token 轮换、广场全家桶(浏览/点赞/评论/关注/发帖含图片)、IM 收发,全部服务端 10001 实测通过。
而算法本体最后被证明是:标准 MD5 + 两个字符置换 + 两个明文常量。这个讽刺的结局我在文末再展开。
1. 第一步:把入口找出来
我的习惯是逆向永远从最朴素的问题开始:加密的入口在哪? native 层的入口不管怎么藏,System.loadLibrary 这一步是绕不开的。于是 jadx-gui 打开 APK(挂了 MCP server 方便脚本化查询),直接搜 loadLibrary。
命中两个类:ImEncryptUtils 和 SoulPowerful。后者一看就是所有签名的 JNI 门面——名字都很诚实。
然后就是体力活:对每个 native 方法做交叉引用,一层层往上爬调用链:
loadLibrary
→ ImEncryptUtils / SoulPowerful(JNI 门面)
→ sy.h / sy.i(Retrofit 构建)
→ sy.q(SoulNet,OkHttp 组装)
→ ty.f(ParamsInterceptor)★ HTTP 签名落点
→ ColdStartupHelper.u(初始化实参)
→ m0(DomainsManager)
→ g60.a(IM 连接配置)
爬链的副产品很丰富:40+ 条域名按 4 套环境全量提取;IM 入口 im.soulapp.cn:8393,走 HTTPDNS 多 IP 轮询;IM SDK 门面 ChatManager,完整链路 UI → p0 → h60.h.o → h60.d.u → 写线程 → socket。
通读 ty.f 源码之后,我把 HTTP 签名方案写成了伪代码:
sb3 = url.path // 只有 path!不带 host
+ "?" + sortByKey(query ∪ urldecode(form))
.map(k -> k + "=" + decode(v)) // 值用解码值,"+" 还原空格
.join("&");
sb2 = ["tk","di","sdi","aid","av","at","User-Agent"]
.sort() // Java 的字符串排序
.map(取值) // 只拼值不拼头名
.join("");
header["cs"] = SoulPowerful.l(ctx, tsSec, sb3, sb2); // 主角
header["sthor"] = SoulPowerful.i(...); // v3,AB 开关
header["at"] = Hi.bth(ms, salt); // 在另一个 so 里
当时我特意把这些口径抄得很细——路径不带 host、值用解码值、恰好 7 个头、Java 排序规则。直觉告诉我这些"细节"后面都会变成服务端重算签名的雷区。后来证明这个直觉完全正确,第 9 节的坑表里一半的坑来自这里。
这里我踩了第一颗雷(虽然当时不知道):ty.f 其实是旧版死代码,真正在跑的拦截器在 cn.soulapp.baseutility.fingerprint 包下。幸运的是两者签名逻辑同构,没造成实际损失。但这给我立了条纪律:静态看到的类,必须动态印证它在不在跑。另外 aa.y.b 反编译直接失败,我评估了一下——ColdStartupHelper.u 已经覆盖同样信息——就果断放弃了。逆向要经常判断"这块的收益配不配死磕的成本"。
到这里,攻击面已经收敛得很干净:三个 native 签名函数 + 一把 DES key,全在 libsoulpower.so (32 位 ARM)里。该上真机了。
2. App 根本不让注入
装好 App,启动——零流量。
我第一反应是"坏了,被风控识别了"。如果你也遇到过类似场景,先别慌,我的排障顺序是:ss/netstats 确认确实没有 socket → uiautomator dump 看 UI 树——结果屏幕上停着两个弹窗(Android 15 兼容性提示 + 隐私政策二次确认),都等着点同意。UI 自动化点掉,443 流量立刻全量涌出。
先排除环境问题,再怀疑对抗。 如果当时我顺着"风控"去查,一晚上就没了。
四组对照,把反 Frida 揪出来
接下来上 frida-server。先摔了个低级跟头:版本必须与宿主严格匹配(最后用 fsarm64@17.2.12),不匹配的表现是静默超时,没有任何报错指向版本。
然后我设计了一个四组对照实验,专门回答一个问题——检测的触发条件到底是什么:
| 组 | frida-server | 注入 | 结果 |
|---|---|---|---|
| A | 无 | 无 | ✅ 存活,全量联网 |
| B | 默认端口 27042 | 无 | ❌ 启动 1 秒左右无声死亡 |
| C | 改端口 -l 127.0.0.1:55555 |
无 | ✅ 存活 |
| D | 55555 | attach | ❌ 主进程即死 |
B 组那个"无声死亡"值得单独说:没有 tombstone、没有 crash log、logcat 干干净净。能造成这种死法的只有一种东西——进程自己调了 exit() 。这不是崩溃,是 native 代码里的主动自杀。
C 组活了、D 组死了,说明有两层检测:第一层是端口探测(connect("127.0.0.1:27042") 成功就退出,换端口可绕);第二层在进程内部,attach 注入的痕迹会触发它——后来我在 so 里定位到了这个函数:EnvDetection.isHaveHookMoudle(),偏移 0x2DF855。
我做的最重要的一个路线决策
理论上我可以继续对抗:spawn 模式早期 patch 掉 isHaveHookMoudle,或者上内核态无痕 hook。但我决定放弃一切对 App 进程的注入。理由有三:
- 对抗升级的成本不可控,而且每 patch 一处,我看到的运行时状态就被我污染一分;
- 我要的是复现协议,不是打赢防护——只要有办法拿到代码和输入输出对,注入不是必需品;
- 我手里有零注入的替代手段。
现在回头看,这是我整个项目做的最重要的决定。最终我一次都没注入过 App 进程,照样全逆完了。
不注入,那怎么观察 native 层?
我的设备上跑着 APatch(root 方案)+ 一个 KernelPatch Module,能在内核层面挂钩 ArtMethod::RegisterNative。这本來只是"顺手开着"的日志,结果成了后面整个 native 阶段的地图:
SoulPowerful.k(Context,int,String,String) off = 0x2D5349
SoulPowerful.h(Context,int,String,String) off = 0x2D629D
Hi.bth(long,String) off = 0x2D5DA9
ImEncryptUtils.genImSign off = 0x2DCEBD
ImEncryptUtils.getUserIdKey off = 0x2DD71D
EnvDetection.isHaveHookMoudle off = 0x2DF855 ← 就是它
... 共 53 个函数
两个决定性情报:第一,全部 JNI 动态注册,so 没有 Java_ 导出*——怪不得符号表搜不到,这份偏移表是唯一的地图;第二,so 是 NDK r23b 常规构建、没有壳,IDA 可以直接分析。
(第二句话里埋着一个大雷,几小时后就会炸到我脸上。另外收这份表本身也有坑:KPM 日志量一大 logcat 就轮转丢日志,必须 logcat -f 流式落盘才能收齐 53 条。)
我还是按习惯把回退方案写进了记录:如果后续真的需要进程内观察,就走 frida spawn + 早期 patch isHaveHookMoudle(0x2DF855 → mov r0,#0; bx lr)。方案设计阶段就写好回退,比卡死之后再想要强太多。
3. 抓包这件事也能踩三个大坑
静态分析已经把目标钉在签名函数上了,下一步我需要真值:真实的请求头、参数、签名值、响应。我后面所有的分析都要拿真值当裁判,没有真值的逆向就是猜谜。
首选方案直接阵亡
我最想用的是 eCapture——eBPF 抓 TLS 明文,不注入进程,天然无痕,完美契合我的零注入路线。跑起来直接失败:它只支持 64 位目标(EM_AARCH64),而 Soul 主进程是 32 位的。留作以后换 64 位包的备选,转 mitmproxy。
(连环境都不省心:宿主机 Python 全局环境早就坏了,我老老实实建了项目 .venv 装的 mitmproxy 12.2.3,清华镜像。这个 venv 后来又崩过一次——往里装 androguard/scapy 把 pyopenssl 搞崩了,靠降级 cryptography<43 修复。分析工具永远进隔离环境,这是我用一晚上换来的条件反射。)
大坑一号:挂了 CA,App 看不见
Android 14/15 的 CA 信任区在 APEX 分区:/apex/com.android.conscrypt/cacerts。思路很直接,把 mitmproxy 的 CA bind-mount 进去。我挂到了全局 mount namespace,确认目录里 146 张证书齐了,App 走代理重启——TLS 握手全部失败。
排查了半天我才反应过来:zygote 有自己独立的 mount namespace。App 是 zygote fork 出来的,继承的是 zygote 的挂载视图。我挂到全局 ns,zygote 和它的孩子们根本看不见。这个隔离设计本来是安全特性,在这儿成了我的第一道墙。
大坑二号:我把设备干重启了
既然单个 ns 看不见,那就"给所有进程的 ns 都注入一遍"——我遍历 /proc/*/ns/mnt,逐个 nsenter 进去 bind-mount。
结果设备直接重启。重启后所有 runtime 挂载清零。
这个教训我记到现在:永远不要对系统做"遍历式"的 namespace 操作。每多动一个进程,系统就多不稳一分。只做外科手术。
正解其实简单得让人恼火
想明白了就很短:App 都是 zygote fork 的,只需要对 zygote 和 zygote64 两个 PID 各做一次 nsenter bind-mount。App 重启,链路立刻全通。(后来干脆把这套注入脚本化成 post-boot 步骤,因为设备一重启挂载就没了。)
真值到手,还顺手破译了几个格式
一晚收割 90 条明文请求、71 个唯一端点、3.3MB flows。对着真值,好几个静态阶段没看懂的字段瞬间明了:
tk: 游客 token,可经 /v3/account/token 刷新
di: 设备 ID(如 arY1dRoYTpoDAMI8YNoka0LF)
sdi: base64("AOSP on " + ...) + md5 —— 看着唬人,格式就这么直白
at: 毫秒时间戳的 hex
bi: URL 编码的 JSON 数组(机型/系统/分辨率/网络...) bik=32755
UA: ...SoulBegin-Android-6.37.0-{wifi}-SoulEnd ← 注意这个变量段,后面有故事
一个让我直接少干一半活的发现
我把 90 条请求的头全部过了一遍,发现一件事:生产流量只带 cs ,从没出现过 sthor。
sthor 是 v3 签名,受服务端 AB 开关控制——生产根本没启用。也就是说我只需要攻 cs = SoulPowerful.l(ctx, tsSec, sb3, sb2) 这一个函数。
紧接着我做了四组重放实验,给服务端对 cs 的态度定了性:
| 请求 | 服务端响应 |
|---|---|
| 去掉 cs 头 | 403 / 9000003"提交数据信息不全" |
| 伪造随机 cs | 200 / 9000006"服务器有些小异常" |
| 原样重放真值 cs | 200 / 10001 成功(此端点不查严格时间窗) |
| 用 sthor 替代 cs | 403 |
结论:cs 必填、强校验、不可替代。绕不过去,只能正确生成。 好,那就正面硬刚——这就是后面那场长跑的由来。
4. "so 的代码在磁盘上根本不存在" —— 我被打脸的时刻
带着 KPM 给的偏移表,我信心满满地打开 IDA(无头模式,20MB 数据库),跳到函数偏移——NO FUNC。
我往下拉滚动条,然后愣住了:目标偏移比 so 文件本身还大。文件只有 0x20BE4C 字节,函数在 0x2D0000+。一个"2.9MB 偏移的函数"怎么可能存在于"2.1MB 的文件"里?
readelf -l 一句话解答:
LOAD offset 0x1000 FileSiz 0x1F1194 ← 磁盘上实际有的数据
vaddr 0x1000 MemSiz 0x445190 ← 加载进内存后承诺的大小
MemSiz 比 FileSiz 多出 2.3MB。 多出来的部分在内存里是匿名 RX 段——磁盘上没有,运行时才有。配合后续实验,so 的真实布局是:
├─ 磁盘明文区 [0, 0x1F2000) → OpenSSL 等公共函数,IDA 能读
└─ anon RX 区 [0x1F2000, ...) → Soul 专属 JNI 函数
├─ 静止时:高熵密文
├─ 首次调用时:解密成代码
└─ 部分函数:执行完重新加密("用后即焚")
上一节"无壳、IDA 可直接分析"的判断,在这里被狠狠打脸——技术上确实无壳,但核心代码在磁盘上根本不存在。静态分析在这里撞的是结构性的墙。
机制摸清了,dump 策略就变成了"时机学"
既然函数"调用时才解密",那 dump 前提就是:先让业务触发它。我做了个干净的验证:genImSign/getUserIdKey 只在 IM 登录时被调用——IM 未登录时 dump 那一页,高熵密文;走完 IM 登录再 dump 同一地址,标准 Thumb 代码。机制 100% 坐实。
于是 dump 计划按业务触发分类:cs 生成和 Hi.bth,随便一个 HTTP 请求就够;genImSign 一族,得先登录 IM。
技术上用 root 的 dd /proc/<pid>/mem 直接读——零注入,进程毫无感知。有个插曲:file-backed 的 RX 段读出来全是 EIO。我没深究(估计是内核保护),评估了一下:那部分磁盘 ELF 本来就是明文,直接用文件就行——非阻塞,跳过。判断"这个问题值不值得现在解"也是基本功。
匿名段 [base+0x1F2000, base+0x3EB000) 一次 dump 了 1.8MB。
IDA patch 卡死,我干脆自己造了个 ELF
数据到手,先试正路:把 dump patch 回 IDA 数据库。无头模式跑了 40 分钟零产出,弃。
我换了个思路:不 patch 了,直接造一个新 ELF——磁盘 so 的明文头部 + dump 的解密数据,三层拼装。然后 Capstone 反汇编。
第一版造出来,反汇编"成功"——全是乱码。这个 bug 我排了很久,因为它不报错,只是安静地错。根因最后找到了,低级得想笑:program header 里 p_offset=0x1000(我照抄了原 so),但自制文件里数据实际在 0x54——所有地址整体错位 0xFAC。改对 p_offset,代码全部归位。
紧接着又是一个坑:某些页反汇编出来"有模有样",但交叉验证怎么都对不上。我盯着看了半天才明白——那几页 dump 的时候还没解密,所谓"像代码"只是密文碰巧反汇编出了一堆合法指令。
从那天起我有了条铁律:判断一段字节是否已解密,唯一标准是 Thumb 函数序言(push {..., lr} 开头 + 设栈帧)。重新 dd 了当前进程 16 页,序言验证通过,十大核心函数(h_cs / k_sthor / bth_at / genImSign / getUserIdKey / encryptByDes 等)全部到手,导出 2013 行反汇编。
初步通读就有收获:getUserIdKey 的结构是 fopen → fgets 循环 → 3 次 strstr/strcmp 匹配 → 按匹配行派生 key——典型的读 /proc 特征派生密钥。这直接指明了下一战的方向。
5. 第一场胜利:IM 帧解密
帧格式:我对了第二版才对
tcpdump 抓 8393 九十秒(5KB,基本是心跳),对照 jadx 里 h60.d 的读循环和 q60 的写格式,帧格式初步成型。这里要诚实记录一次修正:我第一版把业务帧头算成 8 字节,实际是 7 字节——差的那 1 字节让长度字段全部错位、解密全灭。
magic = 0x0D
心跳 : 0D 00 2B,不加密,5 秒一次
Sauth: 0D 01 <int32 len> 02 <int16 ver=1> 01 <pb> 10B 头
业务 : 0D 02 <int32 len> 01 <DES-ECB(pb)> 7B 头(encFlag=1)
我的教训:帧头长度这种东西,用"解密能不能成功"来反推,比只看反汇编可靠。
顺手让反汇编"长出名字"
原始反汇编全是裸地址。我用 nm -D 导出 2019 个动态符号,把所有 bl/blx 目标重映射成符号名——公共函数瞬间可读,getUserIdKey 里那几个 sub_2da790 也确认为 strstr/strcmp。
追一根指针链,追出一个常量
DES key 在 getUserIdKey 里。我顺着反汇编一层层追:
JNI 入口:r0 = 1 + ctx(探测参数)
↓ 读 .bss 指针 0x761c
↓ 解引用 → 0x87d666f8
↓ 再解引用 → 0x87d5f8a5
↓ 落点 = 磁盘 so 偏移 @0x18a5(静态数据!)
→ "123!@#zaqXSWqwer"
→ Java 层 DESKeySpec 取前 8 字节 = "123!@#za"
最有意思的是最后一步:指针链在内存里绕了一大圈,终点却是磁盘 so 里一个写死的字符串。所谓"动态派生"只是绕路。这种"用复杂度伪装常量"的设计,后面在 cs 里还会反复出现。
拿这把 key 解密抓包里的 hello 消息帧:
DES-ECB("123!@#za", body) → protobuf 明文
msgId = 464867993, senderRoundCount = 0, followed = 0, sourceType = 7 ✅
字段全对上。IM 帧层完全解密——这是项目第一场完整的胜利,更重要的是它验证了我整条工具链(dump 时机学 → 自制 ELF → Capstone)是成立的。
(后来我在 strings 里又发现一把 key "123zaqws",揭开了双 key 体系:帧层 key 加密整包;内容层 key 加密路由字段——field20 = base64(DES(对方uid)),发送帧加密对方 uid、接收帧加密本方 uid,防止嗅探者直接读出"谁在和谁聊"。两把 key 都做到了与抓包逐字节一致。)
另外顺手验证了 at:Hi.bth 的反汇编看着唬人,但 388 个样本全部满足 at == Long.toHexString(currentTimeMillis())——没有混淆。但这一步不能省,不验完不敢在客户端里用。
6. cs 签名:一场五幕的攻坚战
接下来是整个项目最长的战役。我用伪造的 cs 打服务端,9000006——意料之中,但心里还是咯噔一下:正餐来了。
事后复盘,这场仗我打成了五幕:黑盒画像 → 仿真执行 → 进程内重构 → 逐字节溯源 → 符号介入。中间错了三次方向,每次都是真值把我捞回来的。
第一幕:先别啃汇编,把骨架画出来
动手读汇编之前,我先拿抓包样本做了轮纯黑盒的差分——这是我自己的习惯,先知道"它像什么",再去研究"它为什么" 。
三个差分实验:
- 时间敏感性:同秒请求,cs 前段相同;跨秒变化 → 前段含时间因子;
- 输入敏感性(174 组) :固定 at 只改 URL → 174 组 cs 全部不同 → cs = F(at, URL, headers),抄一个 cs 配别的请求在数学上就不可行;
- 雪崩性:输入改一个字符,F 段全变 → 哈希类原语。
画出来的骨架:
cs(18字节) = 02ab | ? | 6a | ? | c5 | ? | 46 | ? | T1 | 00 | T2 | F | 1188
└固定┘ └似乎固定┘ └时间┘ └哈希┘ └固定┘
这张骨架图后来救了我无数次——每破译一个字节就往骨架上归位、拿真值验一次,不至于在汇编海里迷路。
第二幕:在 PC 上仿真执行 so
不能注入 App,但代码 dump 我有——那我在我自己的进程里跑它。我用 Unicorn(QEMU 内核)搭了个仿真器:JNI 函数表打桩、PLT 槽指向我的桩、缺页自动从 dump 补、bx lr 通用桩兜底、全程记 PC 轨迹。
搭的过程两个大坑,都值得记录:
坑一:0x43xxxx 一大片"密文"。 仿真推进到这片区域全是异常,dump 出来全是高熵——我第一反应"又一片运行期解密区",差点又去等解密窗口。后来发现真相哭笑不得:那是 ARM 模式的 PLT 跳转桩(ldr pc,[ip,#off]!),被我按 Thumb 反汇编,当然全是乱码。"乱码先查模式错配"这条规矩,从此焊死在我脑子里。
坑二:这个 so 的符号不走 .got.plt。 破译 PLT 后发现 .got.plt 是空的——它用了自定义 dlsym loader(PLTRELSZ=0),符号解析直接走 .got。我手工解析了 204 个 GOT 槽,发现分属两个库(App 自带 libc++_shared + 系统 bionic),逐一映射回符号名。一个漂亮的实证:给 strlen 打桩后,函数里两处调用返回的恰好是输入串长——符号映射对了。
这一幕还有个我挺得意的实验:有类函数"用后即焚"(静止密文、执行瞬间解密、返回后重新加密)。轮询 dump 物理上抓不到吗?我在业务高频调用期间以每秒几百次的频率 dd 那一页——300 次采样里命中了一次,抓到了 0x2D2F78 解密瞬间的完整代码(f0b5 push 序言开头,留证 s_hit.bin)。瞬态解密在统计上是可采的,这个结论后面还用上了。
但仿真从 k 推进到第四层(k → 0x2d46xx → 0x2d2f78 → 0x2d30b8 的 blx r4 间接调用)后,每遇一个新外部调用就要人肉补桩,成本指数上升。我判断继续硬啃性价比不行了,换路线。
第三幕:native runner——让它自己跑
思路一转:手机本身就是完美的 ARM 执行环境,为什么不直接在上面跑 so?
我写了个独立 Java 程序,app_process32 启动(32 位是硬约束,so 是 32 位的),里面放一组同名 stub 类——SoulPowerful、Hi、SoulCrypt、EnvDetection、ImEncryptUtils、AESUtil、DESUtil 等共 11 个。然后 System.load 加载真实的 libsoulpower.so。
妙就妙在:so 的 JNI_OnLoad 会执行 RegisterNatives,把 native 函数自动绑到我的 stub 类上——so 以为自己在给 Soul App 服务,实际在给我的命令行程序打工。
工具链本身也折腾了一阵:Windows 上 javac 1.8 编译 → d8 转 dex(d8 不在 JDK 里,从 Android Studio 的 JBR21 里借)→ 部署到 /data/local/tmp/。排障三轮:ClassNotFoundException(so 反射找类,逐个补)→ NoSuchMethodError getSoulPowerfulString(so 回调 Java 拿设备信息,补 MODEL|version|di)→ 成功执行。
然后就是这场战役里最挫败的一段。我把 SoulPowerful 全部 18 个导出函数逐一喂真实输入:
k(ctx,...) → "thv3_..." 前缀(v3 格式,不是 cs)
f(bytes,ts)→ 36hex 但 04 前缀
h(ctx,...) → 空串
c(String) → "error"
18 个导出,没有一个输出 02ab 前缀的 36hex。 我当时甚至写下了"cs 生成器不在 libsoulpower 的 JNI 接口里"的结论,开始怀疑 cftech.okhttp3 内嵌或者匿名注册。
中间还有个乌龙雪上加霜:我一开始把真值 cs 数成了 38hex(19 字节),f 输出是 36hex(18 字节)——"长度差一个字节!"我顺着这个错误假设设计了整整一轮 f 的输入矩阵实验(fullUrl、path+query、预置版本前缀……),全 miss。直到回头重数:36hex,18 字节,跟 f 一样长。前缀不同是内容问题,不是长度问题。
那一晚我给自己记了一条规矩:最底层的观察(长度、位数)错了,后面所有推理都会错得很有道理。
第四幕:一行反汇编,纠正了我全局的误判
穷举陷入僵局后,我回到 h() 本身。它返回空串,我此前的归因是"ctx=null 导致 so 全局初始化缺失"(so 要读设备状态派生密钥),二进制 patch 方案都拟好了:把那段"Context 初始化"NOP 掉。
动 patch 前我强迫自己做了一次反汇编复查,这次复查救了整场仗:
0x2d5853: bl sub_2de424 ← 这根本不是 Context 初始化!
它在回扫内存(0x400 步进找 \x7fELF 魔数)定位 so 自身基址,
返回 base ^ 0xda834500 —— ELF 自定位。
按原计划 NOP 掉,整个 so 直接崩。
0x2d586d: cmp r0, #0 ← ctx 在整个 h() 里只被读这一次(判空)!
0x2d5877: strb #2 ← cs 版本字节 02 —— 02ab 的 02 在这儿!
ctx 全程只判空、从不解引用。 指纹是走 Java 回调 getSoulPowerfulString() 拿的,根本不经过 ctx。也就是说"需要真实 Context"是彻头彻尾的表象——只要"是"Context 且非空就行。
答案简单到我想笑:
Context fakeCtx = new ContextWrapper(null);
// 它本身是 Context 的实例 ✓ base 为 null 无所谓 ✓
// h() 又不会调用它的任何方法 ✓
(在此之前我还试过 (Context)(Object)byte[] 这种邪道强转,在调用点 checkcast 直接失败,弃。)
fake ctx 喂进去,配着从抓包精确重建的输入,第一次完整跑通 h() 生成链。跟真值一比:
真值: 02 ab 316a17b10866516b 77 37511bcd5a 7788
生成: 02 aa 316a17b10866516b 00 96cd1c97c3 0089
└──── 8B 时间因子逐位一致 ────┘
盯着这行输出我愣了几秒——bytes[2:10] 与真值完全一致。这意味着密钥链、指纹链(sdi/di/设备指纹回调)全部正确,h() 已经在我的 runner 里完整复刻。剩下 10 个字节的差异,每一个都有了解释的方向。
这个阶段还有个不能省的验证:同进程同输入重复调 h(),输出完全一致——内部没有随机数。这一条排掉了 RNG 和时间抖动一整类假设,给下一幕的逐字节溯源铺平了路。
第五幕之前的四重奏:逐字节溯源
差异字节一个个来。
b1/b17:一对计数器。 b1 来自 native 全局(*(u32*)0x45b468 指向对象首 u32),runner 里恒 0xaa,App 里是 0xab——进程级计数器。runner 直接 /proc/self/mem 把它 poke 成 0xab。b17 = 0x133 - b1(0xab→0x88,与真值吻合),朴素的校验和,随 b1 联动。
b16:埋点状态字节——"1188/7788 尾巴"之谜告破。 b16 来自 Java 静态字段 InfoGather.aa,我回 jadx 查了语义:埋点启动时 aa |= 0x11、采集完成时 aa |= 0x77。也就是说我盯了很久的"固定尾 1188/7788"根本不是签名算法的一部分,是埋点 SDK 的状态汇报:1188 = 启动未完成,7788 = 采集完成。App 抓包时处于完成态。stub 类里写死 public static byte aa = 0x77,尾巴立刻对齐。
(发现过程也有波折:h() 里有 FindClass(InfoGather) + GetStaticByteField 的调用链,我一度以为 FindClass 失败会导致 cs 异常——实验证明失败不致命,cs 照常出,只是 b16 变默认值。)
b10-11 进程盐:服务端"不可能校验"的字段。 同进程恒定、跨进程变(观测 0x46/0x56/0x66/0x76/0x86——高 nibble 随机、低 nibble 恒 6)。我直接做了个逻辑判定:服务端不可能知道别的进程的随机盐,所以它不可能校验这两个字节——生成时固定 0x00,0x46。这个判断后来被服务端 10001 背书。
[sp,#8] 之谜:我最喜欢的一次翻案。 反汇编推断 cs[3,5,7,9] 来自栈槽 [sp,#8],而静态链里那是某构造函数的返回值——应该是栈地址。可这 4 个字节跨进程稳定。地址怎么会跨进程稳定?矛盾摆在这儿,我卡了一阵。
(更早还有一轮误判:我根据 GOT 静止态读数把两个相关 PLT 桩判成 ios_base::init / streambuf ctor,把 [sp,#8] 解释成"流对象字节搅拌"——后来证明那两个 GOT 槽静止时读到的本来就是密文。)
破案靠的是多 ts 采样:用 runner 的 gen 模式对多个时间戳各生成一批:
| ts | cs[2,4,6,8] | cs[3,5,7,9] |
|---|---|---|
| ...456~...470 | 31 17 08 51 恒定 |
6a b1 66 xx 随 ts 线性变 |
| ...500 / 1791000000 | 31 17 08 51 |
6a b1 66 cb / 0a cd 76 0c |
结论整个反转:cs[3,5,7,9] 才是时间因子,cs[2,4,6,8] 是随 sb2 变的哈希段。机制随后水落石出:
x = strtoll( perm( sprintf("%08x", ts) ), NULL, 16 )
└─ perm = [3,1,6,5,4,0,7,2]
源头:静态数字表 T1 = "42765183"(ASCII 数字串,经 -0x31 变换得索引)
cs[3,5,7,9] = rev(x) 四字节交错写入
不是地址,是时间戳 hex 的字符置换。那两个 PLT 桩也随之翻案:真身是 sprintf 和 strtoll。10 个采样点全验证通过。
这幕里还有两个底层坑,各值一次重跑,我一并记下:
- Thumb 的
add Rd, PC :PC 按字对齐(清 bit0) ——所有 pc 相对寻址字面量差 1 的原因。这坑我在 hex 查表地址和盐槽地址上踩了两次,最后立规矩:pc-rel 计算以运行期实测为准,不信静态推算; - bzero 两参签名:仿真时当 memset 三参喂,一个 hex 串 strlen 越界读了 749 字节——不报错,只表现为后续哈希全错。桩签名错误的排查成本极高。
(这幕还有个基建副产物值得一提:app_process 环境没有 hidden-API 限制,Method.artMethod 字段直接可读——ArtMethod 结构 24 字节,+16 处的 data_ 就是 RegisterNatives 注册的 native 指针。由此我能从任意 stub 方法反射拿到真实 native 入口、反推 so 映像基址,全局变量想定位哪个定位哪个。)
第五幕:LD_PRELOAD——整场仗的转折点
算法框架全就位了,只剩两个拼接盐:secretA、secretB。它们在 so 里是静态密文,只在 string::append 被调用的瞬间以明文出现在参数里,然后立刻被用掉。
为抓这个窗口我把能试的都试了:
- 大输入拖慢 h() 头部(字符串转换/哈希阶段),轮询线程死盯尾部——窗口只有微秒级,物理上抓不到;
- 轮询探测进程的 fd 还会被 so 主动 close(反调试),我改成"每次读都重开 fd"扛住了——扛住了反调试,窗口还是抓不到;
- 写好了 h() 中段 5 断点的 Frida 观测脚本,又卡在"app_process32 需要 arm32 版 frida-server",注入超时。
死磕到这个程度,我把视角掉了个头——这次掉头是整个项目我最满意的一个瞬间:
既然明文必然流过 append 的函数参数,那我根本不需要"看到"so 内部的 append。我让 so 调用的是我的 append。
LD_PRELOAD 符号介入,四步:
- NDK armv7a 编一个小 so(libpreload.so);
- 导出与 libc++ 完全同名的
std::string::append符号; - runner 运行时挂
LD_PRELOAD——linker 的符号介入规则会把 so 内未绑定的 append 解析到我的实现; - 我的 append 打印
(this, n, data) 后转发真实逻辑,cs 的输出不受任何影响。
一轮运行,三个明文一次到手:
APPEND this=sb2串 sz=368 + n=12: "SoulPowerful" ← secretA!
APPEND this=sb3串 sz=148 + n=8 : "a6b6b616" ← 中间量
APPEND this=sb3串 sz=156 + n=7 : "kG@yGB9" ← secretB!
顺带还发现:静态反汇编里 append 调用点的 movs r2,#0x10(长度 16)是假象,运行时 r2 被桩改写成了 strlen。又一条"静态常数不可信"的证据。
连锁翻案:MD5x 根本就是标准 MD5。 此前仿真一直有个对不上的环节:cs 的哈希段用标准 MD5 算不出来,so 内 0x2da8c5 的常数是"EFCDAB89 族"(MD5 的 IV 只对上一部分),我一度下了"自定义 MD5 变体"的结论。用 preload 直调 so 内部那个哈希函数 + dump 它的运行态查找表:
soMD5(380字节输入) = 31170851... ← 与真值一致 ✓
K 表 @0x2ba3a0:静态密文 → 运行态 dump → 标准 sin 表
S 表 @0x2ba4a0:静态密文 → 运行态 dump → 标准移位量
就是完全标准的 MD5。 "变体"从头到尾是密文查找表的假象——我的仿真器喂的是密文表,当然怎么算都不对。
最后剩一个未知量:hex(rev(x2)) 与 x 串之间还有一重字符置换(源头是段 NEON 变换,静态读不动)。我懒得死磕,直接用数学:13 个真值样本做约束求解,零冲突解出 perm2 = [1,4,7,6,2,5,3,0]。
幕落:40 行公式,和服务器的裁决
所有谜底归位,算法浓缩成:
from hashlib import md5
T1 = "42765183"
PERM1 = [3, 1, 6, 5, 4, 0, 7, 2]
PERM2 = [1, 4, 7, 6, 2, 5, 3, 0]
SEC_A = b'SoulPowerful'
SEC_B = b'kG@yGB9'
def gen_cs(ts_sec, sb3, sb2, counter=0xab, salt=b'\x00\x46'):
W = format(ts_sec & 0xFFFFFFFF, '08x')
xs = ''.join(W[i] for i in PERM1)
hex2 = ''.join(xs[j] for j in PERM2)
m1 = md5(sb2 + SEC_A).digest()
m2 = md5(sb3 + hex2.encode() + SEC_B).digest()
x = int(xs, 16)
b = [0x02, counter]
for i in range(4):
b.append(m1[i]); b.append((x >> (24 - 8*i)) & 0xFF)
b += [salt[0], salt[1]]
b += m2[0:4]
b += [0x77, (0x133 - counter) & 0xFF]
return ''.join(f'{c:02x}' for c in b)
三组时间戳离线生成,36/36 字符全对。然后是服务端终审,三模式:
| 模式 | cs 来源 | 服务端 |
|---|---|---|
| A 真值重放 | 抓包原值 | 9000006(预期内,原因见下) |
| B 真值 ts | 纯 Python 生成 | 10001 ✅ |
| C 全新时间 | 纯 Python 生成 | 10001 ✅ |
看到 B 和 C 返回 success:true 的那一刻,我知道这场长跑结束了。cs 闭环,签名字段从此零设备依赖。
(A 模式为什么失败,我后来收口时找到了确切原因,非常典型:真值 URL 的 bi 参数里 deviceModel 是去空格版 AOSPonOriole,我重放时用了 URL 编码原样值 AOSP+on+Oriole——一个空格的口径差,md5 出来就是两个世界:511bcd5a vs cd1c97c3。签名口径铁律就此确立:deviceModel 一律去空格,sb3 以实际发送的 URL 为准。)
7. IM 消息签名,和一次教科书级的 dex 注入
IM 帧 body 解密后我发现,CommandGroup 的 field19 还带一个 36 字符的 cs——和 HTTP 的同族,但签的不是 URL 是消息。要复现发送,得逆向它的输入口径。
环境比之前更恶劣
这一轮的阻力更大:Frida attach 64 位 Soul 进程直接触发 native 反调试自杀——更糟的是排障期间我一次 pkill 误伤,把 system_server 干重启了,整机图形会话重置。杀掉 frida-server 后 App 恢复。jadx 接口又连续超时两次。
Java 工具链全断,我换 Python 系的 androguard 做 smali 级反汇编——脚本化反而更顺手。全局搜"谁调用 SoulPowerful.l",命中三个调用方:HTTP 的 az/c、ty/f,还有个新面孔 n60/b.a(String,int,long) ——IM 的签名入口。
静态读出来 90%,剩下 10% 穷举到天黑
smali 一行行读下来:
String a(String cmdId, int size, long tsMs) {
b() = Build.MODEL.replaceAll(" ","").trim() // "AOSPonOriole"
c() = 包名.versionName // "6.37.0"
sb3 = "IM" + b() // ← 不含 cmdId!smali 细读才发现
sb2 = di + tk + cmdId + c() + size
return SoulPowerful.l(ctx, tsMs/1000, sb3, sb2);
}
// 调用者 v60/g.d: a(getCmdId(), getSerializedSize(), currentTimeMillis())
拿样本验证:x 段、m2、盐、结构全对——唯独 m1(sb2 的哈希段)差一元。
我开始了系统排除。先确认 getToken() 是 mmkv 里的明文 tk、getOldDeviceId() 是 utdid=di,然后穷举:
cmdId 候选 ∈ {traceId, msgId, "", null, toUid, content}
size ∈ {4, 6, 155, 173}
→ 全 miss
继续深挖:a70/a.a 是个 797 条指令的大方法(ImMessage→Packet 转换器,packed-switch 按 msgType 分发 v60 家族),我把静态推演的所有组合(cmdId 候选 × size 1-39 全扫 × tk 新旧 × di)全部跑完——全部 miss。日志这条路也是死的:n.e 日志走 insight 框架的加密 slog 格式,logcat 里根本没有明文。
我给了自己一个明确的判决:sb2 里有"运行期才知道形态"的值,静态分析已到极限。需要动态 hook。
我选了最"物理"的方案:直接改 dex
设备上有 LSPosed,但我评估后选了更直接的路线——把日志调用点改成我自己的:
① maven 拉齐 baksmali/smali 2.5.2 工具链
(dexlib2 + util + guava + jcommander + antlr-runtime 五个依赖)
② baksmali 反编译 classes17.dex
③ 在 n60/b.smali 里找到两处 utils/n;->e(String)V 日志调用
替换成我的注入类 n60/lh;->log(内部就一句 Log.e("SB2LOG", ...))
④ smali 回编 classes17_new.dex(15.5MB)
⑤ jar uf 注入 base.apk —— 关键细节是尾插(追加到 zip 末尾),
因为 ART 读 zip 的"最后一条"同名条目
⑥ root 覆写 /data/app 的 base.apk + 清 oat
两个此处的关键观察:签名校验和 fs-verity 都没有拦截(先备份了 base.apk.bak);注入版 App 正常启动、正常工作,SB2LOG 持续输出。
(后续教训:注入版 APK 后来触发了两次系统自动清除——"App 自己消失了"。这类修改必须即插即拔,采完样立刻还原原版,这也是我最后恢复纯净版的原因。)
logcat 跳出铁证的那一刻
SB2LOG: pathParam: IMAOSPonOriole
SB2LOG: headerParam: arY1...|DIBo...|179041404787194263|6.37.0|19
三个悬案一次收口,之前所有 miss 的原因全在这两行里:
- sb2 结构与静态逆向完全一致(di + tk + cmdId + 版本 + size)✓;
- tk 是 IM 进程启动时的快照(静态字段缓存),不是实时 mmkv 值——我此前拿"现在的 tk"去拟合,而 App 用的是"它启动时的 tk",tk 又在轮换,当然全 miss;
- size = CommandMessage 全字段完整序列化的长度(实证 19/154),不是我从帧里量出来的子消息长度——静态阶段读到的 4/6 字节是"正在输入"类小帧,口径完全不同。
带着修正后的口径重新采样(tcpdump 与 SB2LOG 抓同一帧,双通道比对):
重算 cs = 02ab977a4b0c6196487b77 53 15e1655a7788
真值 cs = 02ab977a4b0c6196487b77 43 15e1655a7788
↑ 唯一差异 = 进程盐(服务端不校验)
35/36,唯一差异是我自己决定忽略的那个字节。IM 消息级 cs 公式完全确立。
8. 服务端给我上的一课
按剧本,到这里 IM 发送应该水到渠成了。结果我撞上了一堵看不见的墙——这堵墙贡献了整个项目最深刻的一课。
现象是这样的:我用独立轮换出的 tk 连 IM——Sauth 成功、心跳存活、能收 ACK、甚至收到服务端主动推的离线同步帧(连接身份完全合法)——但发出的消息没有 echo、不投递,接收方 App 里根本不出现。
静默失败是最难调试的失败:没有任何错误码给你指路。
我按部就班排除:size 口径、KV 字段值、traceId 位数、空格处理……全部排除。然后开始怀疑更玄的东西:服务端对轮换链 tk 有发送权限限制?深层风控?
这时候我设计了一个一锤定音的实验——把 App 自己发过的、带原始合法 cs 的帧,原样重放。
结果:一样不投递。
这一行结果值千金。官方产物、官方签名、官方字节,照样死——说明我构造的帧没有任何问题,问题在别处。 十分钟结束了所有猜谜。
补几组场景把矩阵补全后,定性很清楚:
| 场景 | Sauth | 投递 |
|---|---|---|
| App 在线(任意 tk) | "其它设备登录"拒 | — |
| App force-stop + 等待 | 仍拒(断开粘滞) | — |
| 轮换 tk 独立连接 | 成功 + 离线推送 | 静默不投递 |
| 重放 App 合法帧(原 cs) | — | 不投递 |
| 纯净原版重装后 App 自身 | 正常 | 正常 |
IM 通道对"最后登录会话"有长期粘滞的独占记录,非当前会话的发送被服务端静默丢弃。回看时间线还有个佐证:此前某次我的 Sauth 意外成功,是因为 App 的 IM 恰好因 tk 换代断线——我撞进了通道真空窗口。
出路顺理成章:既然绑定跟着"最后登录"走,那就用纯 Python 走一遍正规登录,让服务端自己把绑定转移过来。实测:codeLogin 后新 tk 的 Sauth 不再被拒——App 反而被挤下线(绑定转移的实锤)。服务端主动推送可达、ACK 同构,通道双向全通。(echo 缺席也解释通了:真值里的 echo 是多端同步回显,单端登录场景本来就没有。)
这次我沉淀的方法论:用"重放官方产物"做对照,把"我方构造物的嫌疑"和"服务端策略的嫌疑"一刀切开。
9. 收尾:剩下的全是口径
cs 一通,我以为接下来是体力活。事实上它确实是体力活——但工作量证明了一件事:逆向的难点从来不只是算法,还有口径。下面这张坑表,每一条都是我拿服务端 9000006 换来的:
| 坑 | 症状 | 我是怎么定位与解决的 |
|---|---|---|
| 显式 Content-Type 头 | 9000006 | 头矩阵二分实验:requests 默认行为能过,手动设置反而被拒 |
data=dict 传 form |
9000006 | requests 会把 dict 重新 URL 编码,服务端按原始字节重算 cs → 失配。必须 data=字符串 |
| deviceModel 带空格 | m2 段全错 | AOSP on Oriole → AOSPonOriole。双 md5 对照实锤(见第 6 节末尾) |
| JSON body 参不参与签名 | — | 实验定论:不参与,仅 FormBody 并入。锚点:recommended 真值 m2=70df7405 精确复现 |
| UA 网络段是变量 | m1 段全错 | SoulBegin-...-{wifi/none}-SoulEnd 随设备网络态变,签名里的 UA 与请求头的 UA 必须逐字符一致 |
| bi[0] 是动态值 | — | 不是设备 ID,是会话启动毫秒的 hex,且 JSON 里带引号 |
| 编码值 vs 解码值 | m2 段全错 | 发送用编码值,签名用解码值(+→ 空格) |
然后是两块最后的拼图。
纯 Python 登录(此前还依赖 App 的 UI):
① GET /v4/account/smsCode?phone=<enc>
enc = 双层 base64( DES-ECB("789!@#xs", 手机号) )
key 来自 SoulPowerful.i() = "789!@#xswEDCzxcv" 前 8 字节
—— 又是"复杂派生包着硬编码常量",跟 getUserIdKey 一个套路
② POST /v7/account/codeLogin(form body 并入签名集)→ data.token = 登录态 tk
③ GET /v3/account/token → tk 轮换
④ GET /v3/account/logout → 登出(第 8 节的教训直接指导了这步:要主动释放设备绑定)
tk 轮换有个三因排障值得记录:/v3/account/token 每次调用返回全新 tk——意味着拿到一次种子就能免设备长期自续。首版客户端连续 500,我挖出三个原因:bi[0] 是带引号的动态会话值、缺 content-encoding: gzip 头、响应是 {"ResponseModel":{...}} 包裹结构(不是裸 code/data)。修复后链式 3 次全通(第二次起幂等,短窗口有稳态)。
广场全家桶(feed/详情/赞/评论/回复/关注/取关)全 10001。权限分层也摸清了:游客 tk 只能 feed 类公开操作,互动需要登录 tk,服务端用 20001 区分。抓包侧两个小坑:mitm saver 的 BODY 截断在 600B,带图帖 4600B 直接截没——所以发帖 body 最小字段集是我从响应反推构造的;winterfell.soulapp.cn 是私有 HTTPDNS 域、公网不解析,mitm 环境下 App 会报"服务器连接失败",重启 App 恢复。
完整发帖(文字 + 图片 + 话题)是三段式:
① GET /file/batch_get/token → STS 临时凭证 + 云上 key + CDN fileUrl
② PUT soul-app.cn-hangzhou.oss.aliyuncs.com/{key}
标准 OSS V1 签名:Authorization: OSS {ak}:{base64(hmac-sha1(sk,
"PUT\n\nimage/jpeg\n{Date}\nx-oss-security-token:{t}\n/bucket/key"))}
③ POST /v4/post/add attachments:[{type:"IMAGE", fileUrl, ...}]
话题 = content 前缀拼 <innerTag>#话题名</innerTag>(服务端自动解析)
两个必踩坑:attachment 必须含 type:"IMAGE" ,否则 10002"附件类型不能为空";domain 提交裸域(不带 不带尾杠),完整 URL 是响应回显才拼出来的。
最终 14 项功能回归全 PASS,全部纯 Python、零设备依赖。
10. 复盘:这次我学到了什么
技术细节会过时——App 会更新、偏移会漂移、key 会换。但这几条是我打算长期带走的东西:
1. 真值锚点驱动。
从第一份明文 flows 开始,我每一个结论——每个字节、每个置换、每个常量——都对着真值验证过,没有一个环节是"推测正确",全部是"验证正确"。没有 ground truth 的逆向是玄学,有 ground truth 的逆向是工程。
2. 判决实验:一次实验回答一个问题。
四组对照定位反 Frida 的触发条件;174 组同 at 异 URL 判决 cs 的输入域;重放官方帧切开两种嫌疑;多 ts 采样终结 [sp,#8] 之惑。问题模糊的时候,先把它设计成二值实验。
3. 结构先行,再逐字节溯源。
啃汇编前先画骨架,之后每破译一个字段立刻归位验证。反过来做(先读汇编再猜结构),我会在那四层加密调用链里迷路。
4. 多路径冗余,死一条换一条。
Frida 死了有内核日志;IDA patch 卡死有自制 ELF;仿真断头有 runner;hook 不可行有 LD_PRELOAD;jadx 超时有 androguard;LSPosed 之外还有 dex 注入。对抗性环境里,准备 Plan B 的速度决定项目速度。
5. 让程序自己解释自己。
这场仗最高效的几步全是这个哲学:runner 让 so 在自己的进程里跑;fake ctx 骗过一个判空;LD_PRELOAD 让 so 亲手把明文盐递进我的函数。与其人肉理解四层加密调用链,不如改变它的运行环境,让它自己吐答案。
6. 动手改二进制之前,再读一遍反汇编。
差点 NOP 掉的 sub_2de424 是 ELF 自定位;差点绕不过的"Context 依赖"只是判空;差点信了的"MD5 变体"只是密文表。对抗代码的陷阱密度极高,而且陷阱长得恰好像"可以绕过的检查"。
7. 最底层的观察错一位,推理错一片。
38hex vs 36hex 数错一位,白跑一整轮实验矩阵;帧头 8B vs 7B,解密全灭;AOSP on Oriole vs AOSPonOriole 一个空格,A 模式失败被错误归因。长度、位数、空格、编码——这些"不值得检查"的事实,恰恰最值得检查。
8. 环境的账要算清,坑要文档化。
遍历 nsenter 重启设备、注入版 APK 被系统清除、venv 依赖冲突、pkill 误伤 system_server、重启丢挂载……这个项目近一半时间我在跟环境搏斗。16 篇实验记录全部留档——坑的文档化是复利,忘掉的坑是复读机。
9. 判断"现在值不值得解这个问题"。
file-backed 段 EIO 没深究(磁盘有明文,非阻塞);aa.y.b 放弃(有替代路径);NEON 置换不硬读(约束拟合直接解出)。逆向的稀缺资源是时间,不是知识。
尾声
写完这篇,我最大的感触还是 6 里那个公式:掀开运行期解密、流搅拌 GOT、瞬态解密、反调试、反 Frida 这一层层保护,算法本体是标准 MD5 + 字符置换 + 明文常量——
对抗的本质不是算法强度,而是可见性。防护方赌的是"你看不到",不是"你算不动"。
而这层赌注在三个动作面前溃败:dump 时机学(让解密发生)、native runner(让它自己跑)、LD_PRELOAD(让它亲手交出来)。
两个通宵,值了。