首页
社区
课程
招聘
[原创]复现并修掉ART hook框架 Pine 调用原方法时的偶发 SIGSEGV
发表于: 1天前 717

[原创]复现并修掉ART hook框架 Pine 调用原方法时的偶发 SIGSEGV

1天前
717

Pine(canyie/pine)是目前用得比较多的 ART 方法 hook 框架。它有一个老问题:调用被 hook 方法的原实现时,偶发 native SIGSEGV,概率性、堆栈不固定、重启可能就好。上游源码在出事的那一行留了 FIXME,但一直没修:

本文做三件事:把这个崩溃在真机上确定性复现、拿到崩溃栈、定位到具体那一次内存读;分析根因,并说明几条看起来能修、其实不行的路;给出修法,换上修复版重新跑、拿到不崩的日志。修复已合入 Pine 的 fork(taisuii/tine)。

测试环境:Pixel 6 Pro / Android 16(API 36)/ arm64-v8a。Android 13+ 默认 GC 是 userfaultfd 的 CMC(Concurrent Mark Compact),会搬动对象,正好命中。开机日志可见 Using CollectorTypeCMC GC.

不铺垫原理后面看不懂,但只讲后面要用到的。

1)backup 方法,以及它为什么“游离”在 GC 视野之外。
Tine 走方法替换:把目标方法的 ArtMethod 入口指向自己的 trampoline,同时克隆一份原方法叫 backup,你调原实现时跑的就是它。关键在这份克隆怎么来的(core/src/main/cpp/art/art_method.h):

它是 malloc 出来的裸内存,既不在 ART 托管堆上,也不挂在任何类的方法数组里。换句话说,运行时根本不知道有这么一个 ArtMethod 存在——这一点后面是核心。

2)declaring_class 是一个 32 位压缩 GcRoot。
ArtMethod 里有 declaring_class,指向方法所属的 mirror::Class。ART 中堆引用普遍用 32 位压缩引用存储,所以它实际是个 uint32_t,native 侧就是按 uint32_t 读写:

GcRoot 的含义是:GC 在回收/压缩时会遍历所有 root 并就地修正它们。但前提是这个 root 能被 GC 扫描到。真实方法的 declaring_class 能被扫到(下面讲路径),游离的 backup 扫不到。

3)移动 GC 与安全点。
“移动式 GC”会在回收时搬动存活对象来压缩内存,对象地址因此改变,所有指向它的引用都要被同步修正。Android 8~12 默认 CC(并发拷贝),13+ 默认 CMC(并发标记-压缩),都会搬;4.4 及以下不会搬。并发 GC 不能在任意指令处搬对象,它要等线程到达安全点(方法调用、分配、循环回边、JNI 转换等)才动手。“移动只发生在安全点”这条性质,是后面修复能成立的支点。

逻辑很简单:hook 一个静态方法 victim,然后在堆分配压力下反复调它的原实现。每次调用都会走一遍 callBackupMethod,也就是崩溃窗口。

victim 是静态方法,它的 declaring class 就是 GcBugReproActivity——一个应用类,位于可移动空间,会被 moving GC 搬动。

光靠分配压力撞 GC 是概率性的。为了每次必中,复现构建把 callBackupMethod 还原成上游 Pine 的原始写法,并按 FIXME 的提示在窗口里强制一次 GC:

这样每次 backup 调用都精确地“补写最新地址 → 立刻把类搬走 → 再去用它”,命中率 100%。

装上复现构建,am start 拉起,进程秒崩。logcat(已裁剪):

逐帧读:#06 Method_invoke#05 InvokeMethodMethod.invoke 的 native 实现;它在真正执行前要做类初始化检查 #04 EnsureInitialized#03 InitializeClass,期间去取类名 #02 PrettyClass#01 PrettyDescriptor#00 GetDescriptor,在这里读了一个坏掉的 Class* 而崩。

再看寄存器:fault addr 0x10x2 = 0x10,是在一个近乎为空的 Class* 上读偏移 0x10x3 = 0x656800656e696c5f 按小端解出来是 _line\0he 这样的字符串字节——说明这个 Class* 指向的内存已经被搬走/释放、又被填进了别的数据。野指针读,证据确凿。

崩溃前那行 Explicit concurrent mark compact GC 就是我们强制的 Runtime.getRuntime().gc() 触发的一次 CMC 移动压缩。时间线完全对上。

图1:移动 GC 如何让 backup 的 declaring_class 变成野指针

为什么真实方法没事、backup 出事? 因为 GC 修正 declaring_class 是靠遍历 root,而 root 只有两类路径能覆盖到一个 ArtMethod:一是它所属类的方法数组(GC 扫到类时会顺带访问类里每个方法的 declaring_class root),二是活动栈帧(正在执行的帧上的方法会被栈扫描访问到)。真实方法挂在类的方法数组里,所以类一搬动它就被同步修正;而 backup 是 malloc 出来、不挂任何类、当时也没在栈上执行——两条路径都不覆盖它,于是它的 declaring_class 在压缩后变成指向旧地址的野指针。

窗口在哪? 上游用一次调用前补写来掩盖:syncMethodInfo 把真实方法当前的 declaring_class 抄进 backup,然后 backup.invoke。问题就在这两步之间Method.invoke 的路径很长、安全点密集(参数装箱、数组分配、类初始化检查),补写完、还没真正进 backup 栈帧时,任意一个安全点触发移动 GC,类被搬走,紧接着 EnsureInitialized 去读 declaring_class——就是第三节那条崩溃栈。

一个关键观察: backup 一旦真正跑在栈帧上就安全了,因为栈扫描会就地修正帧上方法的 declaring_class。所以真正危险的,只有“补写完”到“backup 栈帧对栈扫描可见”这一小段;而移动只发生在安全点。结论:只要这一小段里类不能移动,竞态就不存在

一条容易踩的错觉:让类“活着”不等于让它“不动”。 有人会想:那我对 declaring class 加个 JNI 全局引用、或在 Java 里留个强引用把它钉住不就行了?不行。强引用只保证类不被回收,移动 GC 照样会搬它,并且会去更新那个被跟踪的引用槽——但 backup 里的 declaring_class 是一个独立的裸 uint32_t,根本不是被跟踪的槽,它仍然变野。上游那句 declaring.getClass() 即便真把 declaring 钉在了栈上,被就地更新的也是那个局部变量的槽,跟 backup 的字段是两码事——所以它注释里写了 (invalid for now)。要么让 backup 成为被跟踪的 root(改动太大,等于重写 Pine 的内存模型),要么在这段窗口里别让类动。我们选后者。

图2:崩溃窗口 vs 修复,一次 backup 调用的时序

第一反应可能是 ScopedSuspendAll 把 VM 停掉,或用 ScopedGCCriticalSection 把整段调用圈起来禁掉所有 GC。这两种都不能用:backup 会执行任意用户代码,里面随时分配对象、触发 allocation GC;一旦把回收能力也卡死,被调用代码里分配触发的 GC 推不动,结果是死锁或假性 OOM

正确粒度是只关移动、保留回收。ART 里有现成原语:art::gc::Heap::IncrementDisableMovingGC / DecrementDisableMovingGC——这正是 GetPrimitiveArrayCritical 持有裸堆指针期间用的同一把锁(JNI 给你裸数组指针时,也必须保证这段时间堆不压缩,道理一模一样)。

它不复杂,但有两个对我们至关重要的语义:一是把 Heap 里的 disable_moving_gc_count_ 计数器加一,计数器 > 0 期间收集器不会选择压缩式回收(非移动回收照常);二是如果调用时正好有一次移动 GC 在进行,它会先 WaitForGcToComplete 等它跑完再返回。计数器式意味着它可重入——嵌套/递归 backup 安全;“等在途 GC 跑完”这一点,是下面顺序能成立的关键。

三步顺序是铁律。beginCallBackup() 返回时,移动已被禁、在途的也已结束,类停在它的最终地址上;此时 syncMethodInfo 抄进 backup 的就是最终地址,并且在 endCallBackup() 之前类不可能再动。先前那个窗口被彻底关死。顺序反了就没意义:先 sync 再禁,sync 抄进去的地址仍可能在禁之前被搬走。

begin/end 对应一个 RAII 对象的生命周期,指针当 cookie 透传回 Java:


[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 18
打赏
分享
最新回复 (7)
雪    币: 109
活跃值: (285)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
2
666
1天前
0
雪    币: 5
活跃值: (2615)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
6666666
1天前
0
雪    币: 3785
活跃值: (5004)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
过来瞧瞧
1天前
0
雪    币: 12
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
5
看看
1天前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
6
666
15小时前
0
雪    币:
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
66
9小时前
0
雪    币: 5
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
8
6666
8小时前
0
游客
登录 | 注册 方可回帖
返回