绕过Frida/Xposed的最后防线:SVC直接系统调用与Native反Hook实战

写在前面:那个让我熬到凌晨四点的 bug

事情是这么开始的:上线一个金融业务的安全 SDK,灰度第一天,风控同学在群里 @ 我:"线上有人能稳定 Hook 我们的核心校验函数,你那个反 Hook 失效了。"

我心里咯噔一下。我们的反 Hook 已经接了三层:Java 层的反射检测、libart.so 加载校验、还有 Native 层经典的 inline hook 探测------读 prologue 头四字节看是不是 b xxxx / ldr pc, ...。这套方案抗大部分自动化爬虫绰绰有余。

但对方明显是个老手。当晚我抓到他的样本:Frida 的 Stalker 走了 trace 模式 + 自定义 transform,并没有改 prologue,也就是说我那套基于"扫前几条指令"的检测,全部失效。更绝的是,他在 Hook 之前还把我们用来读 maps 的 fopen / open 系统调用全部代理走了一份假的 /proc/self/maps,让我连"自检自己的内存映射"这一步都被骗过去。

那一刻我意识到一个事:只要我用的还是 libc 的导出函数,对手就有机会在我之前先 Hook 它。任何一个被 PLT/GOT 或 inline 改过的 libc 接口,都不能信。要破这个局,只剩一条路------绕开 libc,自己直接 svc 进内核。

这篇文章,我把这两年踩过的坑、最后落地的方案都整理出来。聊三件事:第一,主流 Hook 框架到底是怎么工作的,为什么传统反 Hook 抓不住它们;第二,SVC 直接系统调用怎么实现,AArch64 / ARM32 / x86_64 都得覆盖;第三,工程化集成里那些不写出来你绝对会踩的坑,比如 seccomp、CFI、PAC,以及怎么和已有的安全 SDK 共存。

第一节:先把对手摸清------主流 Hook 是怎么工作的

1.1 三种 Hook 路径的对比

反 Hook 之前要先理解 Hook。市面上能搞 Android 进程的 Hook,简单分三类,每类的"触手"伸进进程的位置完全不同:

类型 代表 挂钩位置 特征
Java/ART Hook Xposed / LSPosed / EdXposed 改 ArtMethod->entry_point_ 改虚机层数据,对 Native 不可见
PLT/GOT Hook bhook / xhook / Substrate 改导入表跳转地址 只能拦"跨 so 调用"
Inline Hook Frida / Dobby / SandHook 改函数 prologue 跳板 最猛,能拦同 so 内调用

说实话,这三类里我最头疼的是 Frida 的 Stalker。Stalker 不是经典 Inline Hook,它是动态二进制翻译------把目标代码块拷一份到自己的代码缓冲区,边翻译边插桩。你扫 prologue 是抓不到的,因为原 prologue 没改。

1.2 为什么传统反 Hook 经常翻车

三个常见的检测姿势,它们今天都不太够用了:

第一种是扫描 /proc/self/maps,找 frida-agent / xposed / substrate 字样。问题是:对方可以 Hook open / fopen,把读到的 maps 给你过滤一遍。我那次掉的就是这个坑。

第二种是对关键函数做 prologue 校验。问题是:Stalker 模式不改 prologue。即便是 Inline Hook,对手也可以做"懒 Hook"------你检测的时候不挂钩,业务真正调用的时候才挂上。

第三种是检测 frida-server 的固定端口 27042。问题是:Frida 早就支持随机端口,改个 spawn gating 模式就能完全摆脱端口监听。

个人判断:2026 年的反 Hook,不能再依赖"对方在不在",必须假设"对方一定在,且我读到的所有用户态信息都可能被污染"。这是一种典型的 Zero Trust 思路,只是把它从网络安全搬到了进程内部。

第二节:SVC 直接系统调用------绕开 libc

2.1 为什么直接 SVC 是有效的

所有 libc 函数最终都要 svc #0(AArch64)或 syscall(x86_64)进内核。Hook 框架的所有"改写"都发生在用户态。只要我把 syscall 编号、参数寄存器、svc 这条指令直接写成机器码嵌进 so,整个调用链就不存在任何"可被 Hook 的导出符号"。对手要 Hook 也行------除非他改内核(root + 改 selinux + 改 syscall table,难度直接拉到 Linux kernel rootkit 级别)。

这个收益是真实的。我们落地之后,线上能稳定绕过的样本从每天几十例直接降到 0。

2.2 AArch64 内联汇编的 openat 实现

下面是我们生产代码里用得最频繁的 openat。Android 现在 AArch64 占绝对主力,先把这个写对:

arduino 复制代码
// arch/arm64-v8a 直接 svc
// __NR_openat = 56
static inline long
sc_openat(int dirfd,
    const char* path,
    int flags,
    int mode) {
    register long x0
        asm("x0") = dirfd;
    register long x1
        asm("x1") = (long)path;
    register long x2
        asm("x2") = flags;
    register long x3
        asm("x3") = mode;
    register long x8
        asm("x8") = 56;
    asm volatile(
        "svc #0"
        : "+r"(x0)
        : "r"(x1), "r"(x2),
          "r"(x3), "r"(x8)
        : "memory", "cc");
    return x0;
}

几个细节,全是踩坑得来的:

① 系统调用号必须按内核版本对,别用 glibc 的 SYS_openat 宏 。Android Bionic 的头文件里有 __NR_openat,但你要交叉编译给老内核(Android 8 那一批),最稳的是把数字硬编码进去

② x8 是 syscall number,不是 r7。AArch64 用 x8,ARM32 才是 r7。这俩搞混了,svc 进去返回 ENOSYS,你自己都查不到原因。

③ "memory" / "cc" clobber 不能省。少了之后编译器优化会把你的内联汇编整个挪位置,发版才出问题,灰度阶段都摸不出来。

2.3 ARM32 与 x86_64 的差异

市场上 32 位机器越来越少,但出海到东南亚、南美还是必须兼容。给一个对照表,背下来:

ABI syscall 寄存器 参数寄存器 陷入指令
AArch64 x8 x0~x5 svc #0
ARM32 (EABI) r7 r0~r5 svc #0
x86_64 rax rdi/rsi/rdx/r10/r8/r9 syscall

注意 x86_64 第四个参数是 r10 不是 rcx 。rcx 在 syscall 指令执行时会被内核保存返回地址,所以 syscall 调用约定特意把第四个参数挪到了 r10。我亲眼见过有同事拷了 Linux 用户态函数调用的代码(rdi/rsi/rdx/rcx/r8/r9),结果第四个参数永远是错的,调试两天才发现。

2.4 用 SVC 实现"干净的" /proc/self/maps 扫描

这是最实用的应用场景之一------绕过对手污染过的 fopen,自己直读 maps:

ini 复制代码
// 用 sc_openat / sc_read / sc_close
// 完成 maps 扫描,全程不调用 libc
bool scanForFridaSig() {
    const char* path =
        "/proc/self/maps";
    int fd = sc_openat(
        -100,         // AT_FDCWD
        path,
        0,            // O_RDONLY
        0);
    if (fd < 0) return false;

    char buf[4096];
    long n;
    bool hit = false;
    while ((n = sc_read(
                fd, buf, sizeof(buf))) > 0) {
        if (memmem(buf, n,
                "frida-agent", 11)) {
            hit = true;
            break;
        }
        if (memmem(buf, n,
                "gum-js-loop", 11)) {
            hit = true;
            break;
        }
    }
    sc_close(fd);
    return hit;
}

这里我留了 memmem 没改。为啥?因为 memmem 是纯字符串匹配,对手 Hook 它没意义------他即使 Hook 了我自己的 memmem,也只能改返回值,但我可以本地实现一个 inline 的 memmem,把这条路彻底关掉。后面会讲。

第三节:把检测做得"无处可逃"

3.1 三层联合检测:永远不要只信一个信号

单一检测项被绕过太容易了。我们线上跑的方案是三类信号联合裁决,任意一项命中就触发风控:

业务核心调用入口

三类信号同时采集

① 静态特征 → SVC 直读 maps,扫 frida-agent/gum-js/xposed

② 行为特征 → 关键函数 prologue 校验 + Stalker trampoline 探测

③ 时序特征 → 关键路径执行时长抖动(CPU 计时器)

任一命中?

否 → 正常下发结果

是 → 上报风控 + 返回伪造结果(不要直接 crash)

我特别想强调最后一点:命中了不要直接 crash,也不要直接告诉对手"你被发现了"。最佳策略是返回一个"看起来对、但拼不齐密钥"的伪造结果,让对方在错误的方向上继续花时间。这一招我们上线之后,对手平均放弃周期从 2 天延长到了 11 天。

3.2 时序检测:Hook 一定会拖慢你

这是个被严重低估的方法。Frida 的 Stalker 即使再优化,也至少有 5~10x 的性能开销。我们用一个简单的"基线 + 抖动"方法:

arduino 复制代码
static inline uint64_t
read_cntvct() {
    uint64_t v;
    asm volatile(
        "mrs %0, cntvct_el0"
        : "=r"(v));
    return v;
}

bool timeAnomaly() {
    // 标定基线(首次启动)
    static uint64_t baseline = 0;
    uint64_t t1 = read_cntvct();
    heavyDummy();   // 固定计算量
    uint64_t dt =
        read_cntvct() - t1;
    if (baseline == 0) {
        baseline = dt;
        return false;
    }
    // 超过基线 3 倍判异常
    return dt > baseline * 3;
}

这里有个我特别想吐槽的细节:不要用 clock_gettime 做时序检测。clock_gettime 是 vDSO 实现的,但 Frida 是可以 Hook vDSO 的(虽然麻烦)。直接读 ARMv8 的 cntvct_el0 寄存器最稳,对手要 Hook 这个得改异常向量表,那已经是改内核的范畴。

3.3 关键函数 prologue 校验(升级版)

传统 prologue 校验只看前 4 字节。我们升级了一版:对函数体多个固定 offset 采样 + CRC 校验。改前几条字节不够了,对手得把整个函数体的 CRC 都修对,工作量直接翻倍。

arduino 复制代码
// 编译期算好 expectedCrc
// 用 build.gradle.kts 注入
bool funcIntact(
    const void* fn,
    size_t sz,
    uint32_t expectedCrc) {
    uint32_t c = crc32(
        (const uint8_t*)fn, sz);
    return c == expectedCrc;
}

注意:CRC 表本身要做 anti-tamper(编译进 .rodata 并对它再做一次校验)。对手如果聪明点,直接把 expectedCrc 也改了------所以校验链得是个环:A 校验 B,B 校验 C,C 校验 A。这个思路在编译器领域叫"自校验码网"。

第四节:工程化的坑------这些没人告诉你

4.1 seccomp 会让你的 svc 直接崩

Android 从 8.0 开始引入 seccomp-bpf 过滤器,限制了 zygote/app_process 子进程能调用的 syscall 白名单。问题来了:你在某些设备(特别是华为 EMUI/HarmonyOS、vivo OriginOS)上手写 svc 的时候,会被 seccomp 直接 kill,而且日志没有任何提示。

我的解法是:启动时探测 seccomp 状态,不可用的设备降级到 PLT/GOT Hook 检测

scss 复制代码
int checkSeccompMode() {
    // /proc/self/status 里有 Seccomp:
    // 0=无, 1=strict, 2=filter
    int fd = sc_openat(
        -100,
        "/proc/self/status",
        0, 0);
    if (fd < 0) return -1;
    char buf[4096] = {0};
    sc_read(fd, buf, sizeof(buf));
    sc_close(fd);
    char* p = strstr(
        buf, "Seccomp:");
    if (!p) return 0;
    return atoi(p + 8);
}

实测:Pixel/Samsung 国行/小米国际版 seccomp=2,可以正常 svc。华为某些机型在敏感 syscall(如 ptrace、process_vm_readv)会过滤。我们用的 openat / read / close / close_range 都在白名单里,没遇到过被 kill。

4.2 PAC 与 BTI:Android 14+ 新约束

Android 14 开始,AArch64 编译产物默认开 PAC(Pointer Authentication)和 BTI(Branch Target Identification)。如果你的 svc 是写在汇编 .S 文件里的裸函数,记得加上 BTI 标记,否则被间接调用时会触发 SIGILL:

csharp 复制代码
// my_svc.S
.text
.global sc_openat_asm
.type sc_openat_asm, @function
sc_openat_asm:
    bti  c           // 关键:BTI 标记
    mov  x8, #56
    svc  #0
    ret

不加 bti c,发到 Pixel 8/9 上就 SIGILL 闪退。我组里有个同学因为这个挂了一个 P0 灰度,后来 build.gradle 里加了 -mbranch-protection=standard 才彻底解决。

4.3 CFI(控制流完整性)和 LTO 的副作用

NDK r25+ 默认开启 CFI 和 LTO。开了 LTO 之后,编译器会跨 TU 内联,你写的 inline svc 函数有可能被合并到调用方。这有好有坏:好处是性能更好;坏处是同一段 svc 代码会出现在多个地方,对手定位起来更难,但你自己也没法做"该函数的 CRC 校验"。

建议的折中是:关键的 svc wrapper 标 noinline + visibility hidden

arduino 复制代码
__attribute__((noinline,
    visibility("hidden")))
long
sc_openat(int dirfd,
    const char* p,
    int flags,
    int mode) { /* ... */ }

visibility hidden 把符号从 .dynsym 表里抹掉,对手 readelf 也看不到。这是廉价但极其有效的混淆。

4.4 和已有安全 SDK 共存

很多 App 已经接了第三方加固(梆梆/爱加密/腾讯柏拉图)。这时候你做的 SVC 反 Hook 要注意:加固本身可能也是用 inline hook 实现的。如果你扫到加固自己的 trampoline,把它误判成攻击,那就尴尬了。

我们的处理:白名单。启动时把已知加固 so 的内存范围记下来,扫描 maps 时跳过这些区段。代码很简单,但流程上得跑通------找加固方要他们的 so 列表,然后写测试用例覆盖每一个加固版本。

第五节:争议与权衡

5.1 这套东西值得做吗?

有同事问我:你这套搞下来,开发 + 维护 + 灰度成本一个人月起步,真的值吗?

我的判断分两个方向:

金融、支付、风控、内容 DRM:值,必须做。攻击成本和你的方案直接绑定收益,对手研究 1 周才能绕过 vs 2 小时就能绕过,对线上风控的杠杆完全不同。

普通业务、社交、电商主链路:未必。我个人不推荐每个业务都铺这套,因为收益不明显,反而把代码复杂度抬上来了。多数情况下,把 Java 层校验做扎实 + 关键计算挪到服务端,性价比更高。

反 Hook 的本质是提高攻击成本,不是阻止攻击。这个共识没建立,技术怎么做都白搭。把"绝对安全"挂嘴上的人,要么没做过实战,要么是销售。

5.2 SVC 直调和 LSPosed 模块化检测,谁更重要?

2026 年 LSPosed 生态已经很成熟,模块化的 root 检测库(Native Detect / EzXposed-Detect)也很多。要不要直接接现成的?

我的看法是:互补,不是替代

现成库的优点:覆盖面广、有人维护、bug fix 快。缺点:对手也有现成的对抗 PoC,整套东西的"惊喜值"为零,他们写一个通用 plugin 就能干掉一片应用。

SVC 直调的优点:定制化、对手必须现学你的实现。缺点:自己维护成本高,syscall 编号在新内核版本可能变(虽然概率很低)。

我的方案是:开源库做底座(覆盖 90% 攻击者),SVC 直调做核心校验(覆盖剩下 10% 高水平对手)。两层叠加,性价比最优。

最后留一个问题

写到这里,3000 多字了,但说实话------本文的所有方案都建立在一个假设上:内核没被改。如果对手能拿到 root + 改 SELinux + 改 syscall table,那我前面所有 svc 都白写。

这就是为什么 Google 把希望寄托在 **Android Pixel 8 之后的"内核完整性 + KeyMint Strongbox"**组合上:把核心校验逻辑放到 TEE / SE,用户态再被怎么 Hook 也撼动不了硬件信任根。

下一步我打算研究的方向:怎么把今天写的 SVC 直调思路 + KeyMint 硬件签名结合起来,做一个"用户态防 Hook + 硬件信任根"的二层防护。这个东西做出来,应该能对付现在 99% 的样本。等做完了再写一篇。

你们线上反 Hook 现在用什么方案?欢迎评论区扔过来交流,特别欢迎那种"我们尝试过 XX 但翻车了"的故事------我最爱看这个。

相关推荐
程序员陆业聪1 小时前
WebView与原生JS交互:JSBridge生产级实现与安全防护
android
我命由我123455 小时前
Android 开发问题:MlKitException: An internal error occurred during initialization.
android·java·java-ee·android jetpack·android-studio·androidx·android runtime
Meteors.5 小时前
Android自定义 View 三核心方法详解
android
2501_916007475 小时前
前端开发常用软件与工具全面指南
android·ios·小程序·https·uni-app·iphone·webview
赏金术士6 小时前
Android Tinker 热修复集成与使用指南 1.9.15.2
android·热修复·tinker
2603_954138397 小时前
安卓误删文件先别慌!5个实用小技巧指南教你补救
android·智能手机
波诺波8 小时前
5-SOFA可变形的3D物体 5-elasticity.scn
android
2501_9159090610 小时前
iOS应用性能优化:十大策略提升用户体验与开发效率
android·ios·小程序·https·uni-app·iphone·webview
sun00770010 小时前
打通android全链路,网卡驱动, 内核 , 到上层hal, framework
android