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

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

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

java 复制代码
// FIXME: GC happens here (you can add Runtime.getRuntime().gc() to test) will crash backup calling

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

cpp 复制代码
static ArtMethod* New() {
    return static_cast<ArtMethod*>(malloc(size));
}

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

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

cpp 复制代码
uint32_t declaring_class = origin->GetDeclaringClass();
backup->SetDeclaringClass(declaring_class);

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

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


二、复现

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

java 复制代码
public class GcBugReproActivity extends Activity {
    public static int victim(int x) { return (x * 31) ^ (x >>> 3); }  // 被 hook 的方法

    private void run() {
        Method m = GcBugReproActivity.class.getDeclaredMethod("victim", int.class);
        Tine.hook(m, new MethodHook() {
            @Override public void beforeCall(Tine.CallFrame f) { }
            @Override public void afterCall(Tine.CallFrame f) { }
        });
        long sum = 0;
        for (int i = 0; i < 200_000; i++) {
            Object[] garbage = new Object[32];                 // 给压缩器制造可搬运的垃圾
            for (int j = 0; j < 32; j++) garbage[j] = new byte[512];
            sum += victim(i);                                  // -> beforeCall -> callBackupMethod -> afterCall
        }
        Log.i("TineGcRepro", "REPRO_SURVIVED iterations=200000 sum=" + sum);
    }
}

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

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

java 复制代码
// 复现构建:还原上游行为
Class<?> declaring = origin.getDeclaringClass();
syncMethodInfo(origin, backup, hookRecord.skipUpdateDeclaringClass);  // 把当前 declaring_class 抄进 backup
Runtime.getRuntime().gc();                                            // 窗口里强制一次移动 GC
Object result = backup.invoke(thisObject, args);                      // 这里读到的就是被搬走后的野指针
declaring.getClass();                                                 // 上游试图"续命",注释自己写了 (invalid for now)
return result;

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


三、崩溃日志(真机)

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

less 复制代码
I TineGcRepro: REPRO_START device=Pixel 6 Pro Android=16 API=36 abi=arm64-v8a
I TineGcRepro: hook installed; hammering backup calls under GC pressure...
I d.tine.examples: Explicit concurrent mark compact GC freed 2673KB AllocSpace bytes, 92% free, ...
F libc    : Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x10 in tid (tine-gc-repro)
F DEBUG   : signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000010 (read)
F DEBUG   :     x2 0000000000000010   x3 656800656e696c5f   ...
F DEBUG   : backtrace:
F DEBUG   :   #00 libart.so (art::mirror::Class::GetDescriptor(std::string*)+76)
F DEBUG   :   #01 libart.so (art::mirror::Class::PrettyDescriptor()+44)
F DEBUG   :   #02 libart.so (art::mirror::Class::PrettyClass()+124)
F DEBUG   :   #03 libart.so (art::ClassLinker::InitializeClass(...)+1928)
F DEBUG   :   #04 libart.so (art::ClassLinker::EnsureInitialized(...)+156)
F DEBUG   :   #05 libart.so (art::InvokeMethod<(art::PointerSize)8>(...)+1876)
F DEBUG   :   #06 libart.so (art::Method_invoke(...)+32)

逐帧读:#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 移动压缩。时间线完全对上。


四、根因

为什么真实方法没事、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 的内存模型),要么在这段窗口里别让类动。我们选后者。


五、修复

5.1 只禁移动,不停世界

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

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

5.2 IncrementDisableMovingGC 到底干了什么

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

5.3 Java 侧:先禁 GC、再 sync、后 invoke

java 复制代码
long gcGuard = beginCallBackup();   // IncrementDisableMovingGC + 等在途移动 GC 跑完
try {
    syncMethodInfo(origin, backup, hookRecord.skipUpdateDeclaringClass);
    return backup.invoke(thisObject, args);
} finally {
    endCallBackup(gcGuard);         // DecrementDisableMovingGC
}

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

5.4 native 侧:一对 RAII guard

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

cpp 复制代码
jlong Tine_beginCallBackup(JNIEnv* env, jclass) {
    return reinterpret_cast<jlong>(new tine::ScopedDisableMovingGc(art::Thread::Current(env)));
}
void Tine_endCallBackup(JNIEnv*, jclass, jlong cookie) {
    delete reinterpret_cast<tine::ScopedDisableMovingGc*>(cookie);
}

ScopedDisableMovingGc 仿照项目已有的 ScopedSuspendVM,构造 increment、析构 decrement,符号不可用时 active_=false、整体退化为 no-op:

cpp 复制代码
class ScopedDisableMovingGc {
public:
    explicit ScopedDisableMovingGc(void* self)
            : self_(self), active_(Android::CanDisableMovingGc()) {
        if (LIKELY(active_)) Android::IncrementDisableMovingGc(self_);
    }
    ~ScopedDisableMovingGc() {
        if (LIKELY(active_)) Android::DecrementDisableMovingGc(self_);
    }
private:
    void* self_;
    bool active_;
};

5.5 怎么拿到 art::gc::Heap*

IncrementDisableMovingGCHeap 的成员函数,调它得先有 this------进程里唯一的 Heap 实例。ART 不导出它,从符号里抠。函数符号按 mangled name 解析:

cpp 复制代码
increment_disable_moving_gc_ = GetSymbolAddress("_ZN3art2gc4Heap22IncrementDisableMovingGCEPNS_6ThreadE");
decrement_disable_moving_gc_ = GetSymbolAddress("_ZN3art2gc4Heap22DecrementDisableMovingGCEPNS_6ThreadE");

Heap*Runtime::instance_(全局单例)→ Runtime::GetHeap()

cpp 复制代码
void** instance_ptr = GetSymbolAddress("_ZN3art7Runtime9instance_E");
void*  runtime      = instance_ptr ? *instance_ptr : nullptr;
auto   get_heap     = GetSymbolAddress("_ZNK3art7Runtime7GetHeapEv");  // const 版
if (!get_heap) get_heap = GetSymbolAddress("_ZN3art7Runtime7GetHeapEv"); // 兜底
if (get_heap) heap_ = get_heap(runtime);

这里刻意没有 去猜 heap_Runtime 结构里的偏移然后硬读。Runtime 很大、heap_ 离任何可校验锚点都远、偏移随版本漂移;猜错的代价不是"拿到 null",而是把一个错位指针 喂进 IncrementDisableMovingGC 解引用,比正在修的 bug 更致命。所以宁可只走 GetHeap():它若在某些 ROM 上被 inline、未导出,就拿不到,拿不到就退化。按版本标定 offset 可以作为后续,默认不冒险。

5.6 降级与零回归

CanDisableMovingGc() 要求 heap_ 和两个函数指针全部 非空;任一缺失即返回 false,guard 变 no-op,callBackupMethod 只剩 syncMethodInfo 那行惰性补写------和改动前完全一致。即:能生效的设备上关死竞态,不能生效的设备上隐身,不引入任何新失败模式。另外 Android 4.4 及以下没有移动 GC,初始化时直接 return,连符号都不解析。

涉及改动:android.h(函数指针 + CanDisableMovingGc + ScopedDisableMovingGc)、android.cppInitDisableMovingGc)、tine.cppbegin/endCallBackup JNI 桥)、Tine.javacallBackupMethod + native 声明),以及 AutomatedTest 里新增的并发 GC 压力步骤。


六、验证

换修复版重跑。 复现 APK 一字不改,只把 core 换成带补丁的版本,同样 20 万次循环 + 同样 GC 压力:

csharp 复制代码
I TineGcRepro: hook installed on victim(int); hammering backup calls under GC pressure...
I Tine    : handleCall for method public static int ...GcBugReproActivity.victim(int)
   ... 20 万次调用全部正常返回 ...
I TineGcRepro: REPRO_SURVIVED iterations=200000 sum=620002257824

同一台机器、同一套压力:复现构建第一批迭代内必崩,修复构建跑满 20 万次正常退出。

回归压力测试。 AutomatedTest 里加了一步:一个线程持续 Runtime.getRuntime().gc(),另一个线程疯狂调用被 hook 方法的原实现,长时间不崩。

线上判断 guard 是否生效(logcat):

  • Moving-GC guard for backup calls enabled (heap=0x...) → guard 已激活;
  • Could not resolve art::gc::Heap* → 符号缺失,已安全退化;
  • 一个间接但有力的信号:GC moved declaring class ... 这条日志降到 0------因为 backup 调用期间类不再移动,自然没有"搬动后补写"这回事了。

七、适用范围

修复覆盖 Android L(5.0) 到 V(15),以及上面实测的 Android 16(CMC)。KitKat 及以下没有移动 GC,不受影响、guard 直接短路。完整 diff 与按版本的兼容说明见仓库 docs/moving-gc-backup-fix.md,编好的 AAR 在 Releases。

上游框架:canyie/pine。本文复现工程与修复在其 fork taisuii/tine

相关推荐
隔窗听雨眠1 天前
大模型加爬虫上篇:技术融合与架构革新
爬虫·架构
Super Scraper1 天前
如何批量抓取 TikTok 数据而不被封锁?完整指南
爬虫·ai·自动化·抖音·tiktok·ai agent
深蓝电商API1 天前
自动化录屏 + 截图:打造爬虫调试的上帝视角
爬虫
tang777891 天前
市场调研自动化采集架构:基于住宅IP轮换的APP数据抓取与反风控方案
爬虫·动态代理ip·爬虫代理ip·爬虫动态ip·住宅代理ip·动态住宅ip
数据知道1 天前
指纹浏览器环境的导入、导出、快照与云端同步机制
爬虫·数据采集·指纹浏览器
星川皆无恙1 天前
大数据k-means聚类算法:基于k-means聚类算法+NLP微博舆情数据爬虫可视化分析推荐系统(新版)
大数据·人工智能·爬虫·算法·机器学习·自然语言处理·kmeans
小二·1 天前
Rust 爬虫与数据处理实战:大规模并发抓取 + 流式处理
开发语言·爬虫·rust
在放️2 天前
Python 爬虫 · 第三方代理接入与合规使用
开发语言·爬虫·python