这个 App 会翻我的口袋查家伙,我把它翻到的名单涂了

2026-10-22 / 52Run.cn 技术手记 · 素材 2026-10-14 至 10-19 五轮对拍笔记

十月中,我又跟一个跑步 App 的加密协议杠上了。这次不是运动世界校园版,是同一家加固体系里的另一个——启动就发 12 个加密请求的那种。上次那个 App 我熬了两天,最后结论是离线解密走不通,文章都写过了。这次我本来想的是:换个号接着试,万一能通呢。

结果五天下来,最值钱的不是把加密破了,而是一个特别别扭的发现:协议确实破不了,但 App 自己能解。那就不破了,让它的手替我干活。

先说狂喜的那一夜

10 月 14 号晚上,我在模拟器里把 App 的加密库整个 dump 下来,用 unicorn 一条指令一条指令地模拟执行。跑通的那一刻是这样的:我拿 App 自己加密出来的第一块密文,反推出它用的初始向量,然后用 Python 的标准 AES 库去算——

C0 = AES-128-ECB(key=mask前16字节, pt[0:16] ^ IV0) = 7364dbbe6b10974155d292cfdcff3be6 ✓

逐字节命中。也就是说这个号称"白盒加密"、号称密钥藏在混淆表格里碰都碰不到的东西,内核就是标准 AES-128,连填充方式都朴素得很——明文 168 字节,后面补零补到 176,连 PKCS7 都懒得用。unicorn 模拟的输出和 Python 算的逐位一致。

更妙的是我把它的轮密钥表也看穿了:那个传说中的 mask 区一共 176 字节,刚好是 AES-128 展开之后的 11 把轮钥匙,它每轮加解密就从里面按轮号取一把。我在模拟器里每次调用都把这块内存抄一份,抄了 11 次,一字节都没变——钥匙是死的,就躺在那儿,玄的不是钥匙,是每块的初始向量。

我顺手把它的请求信封也扒清楚了:一个 JSON,密文字段、明文的 MD5、一个 128 字节的会话密钥、两个版本号,齐活。还发现个好玩的事——它的随机数根本不是随机的,是预生成的六个 UUID 存在本地轮着用,写 App 的人偷了个懒,这个懒对我很有用。

我当时觉得这事就成了。第一块都通了,后面不就是照葫芦画瓢的事吗。

然后我在第二块上卡了三天。

第二块,卡到怀疑人生

标准 CBC 模式里,第二块的初始向量就是第一块的密文,天经地义。我按这个假设跑,解出来的明文从第 17 个字节起全是乱码。

不信邪。我把每一块的真实初始向量全部反推出来,发现一个特别糟心的事实:每一块的 IV 互不相同,而且都不等于前一块密文。6 种明文变体挨个试,不中;15 种 IV 派生方式——md5、sha、AES 自己派生——挨个试,不中。密文长度也对不上:明文 72 块的东西,密文有 98 块;明文 4 块的,密文 8 块——多出来的二三十块不是简单的 IV 流,什么布局头、尾、交替、循环我都排过了。结论慢慢浮出来:每一块的 IV 是它内部一个状态机在逐块推进的,从外面看得见的密文和明文里推不出来。

同一时间另一条线也在收账。这条线的家底是早前抓的一次全量流水:8646 条 Java 调用记录,其中加密方法被调了 2401 次、解密 483 次。我从里头筛出 212 组"密文和明文"都齐全的响应对,拿旧版密钥一对,210 组里通过了 190 组——够狠了吧?当前版本呢,11 个实时样本,解出 0 个。它升级了。顺带把它的登录链路也看明白了:先问一遍能不能一键登录,再走账号密码,最后换回令牌;每个请求头上挂着二十多个设备字段——屏幕像素、CPU 型号、是不是 root、App 装了多久,全给它报上去。

中间还踩了个环境坑:内存 dump 是老版本 ARM64 代码,现在模拟器里跑的是 x86_64 原生库——不是模拟器翻译层转的,是安装包里直接带了 x86 库——我照着老 ARM64 序言去 hook,两个标志位全是 False,白忙一晚上才知道方向错了。还有个更蠢的:dump 内存的小脚本里,读地址忘了加十六进制前缀,把地址当十进制解析,读出来一堆对不上的数,我还以为内存布局变了,折腾半天是我自己进制搞错了。

工具链也在添乱。中间人抓包的脚本默认把请求体截到 2000 字符,导致我能拿到的"密钥和密文"精确配对只剩两对,想验证密钥派生公式,样本根本不够,只能回头改脚本重新抓。

那几天我试过的组合,随手一数:密钥派生 17 种乘 IV 变体 8 种、CTR 模式 5 种计数器形式、OFB、ECB、11 块轮密钥轮转配多种 IV、错误码穷举十万次、2964 组密钥乘 IV 的矩阵、49 个静态候选密钥乘 8 种 IV——全军覆没。

写到这我都替自己累。

还有个更丧的:它杀我脚本比我想的快

这个加固体系带自检。我 hook 它的 Java 解密方法,装上之后十秒钟内脚本就被它干掉,后期干脆安装即死。我摸出来一个"attach 完立刻调用能赶在扫描前面"的窗口——成功了一次,跟抢红包似的,手速再快也就抢一回。而且它还分情况:真实解密路径会触发自检,空结果和快速路径反而不触发——它的扫描是有针对性的,专挑真干活的时候查岗。

重放这条路上也全是坑。把 App 自己发过的请求原样重放,回来的是一个 144 字节的固定错误信封——它有随机数重放检测。这个错误信封还解不开,疑似拒绝路径走的是另一把专门的钥匙,连"看看它怎么骂你"都做不到。后来我对旧版的错误响应做了位级分析,发现个挺讲究的设计:报错那件的初始向量,是在基准值上把错误码的数字直接异或进特定字节——连报错都是拿密钥算出来的。

转折:它翻我的口袋

转机藏在它自己的自检逻辑里。

我盯着它杀脚本的规律看了一晚上,发现这个白盒自检有个特点:它会去读系统里的两个文件——/proc/net/unix 和 /proc/self/maps——这两个文件里躺着当前进程加载的所有东西的名字。它就是在里面搜一个字符串:frida。搜到,就自毁。

顺便说一句,我连 frida 本体都提前改造过:模拟器里跑的是改了线程名的补丁版,就为躲它的进程扫描。结果它根本不扫进程名,直接翻文件内容——防了个寂寞。

也就是说,它查我,靠的是"翻名单"。

那我为什么要正面刚它的加密?我把它的名单改了不就完了。

做法说出来特别土:用 native 层 hook 把 open/openat 拦下来,记下每个文件句柄对应哪个路径;再拦 read/pread,凡是读那两个文件的,把返回缓冲区里的 frida、gum-js 这些特征串做等长替换——一个字母换成一个 x,长度不变,文件结构不变,它读到的名单干干净净。

装上之后,那个十分钟就杀我一次的自检,再也没杀过。它的解密方法我可以反复调用,想调几次调几次。

等长替换这个细节是真琢磨过的:你要是把字符串删了或者换短的,文件偏移就变了,它可能会发现文件不对劲。等长替换,文件的天衣无缝,只是内容换了。它自己的加密函数照常运转,因为它根本不知道自己已经瞎了。

让它的手替我干活

自检绕过之后,路一下子宽了。我搭了个流水线:让 App 自己发请求(它发的请求签名、加密全部合法,人家自己算的)→ 中间人工具在旁边把响应密文抄下来 → frida 附加进去,用刚续上命的那个解密方法,让 App 自己把密文解开 → 我拿明文。

第一次跑通的时候,屏幕上打出来一个 1136 字节的用户信息 JSON,全是明文。整个解密过程耗时 700 到 1500 毫秒,比我想的慢一点,但能用。

当然坑还是有的。最阴的一个:它的密钥管理器只保留最近一个请求的会话状态,会话钥匙每轮还换,同一轮冷启动里我就见过九个不同的值——所以重放来的响应对我毫无用处,解密绑的是当场那把钥匙。App 启动那 12 个加密请求,排在前面的响应全解不开,能解的永远只有最后落地的一两个。而且必须冷启动——热重启它不重建状态,一个都解不出。所以我那个客户端每跑一轮都要把 App 强杀重启,看着特别暴力,但有效。也不是所有接口都能解——时间校准那类接口压根不走标准加密信封,另一套路数,本次没碰。

最后产品化出来一个几百行的 Python 客户端,三种用法:单轮抓一次、持续模式跑 N 秒、或者丢一个密文文件进去单独解。跑一遍,用户信息的完整明文就躺在输出目录里。它还自带体检:启动前先检查抓包进程、代理、模拟器连接是不是都活着,缺哪个报哪个。

账要算清:哪些成了,哪些没成

拉数据,成了。App 会自发请求的那些接口,现在都能拿到明文。

自定义上传,没成。它的请求签名覆盖整个请求体——我做实验确认的:只改密文体,服务端返回 200 但内容 0 字节(拒绝);改随机数,响应原封不动(说明它压根不参与签名);改时间戳,拒绝。原样重放倒是能无限成功,这是个意外收获。我在 13104 组组合里穷举它的签名公式,一个都没中——这玩意是白盒内部算的,公式出不来。

顺着它的代码我还翻到一排备胎:密钥工厂里注册了两种加密实现,主用的就是这套白盒,备胎是另一家叫"爱加密"的商业方案——原来它还给自己留了后手,只是没启用。我把那排混淆得亲妈都不认识的类一个个枚举过去,其中一个看着像签名函数的,实验下来只是个往请求里塞设备信息的辅助;还有一个干脆是调试日志器,专门往控制台写"请求 method:POST url:…"。人家调试用的东西,我还当宝贝研究了一晚上。

想全通,得再啃两三天的 x86 白盒逆向——2MB 的运行时内存映像我已经 dump 好了,就躺在那。我评估了一下,拉数据这条线已经够用了,先放一放。

这五天最实在的一个心得:正面刚不动的盾,看看它自己是怎么用这面盾的。它自检靠翻名单,我改名单;它解密有一手,我借它的手。全程没有破掉那套白盒加密一个字节,但我要的数据拿到了。

上回写运动世界那篇,结尾是"这条路走不通"。这回算给它补了个下集:走不通的路旁边,一般都有一条土路。

本文为真实开发记录整理 · 仅用于安全研究与学习 · 细节出自 2026-10-14 至 10-19 五轮对拍笔记