codex 逆向 flutter

前言:

前段时间试了下 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.dartpackage: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.dartultraComplexWarning() 用 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/Loginpassword 字段,由 LoginController::_passwordlogin_controller.dart(/D:/apk/out/asm/pages/login/login_controller.dart) @ 0x5c2048)调用 Bridge::passwordbridge.dart(/D:/apk/out/asm/bridge/bridge.dart) @ 0x5c2158)生成,分两段 MD5:

  1. 先加盐,再 MD5:
    h1 = md5_hex( "adosoft " + 明文密码 + "adicrosoft" )(32 位小写 hex,盐字符串逐字面量,pp.txt 里就是这两串;toMD5tools.dart(/D:/apk/out/asm/utils/tools.dart) @ 0x578748 = md5.convert(utf8.encode(s)).toString()

  2. 用 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.jspp.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/PayTradepassword 比 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.postorder_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/Login md5_hex( "admint " + 明文 + "adrnoft" ) → 再用 key(ultraComplexWarning 的 57 个码点) 按奇数位插值 → 再 md5_hex(两次 MD5 + key 插值)
/api/v200/Trade/PayTrade md5_hex( 明文 )(单层 MD5,无盐无 key)

也就是说 PayTrade 用的交易密码只做了裸 MD5,没有 Login 那套加盐 + key 插值。如果你要继续,我可以再把 PayOrder / UpdatePayPassword 这些带 password 的接口也一起对一遍,确认是不是都走同一种简单规则。

星期四17:45

/api/v200/Trade/AddTrade 接口 中 sign 签名规则是什么

星期四17:49

/api/v200/Trade/AddTradesign 是在 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/AddTradePayTradeAddOrderPayOrder 等),命中才签名;
  • 命中后 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;退出登录时清成 "")。

  1. 取 10 个数字字符串,拷贝后升序排序:

    复制代码
    [791111, 382795, 403791, 754563, 425073, 886442, 467706, 988277, 369806, 960409]
    → 排序后 sorted = [369806, 382795, 403791, 425073, 467706, 754563, 791111, 886442, 960409, 988277]
  2. 用 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 里挑出那个数字
  3. 取 params 的所有 key,排序后拼成 &k=v 串(值取 params[k],缺省为 null):

    复制代码
    kv = "&".join( sorted(params.keys).map(k => "$k=${params[k]}") )   // 形如 "&orderId=xxx&quantity=yyy"
  4. 拼最终串并 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。成本上一个台阶。
  • 常量别留 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 用的系统 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 格式/符号混淆 > 花式混淆(基本没用)。前两项还能顺带把"就算逆出来也没用"这件事做实,那才是真正的护城河。

相关推荐
帅次2 小时前
Android 高级工程师面试:Flutter 路由导航 近1年高频追问 18 题
android·flutter·面试
浮江雾2 小时前
Flutter第七节-----快捷键创建组件与flutter中的点击事件
flutter
想你依然心痛11 小时前
【共创季稿事节】HarmonyOS 7.0 应用框架(ArkUI-X)跨平台能力深度探索
flutter·react native·arkui-x·跨平台渲染·harmonyos 7.0·arkrender·组件兼容性
SoaringHeart15 小时前
Flutter最佳实践:键盘辅助视图输入框天添加图片附件
前端·flutter
心中有国也有家16 小时前
AtomGit Flutter 鸿蒙客户端:Canvas 绘制进阶-路径、渐变与混合模式
android·javascript·flutter·华为·harmonyos
程序员老刘17 小时前
Android 17的10个AI功能,国内用户真正能用的有几个?
android·flutter·ai编程
浮洋21 小时前
HabitGo:一个 Flutter 习惯追踪 App 的深度技术剖析
flutter
西西学代码21 小时前
Flutter---RefreshIndicator案例
flutter
西西学代码21 小时前
Flutter---RefreshIndicator
flutter