codex 逆向 flutter

📅 2026/7/18 22:52:26
codex 逆向 flutter
前言前段时间试了下 codex 审计我的代码大模型是 Ollama/GLM 5.2。感觉还是挺实用的。今天想想 实时 逆向线上APP因为听说 apk 是flutter 开发的。看网上说 原生 java 编译出来时 字节码 容易反编译。而 flutter 直接编译 .so 文件 安全性高很多。经过 codex 1个小时折腾。接口 登录加密 签名都意义列出来了。邻人汗颜 强的可怕。全程 我只给命令给权限 没有人为干预这是他自己的输出 他的步骤我梳理了一下。和 我们基本一致 但是他的效率更高1.解包apk 解压得到libapp.so, 以下是 codex 总结的我把这个 IDA 工程libapp.solibflutter.so以及同目录的 APK里能静态确定的部分都查了一遍先说结论再说为什么、以及下一步怎么把 key 真正取出来。确认到的背景应用包名xxxxx品牌xxxxxFlutter 写的HTTP 用dio业务接口前缀/api/v200/登录接口正是POST /api/v200/Access/Login请求里带password以及platform/deviceId/version/appKey/language/channel这些公共参数。加密相关代码在自定义库package:bridge/cipherUtils.dartpackage:bridge/bridge.dart是纯 Dart 实现不是 native .so——我把 APK 解开后里面只有libapp/libflutter/libBugly_Native/libbarhopper_v3/...没有libbridge.so所以 key 不在某个 native 库里。可用的密码学原语只有package:cryptoMD5/SHA1/Hmac和package:convertbase64。包列表里没有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/Listint字节常量要么是运行时由若干片段拼/派生出来的。这种常量存在 Dart AOT snapshot 的对象池里IDA 默认反汇编不会把它解出来——这正是用 IDA 直接看libapp.so找不到 key 的原因不是你的问题。2.blutter 恢复符号成功了——Blutter 这次跑通了Dart 3.9.2编译 22 个文件链接出 blutter.exe已分析完对象池并生成汇编。key 和 password 生成规则都拿到了下面是完整结论。接口加密的 keykey 不是明文字符串常量而是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)ultraComplexWarning0x5c239c。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先加盐再 MD5h1 md5_hex( adosoft 明文密码 adicrosoft )32 位小写 hex盐字符串逐字面量pp.txt 里就是这两串toMD5见 [tools.dart](/D:/apk/out/asm/utils/tools.dart) 0x578748md5.convert(utf8.encode(s)).toString()用 key W 把 h1 “按奇数位插值” 后再 MD5buf for i in 0..min(len(h1),len(W))-1: # h132, W57 → 跑 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”不是 XORbridge.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]); // i1,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–0x5cb25cr16 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::post0x3a6afc先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/AddTradeparams 业务参数 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排序后拼成kv串值取params[k]缺省为 nullkv .join( sorted(params.keys).map(k $k${params[k]}) ) // 形如 orderIdxxxquantityyyy拼最终串并 MD5小写 hexfinalStr key P url url kv keyPurl/api/v200/Trade/AddTradeorderId...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, MapString, 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$Purl$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 urlAddTrade 的业务 params orderIdquantity。公共头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。成本上一个台阶。常量别留constpp.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 用的系统 CAmitmproxy 直接能抓。Pinning 后至少要 Frida 绕过多一道。Frida/调试/root 检测查 ptrace、frida-server、magisk、异常线程名。能绕但逼对手写 bypass。Play Integrity / 硬件背书服务端只给通过硬件级 attestation 的客户端发数据。这是目前最难大规模伪造的一层强推。比任何代码混淆都顶用。5. 别浪费时间的地方“花式混淆”如把 key 拆成 char code 再拼、双层 MD5 插值——这次实测给分析师加了大概 20 分钟却给人“很安全”的错觉。别靠这个。改奇怪函数名ultraComplexWarning挡得住扫一眼挡不住 Blutter 耐心。一句话总结这次逆出来靠的不是它“没混淆”而是它把对称密钥和可重放签名都放进了客户端。要让它难逆优先级是密钥不下发 服务端防重放 硬件 attestation关键逻辑 nativeOLLVM改 snapshot 格式/符号混淆花式混淆基本没用。前两项还能顺带把“就算逆出来也没用”这件事做实那才是真正的护城河。