写在前面:那个让我熬到凌晨四点的 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 但翻车了"的故事------我最爱看这个。