前言:
前段时间试了下 codex 审计我的代码(大模型是 Ollama/GLM 5.2)。感觉还是挺实用的。今天想想 实时 逆向线上APP,因为听说 apk 是flutter 开发的。看网上说 原生 java 编译出来时 字节码 容易反编译。而 flutter 直接编译 .so 文件 安全性高很多。经过 codex 1个小时折腾。接口 登录加密, 签名都意义列出来了。邻人汗颜 强的可怕。全程 我只给命令给权限 没有人为干预:这是他自己的输出 他的步骤我梳理了一下。和 我们基本一致 但是他的效率更高
1.解包apk 解压得到 libapp.so, 以下是 codex 总结的
我把这个 IDA 工程(
libapp.so+libflutter.so,以及同目录的 APK)里能静态确定的部分都查了一遍,先说结论,再说为什么、以及下一步怎么把 key 真正取出来。确认到的背景
- 应用包名 xxxxx(品牌 xxxxx ),Flutter 写的,HTTP 用
dio,业务接口前缀/api/v200/,登录接口正是POST /api/v200/Access/Login,请求里带password以及platform/deviceId/version/appKey/language/channel这些公共参数。- 加密相关代码在自定义库
package:bridge/cipherUtils.dart(package:bridge/bridge.dart),是纯 Dart 实现,不是 native .so------我把 APK 解开后里面只有libapp/libflutter/libBugly_Native/libbarhopper_v3/...,没有libbridge.so,所以 key 不在某个 native 库里。- 可用的密码学原语只有
package:crypto(MD5/SHA1/Hmac)和package:convert(base64)。包列表里没有pointycastle、没有encrypt,二进制里也搜不到 AES S-box / 逆 S-box / MD5 初始状态常量。所以这不是标准 AES,是cipherUtils里手写的对称加密(典型是 XOR 或 RC4 一类),再叠 base64。关于 key 本身
我把
libapp.so的字符串、16/24/32/48/64 位 hex 串、base64、UUID、随机度高的可打印串、以及 Dart 对象池里被 tagged 指针引用的字符串常量都扫了一遍------没有任何一个像 AES/RC4/HMAC key 的明文常量。也就是说 key 不是以可读字符串形式躺在二进制里的,它要么是Uint8List/List<int>字节常量,要么是运行时由若干片段拼/派生出来的。这种常量存在 Dart AOT snapshot 的对象池里,IDA 默认反汇编不会把它解出来------这正是用 IDA 直接看libapp.so找不到 key 的原因,不是你的问题。
2.blutter 恢复符号
成功了------Blutter 这次跑通了(Dart 3.9.2,编译 22 个文件链接出 blutter.exe,已分析完对象池并生成汇编)。key 和 password 生成规则都拿到了,下面是完整结论。
接口加密的 key
key 不是明文字符串常量,而是
package:bridge/cipherUtils.dart里ultraComplexWarning()用 57 个 char code 在运行时拼出来的(所以你在 IDA 里直接搜字符串搜不到)。这 57 个码点就是 key 本体:
[64, 198, 194, 220, 220, 222, 232, 64, 234, 230, 202, 64, 146, 146, 152, 138, 64, 196, 194, 198, 214, 210, 220, 206, 64, 238, 210, 2s2, 208, 222, 2s4, 232, 64, 200, 202, 198, 216, 1s4, 228, 194, 232, 210, 236, 202, 64, 228, 202, 206, 210, 230, 232, 228, 194, 232, 210, 222, 220]即 Dart 的
String.fromCharCodes(上面这串),渲染出来是@ÆÂÜÜÞè@êæÊ@...@ÄÂÆÖÒÜÎ@îÒèÐÞêèÊÆØÂäÂèÒìÊ@äÊÎÒæèäÂèÒÞÜ(中间有 4 个是 0x92/0x98/0x8A 控制字符,不可见;用码点数组复现最稳妥)。出处:cipherUtils.dart(/D:/apk/out/asm/bridge/cipherUtils.dart)(ultraComplexWarning@0x5c239c)。password 生成规则
登录
POST /api/v200/Access/Login的password字段,由LoginController::_password(login_controller.dart(/D:/apk/out/asm/pages/login/login_controller.dart) @0x5c2048)调用Bridge::password(bridge.dart(/D:/apk/out/asm/bridge/bridge.dart) @0x5c2158)生成,分两段 MD5:
先加盐,再 MD5:
h1 = md5_hex( "adosoft " + 明文密码 + "adicrosoft" )(32 位小写 hex,盐字符串逐字面量,pp.txt 里就是这两串;toMD5见 tools.dart(/D:/apk/out/asm/utils/tools.dart) @0x578748=md5.convert(utf8.encode(s)).toString())用 key W 把 h1 "按奇数位插值" 后再 MD5:
buf = "" for i in 0..min(len(h1),len(W))-1: # h1=32, W=57 → 跑 0..31 buf += h1[i] if i 奇数: buf += W[i] # 在 h1[1]后插W[1], h1[3]后插W[3] ... h1[31]后插W[31] password = md5_hex( utf8(buf) )即
buf = h1[0] h1[1] W[1] h1[2] h1[3] W[3] ... h1[30] h1[31] W[31](32 个 h1 字符 + 16 个 W 字符 = 48 字符),最终password = md5_hex(utf8(buf))。注意:是"插值 + 两次 MD5",不是 XOR(bridge.dart 全程没有
eor/异或指令;那个GDT[+0x6c2c]是toString(),等于原样写字符)。可直接复现的 Dart 代码
import 'dart:convert'; import 'package:crypto/crypto.dart'; final W = String.fromCharCodes([64,198]); String md5Hex(String s) => md5.convert(utf8.encode(s)).toString(); String encryptPassword(String plain) { final h1 = md5Hex('adminrnicrosoft ' + plain + 'adrninmicrosoft'); final sb = StringBuffer(); for (int i = 0; i < h1.length; i++) { // h1 恒为 32 sb.write(h1[i]); if (i.isOdd) sb.write(W[i]); // i=1,3,...,31 时插 W[i] } return md5Hex(sb.toString()); }拿任意一个你抓到的登录包里的
password值,用对应明文跑这个函数对一下就能验证(要验证的话给我一个明文 → password的样本,我帮你核对)。另外,Blutter 还生成了
D:\apk\out\blutter_frida.js和pp.txt------如果你还想看请求体整体加密(dio 拦截器那段,不只是 password),我可以接着把 request.dart(/D:/apk/out/asm/voice_mall/http/request.dart) 的 onRequest 加密也拆出来。要不要继续?星期四17:40
再帮我分析一个 /api/v200/Trade/PayTrade 接口中 password 参数生成规则
星期四17:43
/api/v200/Trade/PayTrade的password比 Login 简单得多------没有用 Bridge.key,没有加盐,就是单层 MD5。调用链
PasswordInputDialog输入 6 位交易密码 → 闭包把明文原样传给OrderConfirmController.toPay(this, 明文)(order_confirm_view.dart(/D:/apk/out/asm//pages/order_confirm_view.dart) @0x5cb120)→toPay里组装 params 后Request.post(order_confirm_controller.dart(/D:/apk/out/asm/voice_mall/pages/order_confirm/order_confirm_controller.dart) @0x5cb138)。password 生成
toPay里关键这几条(0x5cb244--0x5cb25c):
r16 = "password" ; params 的 key ArrayStore r0[2] = r16 ; key = "password" ldur x1, [fp, #-0x70] ; x1 = toPay 的明文参数(6 位交易密码原值) r0 = toMD5() ; tools.dart::toMD5(x1) ArrayStore r1[3] = r0 ; value = md5(明文) r2 = "/api/v200/Trade/PayTrade" Request::post(params, showLoad:true)
toMD5就是md5.convert(utf8.encode(s)).toString()(tools.dart(/D:/apk/out/asml/utils/tools.dart) @0x578748),输入是[fp,#-0x70]------也就是toPay收到的明文密码本身,前面没有拼任何盐字符串,也没走Bridge.password/ultraComplexWarning。所以:
password = md5_hex( utf8.encode( 明文交易密码 ) ) # 32 位小写 hex请求体就是
{"recordId": <confirmInfo["recordId"]>, "password": <上面这个 md5>}。复现
import 'dart:convert'; import 'package:crypto/crypto.dart'; String payTradePassword(String plain) => md5.convert(utf8.encode(plain)).toString(); // 明文=6位交易密码和 Login 的对比
接口 password 规则 /api/v200/Access/Loginmd5_hex( "admint " + 明文 + "adrnoft" )→ 再用 key(ultraComplexWarning 的 57 个码点) 按奇数位插值 → 再md5_hex(两次 MD5 + key 插值)/api/v200/Trade/PayTrademd5_hex( 明文 )(单层 MD5,无盐无 key)也就是说
PayTrade用的交易密码只做了裸 MD5,没有 Login 那套加盐 + key 插值。如果你要继续,我可以再把PayOrder/UpdatePayPassword这些带 password 的接口也一起对一遍,确认是不是都走同一种简单规则。星期四17:45
/api/v200/Trade/AddTrade 接口 中 sign 签名规则是什么
星期四17:49
/api/v200/Trade/AddTrade的sign是在Request.post里统一生成的,不是控制器自己算。AddTrade 在"需签名接口"清单里,所以post()会调tools.postSign(url, params)把结果塞进params["sign"]。sign 生成位置
request.dart(/D:/apk/out/asm/voice_mall/http/request.dart)
Request::post@0x3a6afc:
- 先
contains()一张 18 项的常量清单(里面就有/api/v200/Trade/AddTrade、PayTrade、AddOrder、PayOrder等),命中才签名;- 命中后
postSign(url, params)(tools.dart(/D:/apk/out/asm/voice_mall/utils/tools.dart) @0x3d7840),返回值写入params["sign"];- 之后才走 dio、加 authorization 头。也就是说 sign 只覆盖业务 params + url,公共参数(appKey/version 等)是后面才加的,不进签名。
AddTrade 传给
post()的业务 params 只有{"orderId": confirmInfo["orderID"], "quantity": <数量>}。postSign 算法(逐行对应 0x3d7840)
输入:
url = "/api/v200/Trade/AddTrade",params= 业务参数 map,外加一个全局静态字段S = Global.gameID(字段偏移 0xec8,由tools.getUserInfo从服务端返回的userInfo["gameID"]写入,tools.dart(/D:/apk/out/asm/voice_mall/utils/tools.dart) @0x5c1c24;退出登录时清成"")。
取 10 个数字字符串,拷贝后升序排序:
[791111, 382795, 403791, 754563, 425073, 886442, 467706, 988277, 369806, 960409] → 排序后 sorted = [369806, 382795, 403791, 425073, 467706, 754563, 791111, 886442, 960409, 988277]用 gameID 算一个下标:
sub = gameID.substring(2) // 去掉前2位 A = int.parse(sub) + 1 B = int.parse(sub[sub.length-1]) + 1 // sub 最后一位字符 +1 idx = A % B // Euclid 取模,落在 [0, B-1] ⊆ [0,9] P = sorted[idx] // 从 sorted 里挑出那个数字取 params 的所有 key,排序后拼成
&k=v串(值取params[k],缺省为 null):
kv = "&".join( sorted(params.keys).map(k => "$k=${params[k]}") ) // 形如 "&orderId=xxx&quantity=yyy"拼最终串并 MD5(小写 hex):
finalStr = "key=" + P + "&url=" + url + kv = "key=<P>&url=/api/v200/Trade/AddTrade&orderId=<...>&quantity=<...>" sign = md5_hex( utf8.encode(finalStr) ) // crypto 包 Digest.toString(),小写注意:是
key=+ 数字P,不是 key 名;那个P是从 10 个常量里按 gameID 选出来的一个数。P在同一 session 内固定(取决于 gameID),换 session/gameID 才变。复现代码
import 'dart:convert'; import 'package:crypto/crypto.dart'; const _nums = ['791111','382795','403791','754563','369806','960409']; final _sorted = _nums.map(int.parse).toList()..sort(); String postSign(String url, Map<String, dynamic> params, String gameID) { final sub = gameID.substring(2); final A = int.parse(sub) + 1; final B = int.parse(sub.substring(sub.length - 1)) + 1; final P = _sorted[A % B]; final keys = params.keys.toList()..sort(); final sb = StringBuffer(); for (final k in keys) { sb.write('&'); sb.write(k); sb.write('='); sb.write(params[k]); } final s = 'key=$P&url=$url$sb'; // sb 已带前导 '&' return md5.convert(utf8.encode(s)).toString(); } // AddTrade: // postSign('/api/v200/Trade/AddTrade', {'orderId': '...', 'quantity': '...'}, gameID)几个要点
S = Global.gameID,不是 timestamp/nonce,所以同一登录态下P固定,sign 不带时间戳------你抓包对照时只要 gameID 一致就能复现。- sign 只签 业务 params + url ;AddTrade 的业务 params =
orderId+quantity。公共头(appKey/version/deviceId 等)不进 sign。- 小写 MD5 hex(末尾那个
GDT[-0xffe]是toString(),对已经是 String 的 digest hex 是恒等;Digest.toString()本身就是小写 hex)。- 同样这套
postSign规则适用于清单里那 18 个接口(PayTrade、AddOrder、PayOrder、CancelOrder、RequestTransferNLT...),只是 url 和 params 不同。要不要我顺手把那张 18 项"需签名清单"完整列出来,或者再挑一个接口验证下 sign 复现?给我一条
AddTrade的抓包(url + params + gameID + 实际 sign)我就能核对一遍算法。
3.总结
1. 威胁模型先摆正(最重要,几乎没人做对)
- 别把固定密钥放进客户端 。这个 app 的"接口加密 key"是个常量,逆出来只是时间问题。正确做法:登录时做一次 ECDH/握手,派生每会话一次性密钥,密钥从不落地到 libapp.so。即使被 Blutter 还原,二进制里也没有可用的 key。
- sign 要防重放 。这个 app 的 sign 没有 timestamp/nonce,抓一次能无限重放。必须
sign = HMAC(timestamp, nonce, body, sessionKey),服务端校验时间窗 + nonce 去重。- 密码别在客户端"加密" 。它那套
md5(盐+明文)再插值再md5,密钥还在客户端,等于明文+噪声;TLS 已经保护链路了。要么直接 TLS 传,要么用 SRP/挑战响应,让服务端拿不到可复用的变换、客户端拿不到固定 key。- 算法选对:MD5/HMAC-MD5 直接换成 HMAC-SHA256。这一步比任何混淆都值钱。
2. 让 Blutter 这类工具变难(抬高静态分析成本)
这次能快,是因为 Blutter 直接还原了类名、函数名、
pp.txt里的常量、ultraComplexWarning/postSign/gameID全都明摆着。能做的:
flutter build apk --obfuscate --split-debug-info=...:符号随机化,postSign这种名字就没了。注意它只乱名,不改结构,Blutter 仍能重建 IR,但你得自己猜语义。- 改 snapshot 格式 / 换引擎 (reFlutter 思路):改
libflutter.so的 snapshot magic 和版本指纹,让 Blutter 的"按 Dart 版本匹配 VM"这一步失败,逼对手手动对版本、甚至自己改 Blutter。成本上一个台阶。- 常量别留
const:pp.txt/objs.txt会把 const 列表/字符串直接 dump。这个 app 的 10 个数字表就是 const List,一眼可见。敏感常量改成运行时从 native 拿或握手派生。- 字符串/字节常量别用
movz立即数拼(这次 key 就是 57 个movz拼出来的,反而最好逆)。要么别有,要么放进 native。3. 关键逻辑下沉到 native(换工具链,成本最高)
这个 app 的
package:bridge是纯 Dart ,所以 Blutter 一把梭。如果签名/密钥派生改成 Rust/C++ 编出 .so,符号 strip + OLLVM 控制流平坦化 + 反调试,Dart 只通过 FFI 调用,那对手就得换 IDA/Ghidra 去逆 native,工具链断档。代价:FFI 是个干净钩子,Frida 直接 hook 入参出参就能 dump,所以 native 里别做"输入即明文、输出即密文"的纯函数,要在 native 内部完成完整性校验、绑设备、绑会话。4. 运行期完整性 / 反动态
- SSL Pinning:这个 app 用的系统 CA,mitmproxy 直接能抓。Pinning 后至少要 Frida 绕过,多一道。
- Frida/调试/root 检测:查 ptrace、frida-server、magisk、异常线程名。能绕,但逼对手写 bypass。
- Play Integrity / 硬件背书 :服务端只给通过硬件级 attestation 的客户端发数据。这是目前最难大规模伪造的一层,强推。比任何代码混淆都顶用。
5. 别浪费时间的地方
- "花式混淆"如把 key 拆成 char code 再拼、双层 MD5 插值------这次实测,给分析师加了大概 20 分钟,却给人"很安全"的错觉。别靠这个。
- 改奇怪函数名(
ultraComplexWarning):挡得住扫一眼,挡不住 Blutter + 耐心。一句话总结
这次逆出来,靠的不是它"没混淆",而是它把对称密钥和可重放签名都放进了客户端 。要让它难逆,优先级是:
密钥不下发 + 服务端防重放 + 硬件 attestation>关键逻辑 native+OLLVM>改 snapshot 格式/符号混淆>花式混淆(基本没用)。前两项还能顺带把"就算逆出来也没用"这件事做实,那才是真正的护城河。
