证明这条路走不通,比拿到结果更值钱
⚠️ 温馨提示:本服务仅限个人娱乐、纪念用途,严禁用于学校体测、企业考核、赛事资格等正式场景。
先给结论。那个校园跑 App 的接口,我最后没解开——但我不是停在"没解开",我是停在一句能被证明的话上:那把 AES 密钥从来就没到过我手上,所以离线解密这条路不是难,是不成立。这两件事的区别,是我那 7 小时里最值钱的东西。
背景得先说清楚,不然下面的报错看不懂。我一个人管着一个跑步数据网站 52Run.cn,前端、服务器部署、客户交付、ICP 备案、每天的备份都得沾手。活儿多了就得找个 AI 搭子,它写代码快、部署利索,毛病是偶尔自信过头先斩后奏。目标是一个校园跑 App 的上传接口,请求头里带一个叫 headerSign 的字段,五段结构:
headerSign: {"d":"<base64>","h":"<hex32>","k":"<base64>","p":"101","t":"0"}
字段的物理尺寸第一晚就摸清了:k 恒定 128 字节,正好是 RSA-1024 的输出长度;d 是 864 字节、16 字节对齐,是 AES 密文;h 是 32 位十六进制,MD5。教科书式的混合加密:RSA 传会话密钥,AES 加密正文,MD5 做校验。方案看着很清楚——找到会话密钥,解开 d。听起来一晚上能搞定。
第一轮:把"猜密钥"跑成了穷举报表
最朴素的想法是密钥会不会硬编码在 so 里,挖出来暴力试。于是有了 160 多个任务夹里最壮观的场面——828 种组合一次跑完,全灭。
【证伪报告】假设1:d 用 libswsport 里的静态密钥加密 → 否,已证伪
828 种组合全部失败:
密钥候选 6 个串 × 派生方式(raw[16/24/32]/md5/sha256/b64解码/hex解码)
模式:CBC / ECB / CFB / OFB / CTR
IV:全零 / 密钥前16字节 / 密文前16字节 / 6候选串各自16字节 / 各串MD5
数据:整体 / 跳过前16字节
最高可打印率 0.435(纯随机噪声),jsonish 命中 0。
0.435 什么意思?Python 那个判断"像不像可打印文本"的比例,正常明文能到 1.0,0.435 就是随机噪音。当时我反而挺平静——真要这么容易,第二天下午我就该关门了。
第二轮:拿 App 当自己的解密工具,还是死
明文搜不到,就从"App 自己怎么加密的"反推。让 App 加密一段我已知的文本,再拿密文跟我本地算的对比反推模式、IV、密钥派生——这叫 oracle。它用 App 加密了 32 字节全零,结论干净:模式是 CBC。两条相同明文产出两块不同密文,只可能是 CBC 或带 IV 的模式。这步很漂亮。下一步就死了。它把 6 个密钥里最像样的那个直接喂进 Python 的 AES,任何模式都对不上。更狠的是那个特例:
key = 2019630bluetooth
App 实际加密全零明文 → block0 = 66e94bd4ef8a2c3b884cfa59ca342b2e
这个值 = AES-128(全零密钥, 全零块) 的标准答案
→ 说明 App 根本没直接用我传进去的密钥,中间必然还有一层派生
66e94bd4... 我认识,那是 AES-128 全零密钥的标准测试向量。也就是——App 收下了我的密钥,把它扔了。这不叫加密,这叫耍赖。
转折:不是解不出来,是问题问反了
又试几轮不成,它去做了件特别笨的事——把所有已知的密文长度、字节分布、跨请求的重复规律全摊在桌面上算了一遍。就是把已知事实排排队。
| 观察 | 说明什么 |
|---|---|
d 恒定 864 字节(16 对齐) | AES 分组长度,密文成立 |
k 恒定 128 字节,170 个样本全不同 | RSA 每次随机填充,同一明文也必不同 |
d 的前 135 字节在所有请求里一模一样 | 同一个 AES 密钥 + 同一个固定 IV |
我们从第一秒就在问"这个密钥是多少、怎么解出来"。可这三条数据说的是另一件事——d 的密文前缀跨请求不变,意味着加密它的那个密钥在整个会话里就没变过。那这个密钥是哪来的?
看 k。它 170 个样本全不一样,长度又刚好是 RSA-1024。k 就是服务端公钥加密后的那个会话密钥——用客户端的 AES 加密正文,再用服务端的 RSA 公钥把 AES 那把钥匙包起来发出去。客户端自己压根不知道那把 AES 密钥,服务端私钥才有。所以:我手上永远不会有那把 AES 密钥,因为它从来就没到过我手上。这不是"我还没找到解法",是"离线解密在数学上就不成立"。
第三轮:只剩最后一招,被翻译层堵死
静态拿不到就动态截——在 App 加密那一刻把明文钩出来,工具是 frida。这一轮踩了几个坑,顺手记下来免得下次重踩:
frida 17 不再内置 Java bridge → 报 ReferenceError: Java is not defined
正解:装 frida-compile + frida-java-bridge,把 agent 源码编译后再加载
Module.findExportByName 在 17 里被移除 → 改用 Process.getModuleByName().findExportByName()
改完 hook 必须 force-stop 重启 App,否则旧 hook 残留报 "already replaced"
这些坑都是真踩过才知道的。但真正卡死的是最后一层:
Process.findRangeByAddress(base+0x8c54) → {"protection":"r--"} ← 只读
Process.findModuleByAddress(...) → null
Process.enumerateModules() → 有 libswsport.so 吗? 0
NativeFunction 调用 ARM 地址 → access violation
原因挺离谱:我的模拟器是 x86 的,这 App 只有 ARM32 版本,靠一层叫 houdini 的翻译层在跑。翻译层加载的 ARM 代码,在 frida 眼里就是一堆只读的内存块——不是代码,是数据。它的原话是:frida 永远无法调用或 hook 那个 so 里的 ARM 函数,只能走 Java 层或纯内存读取。
我说能不能读内存。它还真读出来了——从 .data 段 dump 出 6 把密钥,9954 个类里 SportJniUtils 干干净净躺着,用 App 自己的 Java 层接口验证了索引映射。然后就没有然后了。Java 层拿到的是"加密方法",不是"明文"。要拿明文得拦截 native 调用,而 native 那层它够不着。它自己的收尾结论大意是:信封由 native SDK 构造、字段名运行时拼出来、Java 层拿不到;静态手段能拿的密钥已全拿到,剩下的必须动态截密文,而这条路被 houdini 堵死了。死得干干净净。
那 7 小时里我俩干了什么
老实讲一开始我有点上火。它从晚上十点折腾到第二天下午五点半,中间好几轮方案都是自己提、自己推翻。还有个让我很烦的毛病——每次提新方案都特别自信,说得像已经通了,然后半小时后自己说"此路不通"。我中间好几次就想算了,我要的是一个能用的接口,不是看我俩表演什么叫穷举。但"问题问反了"那个发现把我镇住了。它没有继续瞎试,而是回去把手上所有已知数据重新算了一遍——从 128 字节这个长度读出 RSA,从密文前缀不变读出"密钥不在我手上"。这不是运气,是把话说明白了。后来我们把能确定的全定下来:哪些字段是加密的、密钥在哪一层、为什么静态解不开、唯一一条活路是什么。它列了三条备选——降级到加固更弱的旧版本试、绕到网页版去拿、或者先做数据格式生成器。我没当场拍板,先存着。
之后我改了什么
就一条:以后派活儿,我会先把"怎么算成功"和"怎么算失败"一起说清楚。以前我老说"你去搞定它",它就真的一路试下去。现在我会加一句:搞不定就告诉我边界在哪儿,别拿"我试了 828 种"当答案。它那份证伪笔记我还留着。哪天这个 App 换了协议,我不用从头再来一遍,直接知道上次撞的是哪堵墙。折腾一整天最后没拿到想要的东西,但至少我知道门在哪、墙有多厚。这种"明确的失败",说实话比一次运气好的成功值钱。
关键参数与事实
| 项目 | 实测值 | 含义 |
|---|---|---|
headerSign.d 长度 | 恒定 864 字节(16 对齐) | AES 分组长度,密文成立 |
headerSign.k 长度 | 恒定 128 字节,170 样本全不同 | RSA-1024 输出,每次随机填充 |
headerSign.d 前缀 | 前 135 字节跨请求不变 | 同一 AES 密钥 + 同一固定 IV |
headerSign.h | 32 位十六进制 | MD5 校验 |
| 静态解密密文 | 828 种组合全失败 | 最高可打印率 0.435,jsonish 命中 0 |
| 命中密钥数 | 6 把(.data 段 dump) | Java 层可用,native 层不可达 |
踩过的坑
- 把"猜密钥"当正解——硬编码密钥假设一轮就死:828 组合 × 6 候选 × 5 模式 × 多种 IV × 2 数据处理,最高可打印率 0.435,纯随机噪声。
- oracle 推模式成功但推不出密钥——用 App 加密 32 字节全零可判定模式是 CBC,但传入密钥后 App 走了一层派生,密文对不上。测试向量
66e94bd4…直接证明它扔掉了你传的密钥。 - frida 17 三连坑——不再内置 Java bridge(报
ReferenceError: Java is not defined,需 frida-compile + frida-java-bridge);Module.findExportByName被移除改用Process.getModuleByName().findExportByName();改完 hook 必须 force-stop 重启,否则报 "already replaced"。 - x86 模拟器 + ARM32 应用的 houdini 死锁——翻译层加载的 ARM 代码在 frida 眼里是
r--只读数据,enumerateModules()里查不到libswsport.so,NativeFunction 直接 access violation。
输入 / 输出示例
如果只需要"看起来像那么回事"的运动数据,走的路跟上面完全不同:输入运动类型(跑步 / 骑行 / 游泳)、距离、配速、日期、城市与路线,输出的是一张 800×1000 白底数据海报图(示例数据),字段包含配速、时长、消耗、平均心率,格式 JPG ≤200KB。相关的 Keep 数据生成器能直接生成多运动类型数据,用来核对本文这种"接口数据"和"生成数据"的差别——一个是真加密,一个是拟合出来的自洽数值。
常见问题 FAQ
headerSign 里的 k 和 d 分别是什么?
k 是用服务端 RSA 公钥加密后的 AES 会话密钥,长度恒定 128 字节正好等于 RSA-1024 输出;d 是用那把 AES 密钥加密的正文密文,恒定 864 字节、16 字节对齐。h 是 32 位十六进制的 MD5 校验。
为什么 828 种组合全部失败就能断定静态解密不可行?
因为解出来的内容最高可打印率只有 0.435,而正常明文接近 1.0,jsonish 命中 0,说明产出就是随机噪声。搜索空间已覆盖 6 个密钥候选、6 种派生方式、5 种分组模式、多种 IV 取法与两种数据边界处理。
为什么说那把 AES 密钥从来不在客户端?
因为 d 的前 135 字节在所有请求里完全一致,说明加密它的密钥整个会话没变;而 k 在 170 个样本里全不同、长度固定 128 字节,这正是 RSA 随机填充的特征。客户端只有服务端公钥,会话密钥由服务端私钥持有。
oracle 方法为什么能判定模式却推不出密钥?
让 App 自己加密已知的 32 字节全零,用密文反推规则,这一步能确定模式是 CBC。但传入的密钥必须再经过一层派生才能用,用原值喂进 Python AES 任何模式都对不上,标准测试向量 66e94bd4 证明 App 丢弃了你传的密钥。
x86 模拟器上 frida 为什么读不到 ARM 函数的明文?
因为 ARM32 应用是靠 houdini 翻译层在 x86 上跑的,翻译层加载的代码在 frida 眼里是 r-- 只读数据块,不是可执行代码。findRangeByAddress 返回 r--,findModuleByAddress 返回 null,enumerateModules 里也查不到 libswsport.so,NativeFunction 调用直接 access violation。
相关工具
- 工具总览 — 全部在线运动数据工具入口一览
- Keep 数据生成器 — 跑步骑行游泳多模式数据生成
- GPX 生成器 — 轨迹文件导出与坐标处理