WMCTF2025 Want2BecomeMagicalGirl复现
WMCTF2025 Want2BecomeMagicalGirl
最近在总结flutter逆向 又重新看了一遍这道题
WMCTF2025 Want2BecomeMagicalGirl - Britney
java层
Java 层其实很短,真正有用的只有一条 Flutter 通道和一个 XXTEA变种
入口MainActivity onCreate() 只做了几件事:
- 注册 Flutter 通道:P0.b.c(aVarJ)。
- 弹一个 120 秒倒计时对话框,文本来自 story 资源。
真正流程关键在 P0/b.java:48:
- 它向 Flutter 注册了一个 MethodChannel(“to”)。
- handler 取的是 hVar.f7172a,也就是 MethodCall.method,不是 arguments。
- 收到字符串后走 f(str)。
f(str) 的逻辑在 P0/b.java:79:
- 第一次调用时启动一个后台线程。
- 把种子重置为固定值 114514。
- 用 Java Random 同款 LCG 公式生成一个 16-bit 数。
- 这个数每次都一样,算出来是 16929。
- 然后调用 c.a(str, “16929”)。
c.a() 在 P0/c.java:10Base64( Magic.encrypt(str_utf8, “16929”_utf8) )
加密算法Magic里确实是 XXTEA/BTEA 加密变种:
- delta = 0x9E3779B9
- 轮数 q = 52 / n + 6
- fixKey() 把 key 截断/补零到 16 字节,再按小端拆成 4 个 int
- 明文也按小端每 4 字节打包成 int[]
可以把它简化成:
Flutter 调 MethodChannel(“to”).invokeMethod(plaintext, anything) -> Java 用固定 key “16929” 对 method 名做 XXTEA/BTEA 变种加密-> Base64 返回给 Dart
dart层
在 Dart 和 Flutter 的架构中,层级设计是实现高效、模块化开发的核心。Dart 层级通常分为UI 层和数据层,并通过清晰的职责划分和组件化设计,确保代码的可维护性和扩展性。
通过blutter看到的,在 /asm/magical_girl/里有editview.dart等关键组件:
调用路径可以还原成这样:
1 | main() |
asm:
- main() 里 runApp(MyApp()) 在 main.dart:48
- MyApp.build() 里先创建 MyEditText(),再创建 MaterialApp(),并把 MyEditText 塞进去,在 main.dart:507
- MyEditText 是个 StatefulWidget,createState() 返回 MyEditTextState,在 EditView.dart:1329
- MyEditTextState.build() 里创建了 TextField,并给它挂了一个 closure (闭包)回调,在 EditView.dart:1 和 EditView.dart:120
- 这个回调再 setState(…),在 setState 里面真正调用 check(text),在 EditView.dart:388 和 EditView.dart:449
以后读 blutter,可以按这个顺序找:
- main -> runApp
- 根 widget 的 build
- 有没有 StatefulWidget
- createState
- State.build
- UI 控件上的 closure
- closure 里有没有 setState / onPressed / onSubmitted
- 最后才到业务函数,比如 check
SetupParameters:先看回调签名,知道它收什么参数
AllocateContext + StoreField:看 closure 捕获了什么变量
AllocateClosure:看又套了几层 closure
setState():很多 Flutter 业务逻辑其实包在这里面
LoadField:调用前到底把哪些 capture 取出来了
bl xxx:最后才是实际调用点
check函数逻辑
- 先通过 FFI 从 libnative_add.so 取两份 native 数据:
- getKey() 返回 24 字节 Uint8List:native_add.dart:9
- getSym() 返回 8 个 Uint16 的 ExternalUint16Array:native_add.dart:117
- 然后读取 getSym().buffer.asUint8List() 的某个字节做分支判断:EditView.dart:643 EditView.dart:679
- 分支两边都会把 getKey() 和一小段固定字节拼成 AES 用的 key material:EditView.dart:682 EditView.dart:726
- 另外还构造了一个固定 16 字节序列 6,8,10,…,32 给 AES:EditView.dart:769
- 用户输入先走自定义 padding,不是标准 PKCS#7,而是补若干个字符值 padLen*2:EditView.dart:1002
- 然后 UTF-8 编码,AES 加密:EditView.dart:872 EditView.dart:881 EditView.dart:892
- AES 密文再被转成 hex 字符串:EditView.dart:962
- 这个 hex 字符串就是传给 Java invokeMethod 的内容:EditView.dart:926 null_sub0.dart:34
调用了send()发送给java层的invokemethod 然后
密文比较
1 | 8sAFX45zT7uc0vSUyFNNly1h/d5zTt89tV3kcVr5P5n7lRKPyYtxg31zYNB2lPV0c5nf/x2/IK94XV9Ufs9XfaDG5IXxMlZy+Z2nE+ZZRFBSpMoKzQXfUq2TSjJJfQxV |
关于java层和dart层的分工,虽然之前已经提过,这里再解释下
可以把它理解成三层:
- Java/Kotlin 负责 Android 容器和生命周期
- Dart 负责 Flutter 页面、状态和大部分业务逻辑
- Flutter Engine(C++) 负责 真正把 Dart 描述的界面画到屏幕上
这个题里,不是 Dart 触发了 Java 的 onCreate()。顺序反过来:
- Android 系统启动应用
- 系统创建 MainActivity
- 调用 MainActivity.java:48 的 onCreate()
- super.onCreate() 里面会把 Flutter 引擎/视图准备好
- Dart 入口 main.dart:6 的 main() 才开始执行
- Dart 里 runApp() 后,Flutter 页面才真正建立起来
所以结论是:先有 Android 的 onCreate,后有 Dart main()。Dart 不负责启动 Activity,它是被 Flutter 引擎拉起来运行的。
页面到底谁负责显示?这题里两边都参与了,但分工不同:
- Java 负责显示那个原生弹窗。你在 MainActivity.java:57 能看到它直接创建并 show() 了一个 Android 对话框。
- Dart 负责真正的 Flutter 页面,也就是输入框、文案、检查逻辑这些。在 EditView.dart:1 和 main.dart:124 能看到 UI 都在 Dart 里。
更准确地说:
- Activity/窗口 是 Java 的
- Flutter 页面结构 是 Dart 的
- 像素绘制 是 Flutter engine 的,不是 JVM 自己画的
Dart 在 Flutter 里大致起什么作用?Dart 不是辅助脚本,它基本就是 Flutter app 的主代码层。通常负责:
- 页面怎么长
- 按钮点了做什么
- 状态怎么变化
- 网络/校验/业务逻辑怎么跑
- 什么时候调用原生能力
在这题里,Dart 的作用:
- 搭 UI:输入框和结果文案在 Dart 里
- 处理输入:check() 在 EditView.dart:505
- 做一层本地 AES
- 通过 MethodChannel 调 Java:见 null_sub0.dart:26
- 再拿 Java 返回值去比较,决定显示成功还是失败
所以对这题来说可以简单记成:
- Java:Android 壳、生命周期、原生弹窗、MethodChannel 接口、XXTEA 那层
- Dart:Flutter 页面、输入处理、AES、调用 Java、最后比对结果
所以现在是
1 | 硬编码 Base64-> Base64 解码-> XXTEA 解密-> 得到 AES 密文(hex)-> hex 转 bytes-> AES 解密-> 明文 |
1 | def rc4_crypt(key: bytes, data: bytes) -> bytes: |
7a25c524c6334c30f362afac
这里的key还要追加几个常量
真正追加发生在这两处 addAll()
如果 getSym().buffer.asUint8List()[7] == 0xd6,走 EditView.dart:682,然后 EditView.dart:692 的 addAll() 把 fp-0x20 那组尾巴拼到 getKey() 后面。
当前设备/环境里,libart.so 的 ArtMethod::PrettyMethod(bool) 这段机器码的某个字节是不是 0xd6。
这通常是:
- Android 版本差异
- ART 实现差异
- 指令布局差异
- 有时也能顺带做一点反分析/环境区分
也就是说,这更像一个运行环境分支开关,决定后面 AES key 用哪组尾巴
否则走 EditView.dart:726,然后 EditView.dart:736 的 addAll() 把 fp-0x18 那组尾巴拼到 getKey() 后面。
所以会有两个完整 key:
- 7a25c524c6334c30f362afac3f2303d5
- 7a25c524c6334c30f362afac01010405
Native层
AES 魔改分析
aes处魔改
它里面的调用顺序很明确:
- _addRoundKey_291204
- _subBytes_2901b8
- _shiftRows_28ffb4
- _addRoundKey_291204
- _mixColumns_28d8b0
标准 AES 中间轮应该是:SubBytes -> ShiftRows -> MixColumns -> AddRoundKey
而它这里是:SubBytes -> ShiftRows -> AddRoundKey -> MixColumns
1 | __int64 __fastcall magical_girl_aes_crypt_null_safe__Aes::_mixColumns_28d8b0(__int64 a1, __int64 a2) |
发现一大串疑似sbox的东西
其中这些函数
1 | __int64 magical_girl_EditView_MyEditTextState::check_28bea8() |
最重要
包括setkey和mode
加密的数据通过 aesEncrypt_28c6a8 和其他相关函数进行处理,这表明该函数处理的是加密或解密任务。
显然这里肯定会传一个类似实例的东西,借助blutter自带的frida脚本
给了一个blutter_frida.js hook脚本
hook试试
1 | function onLibappLoaded() { |
或者hook这个
getArg(this.context, 0) 更改第二个参数控制传参打印位置
1 | function onLibappLoaded() { |
hook得到
1 | AesCrypt@6f00854529 = { |
aesEncrypt_28c6a8都第一个参数是输入的flag
16字节的那个有点像key
1 | frida -U -f work.pangbai.magic.magical_girl -l blutter_frida.js |
反正现在S盒子S盒逆key ecb都有了可以写代码解密了
sbox 我们将这些值均除以2
flutter的整数貌似都是*2存储的,因为虽然 Dart 中一切皆为
Object,但是实际 Dart 在也是有底层优化的。在 Dart 中最最常见的就是 int 类型。int 分两种类型:
smi: 对于小数值的数字,Dart直接将原数值左移一位。
bigint: 对于大数值,Dart构建对象,其数值在对象偏移+7处。
Flutter 函数调用约定: 参数从右往左入栈,最后入栈对象的this指针
1 | #!/usr/bin/env python3 |
解不出来 是乱码
init_proc
标准的xxtea,不过出不来T.T,出题人提示init_proc,猜测可能是被改掉了 ,而且字符串均被加密了
在libnative_add.so里
这里init_proc() 的行为非常典型
先解析 libart.so 里的目标函数地址
mmap 一块可执行内存
把 shellcode_start_ 那段 trampoline/shellcode 拷进去
把 handler 地址写到 shellcode 里
mprotect 把目标函数所在页改成可写可执行
直接覆盖目标函数开头 16 字节
把原始前 16 字节另存起来,后面还能跳回去
第一组:
- 入口 handler: sub_1A51C
- 收尾/返回 handler: sub_1A6E0
第二组:
- 入口 handler: sub_1A710
- 收尾/返回 handler: sub_1A76C
init_proc() 和 .init_array 是什么
init_proc() 在这里不是 Java 的构造函数,而是 ELF/so 层的初始化函数。
init_array 是 ELF 里的一个段,里面放的是函数指针数组。动态链接器在加载这个 .so 时,会把这些函数按顺序调用一遍。
在 Android 上,System.loadLibrary() / dlopen() 把 so 加载进来后,链接器会先跑这类初始化代码,通常早于 JNI_OnLoad。
所以 .init_array 里的东西常被拿来做:
- 全局状态初始化
- 反调试
- 自修改代码
- 提前 hook
字符串解密代码:
veorq_s8 是 ARM NEON 的一个 intrinsic,意思是对两个 128-bit 向量做按位异或(XOR)
1 | import ida_bytes |
1
2
3
4
5
6
7
8
9
10
11
12
13
14 >libart: b'libart.so'
>libart_str: libart.so
>target1: b'_ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE'
>target1_str: _ZN3art11interpreter6DoCallILb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtbPNS_6JValueE
>target1_fallback: b'_ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE'
>target1_fallback_str: _ZN3art11interpreter6DoCallILb0ELb0EEEbPNS_9ArtMethodEPNS_6ThreadERNS_11ShadowFrameEPKNS_11InstructionEtPNS_6JValueE
>target2: b'_ZN3art9ArtMethod12PrettyMethodEb'
>target2_str: _ZN3art9ArtMethod12PrettyMethodEb
>target3: b'_ZNK3art9OatHeader12IsDebuggableEv'
>target3_str: _ZNK3art9OatHeader12IsDebuggableEv
>needle1: b'gic.fi'
>needle1_str: gic.fi
>needle2: b'gic.toB'
>needle2_str: gic.toB也就是说它不是在 hook 自己的函数,也不是在 hook libc,而是在 hook libart.so 里的运行时函数。
ART = Android Runtime。它是 Android 现在的运行时环境,负责执行 app 的 Dex/OAT 代码。
大致上它管这些:
- 类加载
- 方法调用
- 解释执行
- JIT/AOT
- 垃圾回收
- JNI 交互
可以把它理解成:Android 上 Java/Kotlin 代码真正跑起来时,底下那套运行时系统。早年是 Dalvik,后来换成了 ART。
推断这两个片段大概率就是在匹配 Magic.fixKey 和 Magic.toByteArray。
而 0x1a51c 的行为是:
- 命中 gic.fi 时,保存并修改 DoCall 前 160 字节(后面证实不是这个功能)
- 命中 gic.toB 时,再把那 160 字节恢复回去
会影响前面那条 XXTEA。
hook 了:
- ART 解释器的 DoCall
- ArtMethod::PrettyMethod(bool)
- OatHeader::IsDebuggable()
更关键的是,第一个 hook handler 0x1a51c 会先调用 PrettyMethod 取当前方法名,然后按方法名片段触发动态 patch。它解出来的两个触发子串是:
- gic.fi
- gic.toB
但是这里hook不太好处理
dlopen函数调用完之后.init_xxxx函数已经执行完了,这个时候不容易使用frida进行hook,hook linker的call_function并不容易,这里面涉及到linker的自举。所以这里有一个思路:在.init_proc函数中找一个调用了外部函数的位置,时机越早越好
init_proc@0x1a09c 不是在改 ART 的通用解释器分支逻辑,而是 hook art::interpreter::DoCall,把它当触发器去热补丁当前正在解释执行的 DEX code window。真正被改的是字节码语义,不是DoCall 本体。
在sub_1A51C方法中,可以看到
- 0xb3 -> 0xb2:div-int/2addr -> mul-int/2addr
- 0xe0 -> 0xe2:shl-int/lit8 -> ushr-int/lit8
所以=可以直接概括成:
- 借 Magic.fixKey 进入点触发 patch
- 在后续一小段 Java/Dex 执行窗口里,把除法改成乘法,把左移改成无符号右移
- 到 Magic.toByteArray 再恢复
0xb3 -> 0xb2、0xe0 -> 0xe2正好能对上 DEX opcode,不像 ARM64 机器码 patch
https://android.googlesource.com/platform/dalvik/+/refs/heads/main/opcode-gen/bytecode.txt
这里可以看到dex的opcode,可以静态分析
解密脚本
给出exp:
1 | import base64 |
1 | WMCTF{I_R4@11y_w@n7_70 _84c0m4_@_m@gic@1_Gir1} |
“原生库”是指用 C 或 C++ 编写并编译为
.so文件(Shared Object,共享库) 的代码。它不是 Java 层代码,而是直接运行在 Linux 层 的二进制代码。
在 Android 应用中,如果你用 JNI(Java Native Interface) 调用本地函数,例如:
1 System.loadLibrary("mylib");系统库是 Android 平台本身提供的
.so文件,位于系统分区,比如:
1
2
3
4
5 /system/lib/ 或 /system/lib64/
libc.so
libm.so
liblog.so
libandroid_runtime.so所有的应用库都是原生库,但不是所有的原生库都是应用库。
名称 含义 由谁编写 存放位置 运行层级 原生库 指用 C/C++ 编写、编译成 .so(共享库)的底层二进制库。包括系统的和应用自己的。Android 系统或应用开发者 /system/lib/、/vendor/lib/、/data/app/.../lib/等Linux 层(Native 层) 应用库 指应用自己带的原生库(一个子集),随 APK 打包,用于 JNI 调用。 应用开发者 /data/app/包名/lib/应用进程的 Native 层
Android 7.0 的变化:引入“命名空间隔离”
在 Android 7.0(Nougat)之前,应用加载原生库时,它能看到系统 /system/lib/ 目录下几乎所有 .so 文件。
这带来了严重问题:
- 有些应用会“偷偷”调用 Android 内部未公开的系统库函数(非 SDK 接口);
- 这些接口未被官方支持,系统更新后容易崩溃;
- 同名库(系统库 vs 应用自带库)容易“串库”或冲突。
Android 7.0 起,每个进程(尤其是 app 进程)都有独立的 linker namespace。
这意味着:
- 系统库和应用库在“命名空间”上是隔离的;
- 应用默认只能访问经过允许的系统库(例如
libc.so,libm.so,liblog.so等); - 其他系统内部库(如
libandroid_runtime.so,libbinder.so)对应用是不可见的; - 如果应用想加载这些“私有系统库”,会报错
原生库的命名空间 | Android Open Source Project
Android 7.0 为原生库引入了命名空间,以限制内部 API 的可见性,听起来很高大上,具体解释就是安卓上的动态链接库使用被约束了,普通开发者不管是编译时链接还是使用 dlsym 都不能获取到被限制的 lib 的符号地址, native 开发能调用的 api 被严重限制,同理 Java 也有类似的约束 hiddenapi 用来限制反射的功能。
ssrlive/fake-dlfcn: dlopen/dlclose/dlsym in Android
本题目使用 fake-dlfcn 来查找调用 libart.so 的符号,同时修改了一些代码来适配高版本安卓。这个项目原理比较简单,扫描 proc/self/maps 得到原生库的基址,并通过解析原生库的 elf 符号表得到偏移,再进行计算得到符号地址。
libart.so是什么?
libart.so的全称就是 Android Runtime Library。它是 Android 系统中实现 ART(Android Runtime)虚拟机 的核心共享库。
通俗地说:
libart.so就是 Android 上运行 Java/Kotlin 应用的底层“虚拟机引擎”。
fake-dlfcn是一个“自实现的 dlopen/dlsym”小工具/库,它绕过 Android(尤其是 Nougat 以后的)对dlopen/dlsym访问的限制,允许在受限环境下加载系统.so(比如libart.so)或直接解析 ELF 符号,从而找到/取到那些符号在运行时的地址。Android 7+ 有命名空间限制,应用不能任意
dlopen("/system/lib/libart.so"),系统会把很多内部库对应用隐藏;因此普通dlsym/dlopen不一定生效。
fake-dlfcn提供了替代实现(或直接解析 ELF)来读取/解析系统库的符号表并返回函数地址,或者通过把库映射到进程地址空间再解析符号,从而找到调用libart.so的函数名/地址,便于你在运行时定位与 hook。
解密代码:
1 |
|
1 | WMCTF{I_R4@11y_w@n7_70 _84c0m4_@_m@gic@1_Gir1} |
Frida-dexdump
现在AI太猛了,告诉他哪里有问题直接能分析出来
预期正常解是用frida-dexdump或者其他hook或调试方法去做
在 Android 的解释执行模式(Interpreter Mode)下,Java 字节码中的 invoke-*指令最终就是通过 DoCall函数(及其相关函数)来完成方法调用的执行的。
DoCall 可以理解成 ART 解释器里处理方法调用的核心 helper。根据 AOSP
- interpreter_common.h: https://android.googlesource.com/platform/art/+/dc1dffc/runtime/interpreter/interpreter_common.h
- interpreter_common.cc: https://android.googlesource.com/platform/art/+/5c7cddf609/runtime/interpreter/interpreter_common.cc
1 | // Docall函数定义 |
它大致做这几件事:
1. 从当前 invoke-* 指令里把参数信息拆出来。
2. 确认被调方法 called_method。
3. 根据被调方法创建新的 ShadowFrame。
4. 把调用者寄存器里的参数拷到被调用者的参数寄存器。
5. 必要时做引用类型可赋值检查。
6. 决定继续走解释器,还是桥接到已编译代码。
7. 执行调用,把返回值写进 result,异常则返回 false。
几个参数的意义:
- called_method: 已解析出的目标方法
- self: 当前线程
- shadow_frame: 当前解释器栈帧
- inst: 当前这条 DEX invoke-* 指令
- inst_data: 指令的附带解码数据
- result: 返回值槽
DoInvoke 最后会调到 DoCall,所以它是每次解释执行方法调用的好观察点。
它同时拿得到:
- called_method
- 当前 inst
hook 它有两个好处:
- 能拿到当前被调用的方法 ArtMethod*
- 能拿到当前调用点对应的 Instruction*,也就是当前 DEX 指令位置
所以出题人的 so 才会 hook DoCall,再配合 PrettyMethod 判断当前是不是 Magic.fixKey / Magic.toByteArray 附近,然后去改当前解释路径对应的 DEX code window。
也就是说,在这题里 DoCall 的作用不是藏加密算法,而是提供一个稳定的 runtime 截获点。
libnative_add.so 的 init_proc() 在 so 加载时自动执行。它做的是:
1. 解密出 libart.so 和几个 ART 符号名
2. 在 libart.so 里解析到目标函数地址
3. mmap 一块可执行内存,拷自己的 trampoline/shellcode
4. 保存目标函数前 16 字节原始指令
5. mprotect 把目标页改成可写
6. 把目标函数入口改成跳到我的 shellcode
思路有进入 gic.toB 分支前将 dex dump下来,要么去 trace Smali,对比看看他改了什么,当然最简单的思路是作者给出的。
第二次 strstr 后的还原逻辑 NOP 掉
这样,dex 文件就不会被改回去了
windows上操作不区分大小写解包时候会有资源文件发生同名替换的问题
1 | java -jar apktool_3.0.0.jar d Want2BecomeMagicalGirl.apk |
1 | ./hs -l 0.0.0.0:9999 |
dump前
dump后
其他小知识
这道题不得不说出的非常好
from Want2BecomeMagicalGirl出题笔记-WMCTF2025 - 逆向工程 - CTF | PangBai’s Blog = 𝕺𝖚𝖗 𝕷𝖎𝖋𝖊 = 旁白博客
hook OatHeader::IsDebuggable(),不是为了直接改加密逻辑,而是为了把 Java 执行路径尽量拽回解释器,这样前面 hook 的 DoCall 才有机会触发。
可以分成 3 层理解。
为什么单 hook DoCall 不够,DoCall 是 art::interpreter::DoCall,名字就说明了它是解释器路径里的函数。但 Release 包正常运行时,很多 Java 代码不会走解释器,而是走AOT或 JIT 后的编译代码
一旦直接跑编译后的 native 代码,就不会经过解释器里的 DoCall。所以只 hook DoCall,程序很可能根本踩不到
解释执行就是:ART 不把字节码提前翻译成机器指令,而是一条一条读取 dex bytecode,再逐条解释运行。
比如 Java 里的一个方法调用,解释器执行到
invoke-*这类字节码时,可能就会进入解释器内部的调用逻辑,例如art::interpreter::DoCall。所以- 走解释器 → 有机会经过
DoCall - 不走解释器 → 就根本不会到
DoCall
AOT 是 Ahead-Of-Time compilation,即提前编译。
意思是:在应用安装时、系统启动阶段,或者某些预编译流程里,ART 会把 dex/字节码直接编译成目标平台的机器码。这样程序运行时就可以直接执行机器指令,而不是再逐条解释字节码。
你可以把它理解成:
- 原本:运行时看到一条 dex 指令,就解释一条
- AOT:运行前就把整段方法翻译成 ARM/x86 机器码了,运行时直接跳进去执行
所以一旦某个 Java 方法已经被 AOT 编译:
- CPU 直接执行那段机器码
- 不再由解释器逐条处理
invoke-* - 自然也不会经过解释器里的
DoCall
JIT 是 Just-In-Time compilation,即即时编译。
它和 AOT 的区别主要在“编译发生的时间”:
- AOT:运行前就编译好
- JIT:程序运行过程中,ART 发现某些方法很热点、调用很多次,就把它们动态编译成机器码
也就是说,某个方法一开始可能是解释执行,但跑着跑着,ART 觉得它太常用了,就把它编译掉。之后再调用这个方法时,就直接跑编译后的机器码了。
于是就出现这种情况:
- 前几次调用可能经过解释器,能踩到
DoCall - 后面变成 JIT 代码后,就不再经过
DoCall
这也是为什么只 hook
DoCall,实际命中率可能很低,甚至完全打不到。DoCall属于解释器处理 method invoke 字节码时的辅助逻辑,而 AOT/JIT 编译后的代码已经不是“解释字节码”了,而是“直接执行机器码”。解释器路径大概像这样:
1
dex bytecode -> interpreter switch/dispatch -> 遇到 invoke -> DoCall -> 目标方法
而编译执行路径更像:
1
dex bytecode -> 编译成 native code -> 运行时直接跳到机器码入口 -> 执行
Release 包通常会更偏向性能优化,因此更容易出现:
- 方法已被 AOT 编译
- 热点方法被 JIT 编译
- 内联优化,把方法调用直接展开
- 一些桥接/调用路径被优化掉
执行方式 来源 特点 AOT .oat安装时编译,直接执行 Interpreter .dex逐条解释执行 JIT .dex运行时编译热点代码 - 走解释器 → 有机会经过
IsDebuggable() 在这里是干什么的,OatHeader::IsDebuggable() 是 ART 在处理 oat/odex 时会参考的一个标志函数。直观理解就是:这个 oat 是不是按可调试/可插桩/不那么激进优化的方式对待?
作者的做法是 hook 它,让 ART 在运行时误以为当前这套 oat/执行环境更偏 debuggable。这样带来的实际效果,作者自己也说了,是实验验证出来的:
所以他的真实意思不是我已经完全证明它精确关闭了哪条 JIT/AOT 分支,而是通过 hook IsDebuggable(),成功把运行时执行策略改到了一个更容易进入解释器/可观测路径的状态。
值得注意的是如果你将 Apk 编译为 Debug 将不会触发 AOT ,但或许会触发 JIT
OatHeader::IsDebuggable()本质上不是一个直接关掉 JIT/AOT 的总开关,而是 ART 读取 oat/odex 头部元数据时,用来判断这份 oat 是否按 debuggable 语义生成/使用 的一个标志位读取函数。AOSP 里它本身非常简单,就是从 oat header 的 key-value store 里检查kDebuggableKey是否启用。更关键的是,这个标志会影响 ART 是否愿意使用某些优化后的代码路径。例如在 ART 的 instrumentation 代码里,AOSP 明确写着:在 debuggable 模式下,只能对 native 方法使用 AOT code,也就是说对普通 Java 方法,会更倾向于不用那些高度优化的 AOT 执行路径
hook
IsDebuggable()之后,运行时对这份 oat 的判断发生了偏转;这个偏转 改变了 ART 的执行策略,使其更接近 debuggable 场景;
结果上表现为:更容易进入解释执行/可观测路径,或者减少某些优化代码路径的使用。
其实并不是不能让 APP 走 Interpreter,而是 Android 设计上不会长期用 Interpreter 运行应用代码。
Java 代码:
1 a = b + c如果是 AOT / JIT:
CPU直接执行机器码:
1
2 mov r0, r1
add r0, r2只需要 几条指令。
但如果是 Interpreter:
ART需要:
- 读取 dex bytecode
- decode opcode
- 查找 handler
- 执行 handler
- 更新寄存器状态
- 再读取下一条
流程类似:
1
2
3
4 fetch opcode
switch(opcode)
execute handler
update state一条 dex 指令可能需要 几十条甚至上百条机器指令。
其他解法
Smali trace,看到同一个 method 同一个 code offset 实际执行的 opcode,就能直接对比出哪里变了。
记录一些项目以后学习
SeeFlowerX/frida-smali-trace: smali trace[未复现成功]
CE 断点 + Dump 内存。只要恢复之前断下来,内存里就存在改过的 dex。
大致流程是:
- 附加到进程。
- 在 patch 点下断点。常见下法:
- 下在 sub_1A51C 里 patch 循环开始/结束处
- 或下在 restore 分支前
- 或直接监控那片 DEX 内存的写入
- 触发一次输入,让 patch 生效。
- 在 restore 前暂停。
- 把对应内存区域 dump 出来。
- 再按 dex header 修复/切块,得到修改后的 dex。























