首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
11
10
[原创]ART 底层执行链:从 ArtMethod::Invoke 看 FART 在 Android 12–16 为什么失效
发表于: 2026-8-5 20:56
35341
[原创]ART 底层执行链:从 ArtMethod::Invoke 看 FART 在 Android 12–16 为什么失效
FinSectech
2026-8-5 20:56
35341
**ART 运行时深层结构完全解析** **——从 ArtMethod、ClassLinker 到 Invoke 全链路,兼谈 FART 在 Android 12–16 的失效与修复** 如果你已经拿 FART 脱过壳、或者自己写过主动调用,那这篇应该能直接对上。 前面不废话, 背景尽量压短,后面把 `Invoke` 分流、Nterp、dump 点选择、高版本失效和现在怎么处理写细一点,代码也会多放一些。 --- 先把认为最重要的东西放在这里 以下 `ArtMethod` 只是入口。真正决定主动调用能不能脱到东西的,是这四个问题同时成立: 1. 当前方法的 Entry Point 是什么 2. `ArtMethod::Invoke` 会不会把你带进能看到 CodeItem 的路径 3. 壳的恢复窗口和你的观察窗口有没有重合 4. dump 到的东西最后能不能修回去 FART 早期能打,是因为这四个问题当时相对好对齐。Android 12 之后开始大面积失效,不是单纯工具老了,而是执行路径、结构布局、存储限制、Profile/AOT、壳对抗一起变了。 --- ### 背景只留必要的 `ArtMethod` 里真正关键的是三块: 身份:class、method index、access flag 代码位置:早期靠 `dex_code_item_offset_`,后面更多走 `DexFile` / `CodeItemDataAccessor` 执行入口:`entry_point_from_quick_compiled_code_` 调用时真正跳出去的,通常是最后一个。 所以会有这种误判: ArtMethod 找到了,方法也调用了,CodeItem 却还是空的。 不一定是 dump 代码写错,更可能是你看的入口和壳恢复时用的不是同一条路。 `ClassLinker` 负责 load/link/init。很多抽取壳不是 loadClass 后立刻恢复,而是拖到第一次真正执行前。 `DexCache` 则让“文件镜像里的 Dex”和“运行时真正用到的解析结果”可能不一致。所以只 dump 整体 Dex 不够,还得单独拿 CodeItem。 执行后端现在也不单一: | 后端 | 含义 | 直接后果 | |------|------|----------| | AOT | 提前编译 | 可能根本不进解释器 | | JIT | 运行时编译 | 入口会中途切换 | | Nterp | 快速解释 | 仍解释,但不保证经旧 `Execute` | | Switch Interpreter | 传统解释 | 更接近 FART 老点 | | JNI trampoline | native | 另一套逻辑 | 还按“禁用 dex2oat 后全部长期解释”来设计方案,在高版本里前提已经不稳。 --- ### Entry Point 比方法名优先看 主动调用前,先看: ```text ArtMethod::GetEntryPointFromQuickCompiledCode() ``` 对应到代码里,大致就是: ```cpp const void* ArtMethod::GetEntryPointFromQuickCompiledCode() const { return GetEntryPointFromQuickCompiledCodePtrSize(kRuntimePointerSize); } // 读的是 ptr_sized_fields_ 里那一项 // EntryPointFromQuickCompiledCodeOffset(pointer_size) ``` 这个指针可能是: AOT/JIT 机器码 `art_quick_to_interpreter_bridge` Nterp 入口 Generic JNI stub Resolution trampoline Instrumentation stub 它不是静态常量。`ClassLinker::LinkCode`、`Instrumentation::InitializeMethodsCode`、JIT 完成、deoptimize,都可能改它。 再叠加这些 flag: ```cpp bool ArtMethod::HasNterpEntryPointFastPathFlag() const { constexpr uint32_t mask = kAccNative | kAccNterpEntryPointFastPathFlag; return (GetAccessFlags() & mask) == kAccNterpEntryPointFastPathFlag; } void ArtMethod::SetNterpInvokeFastPathFlag() { AddAccessFlags(kAccNterpInvokeFastPathFlag); } ``` 只打印 `ArtMethod*`、不看 entry 和 flag,后面很容易盲排。 一个很具体的例子: 在 Android 14 Pixel 7 上,有个被加固的 `onCreate`,当时打印出来的 entry 是 `0x70b1c8a000`,反解后落到 `art_quick_to_interpreter_bridge`。按 FART 老路子主动调用,CodeItem 始终没恢复。 第一反应是 dump 时机不对,或者 `self == nullptr` 那个标记没进分支。后来开了 `--force-interpreter` 再调,entry 切到了 Nterp,以为这次稳了,结果 `Execute` 还是没进——Nterp 不走老 `Execute`。 最后把 dump 点从 `Execute` 往上提到 `EnterInterpreterFromInvoke`,才拿到完整 CodeItem。 排查时建议直接把关键信息打全: ```cpp void DumpMethodState(ArtMethod* m) { LOG(INFO) << "method = " << m->PrettyMethod(); LOG(INFO) << "ArtMethod* = " << m; LOG(INFO) << "access_flags = 0x" << std::hex << m->GetAccessFlags(); LOG(INFO) << "entry = " << m->GetEntryPointFromQuickCompiledCode(); LOG(INFO) << "is_native = " << m->IsNative(); LOG(INFO) << "is_static = " << m->IsStatic(); } ``` 实用判断: entry 已是编译代码,主动调用大概率碰不到解释器恢复逻辑 entry 是 bridge,还有空间,但别默认后面一定进老 `Execute` entry 变成 Nterp 后,老 `Execute` hook 可能直接失效 调用前后 CodeItem 完全不变,优先怀疑路径,不先改文件名和输出目录 --- ### `ArtMethod::Invoke`:真正该啃的主干 #### 主干分流 结合 AOSP 常见实现,主干可以写成: ```cpp void ArtMethod::Invoke(Thread* self, uint32_t* args, uint32_t args_size, JValue* result, const char* shorty) { if (UNLIKELY(__builtin_frame_address(0) < self->GetStackEnd())) { ThrowStackOverflowError(self); return; } ManagedStack fragment; self->PushManagedStackFragment(&fragment); Runtime* runtime = Runtime::Current(); // FART 老插入点大致就在这附近 // if (self == nullptr) { // dumpArtMethod(this); // return; // } if (UNLIKELY(!runtime->IsStarted() || (self->IsForceInterpreter() && !IsNative() && !IsProxyMethod() && IsInvokable()) || /* debugger / fake-invoke */)) { if (IsStatic()) { art::interpreter::EnterInterpreterFromInvoke( self, this, nullptr, args, result, /*stay_in_interpreter=*/true); } else { mirror::Object* receiver = reinterpret_cast<StackReference<mirror::Object>*>(&args[0])->AsMirrorPtr(); art::interpreter::EnterInterpreterFromInvoke( self, this, receiver, args + 1, result, /*stay_in_interpreter=*/true); } } else { bool have_quick_code = GetEntryPointFromQuickCompiledCode() != nullptr; if (LIKELY(have_quick_code)) { if (!IsStatic()) { (*art_quick_invoke_stub)(this, args, args_size, self, result, shorty); } else { (*art_quick_invoke_static_stub)(this, args, args_size, self, result, shorty); } } } } ``` 主要来说有三 1. 会不会强制解释 2. 有没有 quick entry 3. quick entry 是真机器码,还是 bridge / nterp / stub FART 早期靠这个标记: ```cpp extern "C" void myfartInvoke(ArtMethod* artmethod) REQUIRES_SHARED(Locks::mutator_lock_) { JValue* result = nullptr; Thread* self = nullptr; // 特殊标记 uint32_t temp = 6; uint32_t* args = &temp; uint32_t args_size = 6; artmethod->Invoke(self, args, args_size, result, "fart"); } ``` 早期好用,是因为大多数被抽空方法最终还会掉进可观察的解释路径,Invoke 又足够靠前。 高版本继续只靠这个标记会越来越飘:调用表面上成功,实际执行已经从 quick entry 出去了。 #### EnterInterpreterFromInvoke 进解释器后,更关键的一层其实是这里,不是老的 `Execute` 单点: ```cpp void EnterInterpreterFromInvoke(Thread* self, ArtMethod* method, ObjPtr<mirror::Object> receiver, uint32_t* args, JValue* result, bool stay_in_interpreter) { // 1. 静态方法可能先 EnsureInitialized // 2. 构造 ShadowFrame // 3. 把参数填进 shadow frame // 4. native -> InterpreterJni // 5. 非 native -> Execute(...) 或 Nterp 相关入口 } ``` 也就是说我们已经知道了 当前 `ArtMethod*` 方法是否静态 参数区 后续到底会进传统解释,还是被更快的解释后端接走 前面 Pixel 7 那个案子,真正转机不是又找了一个更花哨的 hook,而是 dump 点从 `Execute` 上移到了这一层。 一个可落的插法: ```cpp void EnterInterpreterFromInvoke(...) { // 主动调用或强制解释时,先看一眼当前方法状态 if (/* is_fake_invoke || need_dump */) { dumpArtMethod(method); } // 原逻辑:建 ShadowFrame、填参、Execute / Nterp / JNI } ``` 这比死钉 `Execute` 更能兜住“已经进解释体系,但不走老 Execute”的情况。 #### Execute 传统 FART 会盯 `Execute`,原因很多时候: 干扰 dex2oat 后,不少方法仍解释执行 `<clinit>` 相对稳定走解释 这里能同时碰到 `ArtMethod` 和当时的 `CodeItem` 老插法通常是: ```cpp static inline JValue Execute(Thread* self, const DexFile::CodeItem* code_item, ShadowFrame& shadow_frame, JValue result_register, bool stay_in_interpreter = false) { ArtMethod* method = shadow_frame.GetMethod(); if (strstr(method->PrettyMethod().c_str(), "<clinit>") != nullptr) { dumpDexFileByExecute(method); } // 原解释逻辑 // switch interpreter / mterp ... } ``` 整体 Dex dump 也可以放在这附近: ```cpp void dumpDexFileByExecute(ArtMethod* artmethod) { const DexFile* dex_file = artmethod->GetDexFile(); const uint8_t* begin = dex_file->Begin(); size_t size = dex_file->Size(); // 写 begin ~ begin+size // 再顺手把 class list 打出来 } ``` 但 Nterp 把“解释执行 = 进旧 Execute”这个等式打断了。 #### Nterp Nterp 仍然解释字节码,入口却可以直接挂在 quick entry 上,调用约定更接近编译代码。 结果就是: ```text force interpreter → entry 变成 Nterp → 老 Execute hook 不触发 → CodeItem 看起来像没恢复 ``` 其实不一定是没恢复,更可能是你观察点没盖住这条路。 所以现在更稳的判断不是“有没有进 Execute”,而是: 1. entry 现在是什么 2. 有没有进入 `EnterInterpreterFromInvoke` 3. 进入之后 CodeItem 有没有变 #### quick stub Invoke 一旦认为有 quick code,就进: ```text art_quick_invoke_stub / art_quick_invoke_static_stub → entry_point_from_quick_compiled_code_ ``` 对应关系可以粗写成: ```cpp // stub 收到 ArtMethod* 后,最终会跳到: const void* code = method->GetEntryPointFromQuickCompiledCode(); // brx / blr code ``` 但: `have_quick_code == true` 不等于“已经是最终机器码”。 后面可能是 bridge,也可能是 nterp。看到 quick entry 就当 AOT,会直接排错方向。 --- ### CodeItem 怎么拿,为什么长度计算经常写错 主动调用如果只是“调了一下”,却没有把 CodeItem 正确摘出来,后面修复照样废。 简化版 dump 逻辑: ```cpp void dumpArtMethod(ArtMethod* artmethod) { const DexFile* dex_file = artmethod->GetDexFile(); // 1. 整体 Dex const uint8_t* begin = dex_file->Begin(); size_t dex_size = dex_file->Size(); // write(begin, dex_size) // 2. 方法体 const dex::CodeItem* code_item = artmethod->GetCodeItem(); if (code_item == nullptr) { LOG(INFO) << "CodeItem is null: " << artmethod->PrettyMethod(); return; } // 3. 算真实长度 // 不能只按 sizeof(CodeItem) 盲切 uint32_t code_item_len = ComputeCodeItemSize(code_item); uint32_t method_idx = artmethod->GetDexMethodIndex(); // 写出 method_idx / offset / code_item_len / ins bytes } ``` 长度计算是脏活,也是修失败的高发区。思路大致是: ```text CodeItem ├─ registers_size / ins_size / outs_size / tries_size ├─ insns[] ├─ try_item[] // 如果 tries_size > 0 └─ encoded_catch_handler_list ``` 伪代码: ```cpp uint32_t ComputeCodeItemSize(const dex::CodeItem* code_item) { const uint8_t* base = reinterpret_cast<const uint8_t*>(code_item); const uint8_t* p = base; // 跳过 header + insns // insns 是 u2 数组,长度是 insns_size // 若 tries_size > 0,后面还有 try_item 和 handler if (code_item->tries_size_ > 0) { // 对齐到 4 字节 // 跳过 try_item[tries_size_] // 再按 LEB128 解析 encoded_catch_handler_list } return static_cast<uint32_t>(p - base); } ``` 很多“bin 有了但修回去反编译仍坏”的问题,不是主动调用没触发,而是这里少算了 handler 段。 --- ### FART 闭环为什么成立,也为什么后来不够用 FART 强在三步闭环,不在单点 hook: 1. 整体 Dex dump 2. 主动调用后 dump CodeItem 3. 用 bin 回补 Dex 并验证可解析 Java 侧主动调用链,常见是从 ClassLoader 枚举下去: ```java // 伪代码 Object pathList = getField(classLoader, "pathList"); Object[] dexElements = (Object[]) getField(pathList, "dexElements"); for (Object element : dexElements) { Object dexFile = getField(element, "dexFile"); Object cookie = getField(dexFile, "mCookie"); String[] classNames = getClassNameList(cookie); for (String name : classNames) { Class<?> clazz = classLoader.loadClass(name); for (Method m : clazz.getDeclaredMethods()) { dumpMethodCode(m); // -> native -> myfartInvoke(ArtMethod*) } } } ``` native 再转: ```cpp static void DexFile_dumpMethodCode(JNIEnv* env, jclass, jobject method) { if (method == nullptr) return; ArtMethod* artmethod = ArtMethod::FromReflectedMethod(...); myfartInvoke(artmethod); } ``` 到了高版本,这套闭环还在,但“调用一定能把真实 CodeItem 暴露出来”不再自动成立。路径不对,后面全白做。 --- ### Android 12–16 失效,按根因排 #### 1. 路径变了 旧路径: ```text 主动调用 → Invoke → 解释器 → Execute → 恢复/dump ``` 现在常见: ```text 主动调用 → Invoke → quick entry → Nterp / AOT / JIT / bridge ``` 桥接和 Nterp 特别容易制造假象: 看到 `art_quick_to_interpreter_bridge`,以为稳了 force interpreter 后看到 Nterp,又以为稳了 两边都可能不进老 `Execute` Pixel 7 上那个 `onCreate` 就是标准复现: ```text entry = 0x70b1c8a000 -> art_quick_to_interpreter_bridge -> 老 FART 调,CodeItem 不变 --force-interpreter -> entry 切到 Nterp -> Execute 不进 dump 点上移到 EnterInterpreterFromInvoke -> CodeItem 完整 ``` #### 2. 布局变了 `ArtMethod` 大小、字段布局、pointer-sized fields 一直在变。 写死 Android 8/10 offset 的脚本,到 12 后读错是常态。 与其写死,不如运行时探: ```js // Frida 思路伪代码 const artMethod = ptr(methodAddr); const accessFlags = artMethod.add(accessFlagsOff).readU32(); const quickCode = artMethod.add(quickCodeOff).readPointer(); console.log('flags=', accessFlags.toString(16), 'entry=', quickCode); ``` CodeItem 获取也建议做成多版本后端,不要假设某一个固定位移永远能取到指令体。 #### 3. 存储变了 `/sdcard/fart/...` 在 Scoped Storage 后经常直接写失败。 表现很误导:日志像跑完了,目录却是空的。 更稳的路径一般是: ```text /sdcard/Android/data/<pkg>/files/fart/ ``` 写文件前先把返回值打出来: ```cpp int fd = open(path.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0644); if (fd < 0) { LOG(ERROR) << "open failed: " << path << " errno=" << errno; return; } ``` #### 4. 壳对抗变了 现在更常见: 垃圾类一初始化就退 检测异常 ClassLoader 遍历 / 反射调用 破坏内存 Dex 头 恢复时机压到真实执行点 识别固定线程名、固定路径、固定 so 所以全量主动调用本身就可能成为触发器。 配置化会务实很多: ```text dump=true sleep=60000 force=com.target.* ignore=androidx.*,com.google.*,kotlin.,kotlinx. ``` #### 5. 编译策略变了 同一 App,刚装完和跑过一段时间后,entry 状态可以不同。 有没有 profile、ART 有没有 Mainline 更新,都会改结果。 “我在 Android 14 上试过”信息量不够,得补当前 entry 类型和编译状态。 --- ### 现在更有用的修法 版本自适应先做: 探测 `access_flags` / `quickCode` / `jniCode` 识别 entry 类型 CodeItem 读取做成多版本后端 主动调用改成可控配置,不先全量扫。 脱壳点不要只留一个: | 点位 | 作用 | |------|------| | `EnterInterpreterFromInvoke` | 比老 `Execute` 更靠前,能兜住 Nterp 前后的解释入口 | | 解释器 / Nterp 附近 | 看解释态 CodeItem | | `Invoke` 特殊分支 | 主动调用触发 | | ClassLinker / DexFile 加载 | 整体 Dex | | Frida 枚举 ClassLoader | 补动态加载和遗漏 loader | 一个更稳的顺序是: ```text 1. 打印 entry / flags 2. 必要时 force interpreter 3. 再看 entry 变成了什么 4. 决定 dump 点放哪 5. 小范围 force 调用 6. 对比调用前后 CodeItem 7. 修复并反编译验收 ``` 修复得要验收: Dex 头有没有坏 method_idx` 对不对 tries/catch 有没有算对 修完能否被 jadx/baksmali 正常解析 --- ### 碰到未知抽取壳时怎么做 我现在一般不急着上全量主动调用。 先看整体 Dex 在不在内存里。主体都还没解密,方法体先别谈,否则后面全是空转。确认主体在了,再抽几个方法看 Entry Point,不用多,三五个就行:业务入口一个,壳相关工具类一个,再挑一两个明显被抽空的。把这些记下来就够了: entry 值 是不是 bridge 是不是 nterp 是不是已经编译 调用前后 CodeItem 有没有变化 这里有几个坑是反复踩过的。 entry 如果是 `art_quick_to_interpreter_bridge`,别默认后面一定会进老 `Execute`。Android 14 Pixel 7 上那个加固 `onCreate` 就是这样,entry 打出来是 `0x70b1c8a000`,反解到 bridge,按 FART 老路子调,CodeItem 一直不变。后来开 `--force-interpreter`,entry 切到 Nterp,以为稳了,结果 `Execute` 还是没进。Nterp 根本不走那条老路径。最后把 dump 点从 `Execute` 提到 `EnterInterpreterFromInvoke`,才拿到完整 CodeItem。 所以后面我基本按这个习惯处理: 调用前后 CodeItem 完全不变,先查路径,别先去改输出目录。 force interpreter 之后如果变成 Nterp,而 hook 还钉在 `Execute`,优先上移观察点。 进程一全量调用就没,先把 force 列表收窄,排查垃圾类。 日志有、文件没有,先看存储权限和目录是不是根本没写成。 小范围 force 能稳定出 bin 了,再扩。修完一定拿 jadx 或 baksmali 验一下,目录里有文件不算成功。 源码的话,优先翻这些就行: `art/runtime/art_method.cc` `art/runtime/art_method.h` `art/runtime/class_linker.cc` `art/runtime/instrumentation.cc` `art/runtime/interpreter/interpreter.cc` `art/runtime/interpreter/interpreter_common.h` `art/runtime/entrypoints/...` 读的时候别铺太开,就盯四个问题:entry 是谁设置的,`Invoke` 怎么分流,解释器和 Nterp 怎么接上,哪个位置能稳定看到恢复后的 CodeItem。 高版本继续做抽取壳,已经不是再找一个更靠前的 hook 点就完事了。先看当前方法走哪条执行后端,再决定主动调用要把它往哪条路上逼,最后才是 dump 和修复。路径没对上,后面写再多 dump 代码也没用。
登录后可查看完整内容
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
收藏
・
11
点赞
・
10
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
北袅
感谢你的积极参与,期待更多精彩内容!
2026-9-29 10:41
零_721409
你的帖子非常有用,感谢分享!
2026-9-3 13:52
cr_lgdx
这个讨论对我很有帮助,谢谢!
2026-8-31 14:05
wx_funcrever
感谢你的积极参与,期待更多精彩内容!
2026-8-7 10:48
sinker_
你的帖子非常有用,感谢分享!
2026-8-7 01:02
岁月。
这个讨论对我很有帮助,谢谢!
2026-8-6 12:03
东方玻璃
感谢你的贡献,论坛因你而更加精彩!
2026-8-6 11:55
梧桐生
非常支持你的观点!
2026-8-6 11:30
pexillove
非常支持你的观点!
2026-8-6 08:38
欲拭泪
感谢你的积极参与,期待更多精彩内容!
2026-8-6 00:14
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
5
)
iBa0
雪 币:
1901
活跃值:
(5923)
能力值:
( LV4,RANK:40 )
在线值:
发帖
6
回帖
490
粉丝
37
关注
私信
iBa0
2
楼
666
2026-8-6 11:32
0
Imxz
雪 币:
110
活跃值:
(9571)
能力值:
( LV2,RANK:10 )
在线值:
发帖
6
回帖
775
粉丝
9
关注
私信
Imxz
3
楼
tql
2026-8-6 16:55
0
xuebook
雪 币:
20
活跃值:
(290)
能力值:
( LV2,RANK:10 )
在线值:
发帖
1
回帖
18
粉丝
0
关注
私信
xuebook
4
楼
感谢分享!
2026-8-6 19:33
0
嘎嘎真的很棒
雪 币:
716
活跃值:
(3812)
能力值:
( LV3,RANK:30 )
在线值:
发帖
8
回帖
161
粉丝
7
关注
私信
嘎嘎真的很棒
5
楼
严肃学习
2026-8-13 16:58
0
北袅
雪 币:
2650
活跃值:
(3244)
能力值:
( LV7,RANK:100 )
在线值:
发帖
6
回帖
49
粉丝
45
关注
私信
北袅
2
6
楼
学习了
2026-9-29 10:41
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
FinSectech
6
发帖
2
回帖
20
RANK
关注
私信
他的文章
逆向已死:GPT-6-Astra 通杀传统CTF
507
[原创]当壳遇上LuaJIT字节码魔改:某游逆向
896
[原创]ART 底层执行链:从 ArtMethod::Invoke 看 FART 在 Android 12–16 为什么失效
35333
[原创]编译AOSP时解决flex版本兼容问题:从报错到一招“狸猫换太子”完美绕过
2228
[原创]一次尝试某某会APP签名算法逆向追踪:从抓包到SO层
53706
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部