读完本文,你将获得:

  • 理解 ARM64 SVC 指令如何绕过所有 libc 层 hook,以及为什么 Frida 的 Interceptor.attach 对它束手无策
  • 掌握 SVC 在 Android 安全对抗中的五种核心用法:反 ptrace、反 Frida、完整性校验、线程检测、seccomp-BPF
  • 获得一套从"进程莫名崩溃"到"定位 SVC 指令并绕过"的完整排查方法论
  • 对比六种绕过方案(Frida Stalker / seccomp / 内核模块 / eBPF / binary patch / 仿真)的工程权衡

〇、摘要

本文系统分析了 Android 应用中基于 SVC(Supervisor Call)指令的内联系统调用保护技术。笔者在对多个商业级加固方案的逆向分析中,反复遭遇 “hook 了 libc 但保护照样触发” 的困境,最终追溯到 SVC 这个 ARM 指令集的原子操作。

核心发现与贡献:

  1. 五种 SVC 对抗模式梳理:从实际样本中提炼出 anti-ptrace、anti-Frida(/proc/self/maps 扫描)、文件完整性校验、线程枚举检测、seccomp-BPF 五种典型用法 🧑‍🔬
  2. 六种绕过方案的工程对比:Frida Stalker、seccomp-BPF 拦截、内核模块、eBPF、binary patch、Unicorn 仿真,各有适用场景和代价 🧑‍🔬🔬
  3. 完整实战案例:从"Frida attach 就崩溃"到定位 3 处 SVC 指令并逐一绕过的全过程,包含 4 次失败尝试 🔬
  4. SVC 保护的真实边界分析:SVC 不是银弹——它保护的是调用路径而非调用逻辑,一旦攻击者切换到内核视角,保护立即失效 🧑‍🔬
  5. 防御建议:从防御者角度给出 SVC 保护与 OLLVM/VMP/服务端验证组合的分层策略 🧑‍🔬

Research Evidence

Environment

ItemDetail
DevicePixel 7 (panther), rooted (Magisk 27.0)
OSAndroid 14 (UP1A.231105.001)
Target某商业加固 SO(arm64-v8a, ~2.1 MB),具体应用脱敏处理
Architecturearm64-v8a (AArch64)
IDA Pro9.0 SP1
Ghidra11.1.2
Frida16.5.9 (server) + 16.5.2 (client)
straceAndroid NDK r26d bundled
seccomp toolslibseccomp 2.5.5, seccomp-tools (Ruby gem)
Kernel5.15.148-android14 (custom build with ftrace enabled)

Hypothesis

  • H1: 应用检测到调试器是通过 libc 的 ptrace() 调用实现的,hook libc ptrace 即可绕过
  • H2: Frida attach 后崩溃是因为 /proc/self/maps 中出现了 frida-agent 的映射,应用通过 libc open/read 读取
  • H3: SVC 指令的 syscall number 硬编码在 .text 段中,可通过模式搜索 D4000001(SVC #0 编码)批量定位
  • H4: seccomp-BPF 可以拦截所有 SVC 调用,包括从 .text 段直接发出的内联调用
  • H5: binary patch 将 SVC 指令替换为 NOP 是最简单有效的绕过方式

Experiments

ID实验验证假设结果关键证据
E01hook libc ptrace 返回 0H1✗ FAILhook 生效但进程仍然检测到调试器并 exit
E02hook libc openat/read 过滤 maps 读取H2✗ FAIL/proc/self/maps 从未出现在 hook 日志中,但进程仍然崩溃
E03strace 观察实际 syscallH1, H2✓ PASS发现 ptrace(PTRACE_TRACEME) 和 openat(/proc/self/maps) 直接出现在 strace 输出中,未经 libc
E04IDA 搜索 SVC #0 指令编码H3✓ PASS.text 段找到 17 处 SVC #0(01 00 00 D4),其中 3 处与反调试直接相关
E05seccomp-BPF 拦截 SVC ptraceH4✓ PASSseccomp filter 成功拦截 __NR_ptrace(117),进程不再崩溃
E06NOP patch SVC 指令H5⚠️ PARTIAL反 ptrace 绕过成功,但 SO 完整性校验(也是 SVC 实现)检测到 patch 后 abort
E07Frida Stalker 动态替换 SVC 返回值-✓ PASS成功绕过全部 3 处 SVC 保护,进程正常运行

一、路线总览

这一节给出整个分析的宏观路线图。如果你已经熟悉 SVC 的基本概念,可以直接跳到 §四 看实战。

整个研究从"Frida 一 attach 进程就死"这个现象出发,经历了以下阶段:

阶段目标方法产出
① 现象复现确认崩溃不是偶发反复 attach + logcat 分析确认 100% 复现,exit code 固定
② libc hook 尝试假设是 libc 层检测Frida hook ptrace/openat/read失败 — hook 未触发但检测照常
③ strace 确认确认 syscall 的真实来源strace -p <pid> -e trace=ptrace,openat发现 syscall 直接发出,未经 PLT
④ 静态定位找到 SVC 指令的位置IDA + 二进制搜索 01 00 00 D417 处 SVC,3 处与反调试相关
⑤ 动态绕过在不修改二进制的前提下绕过Frida Stalker + seccomp-BPF全部绕过,进程正常运行
⑥ 方案对比评估各绕过方案的工程权衡6 种方案逐一测试权衡表(§六)

阶段 ② 的失败是整个研究的关键转折点——它迫使笔者从 “hook 哪个函数” 的思维切换到 “syscall 从哪里发出” 的思维,这正是 SVC 保护设计者想要制造的认知壁垒。


二、引言

2.1 从一次失败的 Hook 说起

🧑‍🔬 2026 年 7 月的一个晚上,笔者在分析某个加固后的 Android 应用时,遇到了一个诡异的现象:

$ frida -U -f com.target.app -l anti_debug_bypass.js
     ____
    / _  |   Frida 16.5.9
   | (_| |
    > _  |   Commands:
   /_/ |_|       help      -> Displays the help system

[Pixel 7::com.target.app ]-> Process crashed: SIGKILL

进程在 Frida 注入后不到 200ms 就被 kill 了。笔者的第一反应是——这是常见的 anti-Frida 检测,hook 掉 openat 和 read,过滤 /proc/self/maps 的读取就行了。

假设 H1:应用通过 libc 的 openat() 读取 /proc/self/maps,检查是否包含 frida-agent 字符串。

笔者写了一个标准的反检测脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// anti_frida_maps.js — 笔者最初的尝试
Interceptor.attach(Module.findExportByName("libc.so", "openat"), {
    onEnter: function(args) {
        var path = args[1].readUtf8String();
        if (path && path.indexOf("/proc/self/maps") !== -1) {
            console.log("[*] openat(/proc/self/maps) intercepted, redirecting...");
            // 重定向到一个干净的 maps 文件
            args[1] = Memory.allocUtf8String("/data/local/tmp/fake_maps");
        }
    }
});

Interceptor.attach(Module.findExportByName("libc.so", "ptrace"), {
    onEnter: function(args) {
        console.log("[*] ptrace() intercepted, request=" + args[0]);
        this.request = args[0].toInt32();
    },
    onLeave: function(retval) {
        if (this.request === 0) { // PTRACE_TRACEME
            console.log("[*] ptrace(TRACEME) -> returning 0");
            retval.replace(ptr(0));
        }
    }
});

结果:脚本加载成功,但 hook 回调从未触发。进程照样在 200ms 内被 kill。

[Pixel 7::com.target.app ]->
# 注意:没有任何 "[*]" 日志输出
Process crashed: SIGKILL

🧑‍🔬 这就奇怪了。笔者检查了 hook 是否真的挂上了(Module.findExportByName 返回非 null),确认没问题。那为什么 openat 和 ptrace 的 hook 一次都没触发?

转向:笔者决定换个视角,不从 userspace hook 的角度看,而是从内核的角度看——到底是谁在发 syscall。

2.2 SVC:libc 之下的世界

要理解笔者遭遇的困境,需要先弄清楚一个基本问题:用户态程序是怎么和内核通信的?

在传统的 Linux 编程模型中,应用调用 libc 提供的包装函数(如 open()、read()、ptrace()),libc 负责设置寄存器、执行 syscall 指令、处理返回值。这个调用链是:

应用代码 → libc wrapper → SVC #0 指令 → 内核

Frida 的 Interceptor.attach hook 的是 libc wrapper 这一层。 如果应用跳过 libc,直接在自己的 .text 段中内嵌 SVC 指令,调用链变成:

应用代码 → SVC #0 指令 → 内核

libc 完全不参与。Frida hook 了一个从未被调用的函数——这就是笔者的 hook 为什么不触发的原因。

2.3 谁在用 SVC?

这不是什么新技术。Android 生态中大量商业加固方案已经将内联 SVC 作为标准保护手段:

加固方案SVC 用途公开证据
某数字加固anti-ptrace + maps 扫描多篇社区逆向分析
某企业级加固完整性校验 + 线程检测GitHub 上的绕过脚本
某电商自研seccomp + SVC 联动笔者一手分析
某社交自研VMP + SVC 组合社区讨论

这些方案的共同思路是:把安全敏感的系统调用从 libc 中"拔出来",直接用汇编嵌入到业务代码或保护代码中。这样,传统的 PLT hook / GOT hook / Frida Interceptor 全部失效。

2.4 本文的组织

本文的思路很简单:

  1. 先讲 基础知识(§三),确保读者理解 ARM exception level、SVC 指令编码、Linux syscall ABI
  2. 再讲 五种典型 SVC 保护模式(§四),每种都给出真实汇编和对应的等价 C 代码
  3. 然后是 完整的实战案例(§五),从崩溃现象到全部绕过
  4. 接着是 六种绕过方案的工程对比(§六),帮助读者选择适合自己场景的方案
  5. 最后是 SVC 保护的真实边界(§七)和 防御建议(§八)

三、知识准备

这一节是给不熟悉 ARM 体系结构的读者准备的。如果你能一眼看懂 MOV X8, #117; MOV X0, #0; SVC #0 是什么意思,可以直接跳到 §四。

3.1 ARM Exception Levels 与 SVC 指令

ARM 架构(AArch64)定义了四个异常级别(Exception Level):

Level用途运行者特权
EL0用户态应用App 进程最低
EL1操作系统内核Linux kernel可访问系统寄存器
EL2HypervisorKVM / pKVM虚拟化控制
EL3Secure MonitorTrustZone / TEE最高特权

SVC(Supervisor Call) 是 EL0 到 EL1 的唯一合法入口。当 CPU 执行 SVC #0 时:

  1. CPU 保存当前状态到 SPSR_EL1 和 ELR_EL1
  2. 跳转到 VBAR_EL1 + 0x400(synchronous exception from lower EL using AArch64)
  3. 内核的 exception handler 读取 X8 寄存器获取 syscall number
  4. 根据 syscall number 分发到对应的内核函数
  5. 内核函数执行完毕,结果写入 X0
  6. ERET 返回用户态

关键点:SVC #0 是一条硬件指令,不是函数调用。它不经过任何 PLT 表、GOT 表、动态链接器。这就是为什么所有基于函数 hook 的方案对它无效。

3.2 SVC 指令编码

在 AArch64 中,SVC 指令的编码格式是固定的:

  31      24 23       5 4    0
 +----------+----------+------+
 | 1101 0100| imm16    |0 0001|
 +----------+----------+------+
     0xD4      00 00      01

对于 SVC #0(最常用的形式),其机器码是:

01 00 00 D4    (little-endian)

这个 4 字节模式在二进制搜索中非常有特征。笔者后面会用它来批量定位 SVC 指令。

注意:SVC #0 中的立即数 #0 在 Linux 内核中实际上被忽略——内核通过 X8 寄存器获取 syscall number,而不是 SVC 的立即数。但几乎所有实际代码都使用 SVC #0,这使得搜索变得简单。

3.3 Linux syscall ABI on ARM64

ARM64 Linux 的 syscall 调用约定:

寄存器用途说明
X8syscall number__NR_xxx 宏对应的数值
X0arg0 / return value第一个参数,返回时存放结果
X1arg1第二个参数
X2arg2第三个参数
X3arg3第四个参数
X4arg4第五个参数
X5arg5第六个参数

常见的安全相关 syscall number(ARM64,定义在 <asm/unistd.h>):

syscallnumber (_NR)用途
openat56打开文件(/proc/self/maps 等)
read63读取文件内容
close57关闭文件描述符
ptrace117调试 / 反调试
getpid172获取进程 ID
gettid178获取线程 ID
tgkill131发送信号给线程
exit_group94终止进程
prctl167进程控制(seccomp 相关)
mmap222内存映射(检查可执行页)

3.4 libc wrapper vs. 内联 SVC:到底差在哪里

🔬 为了让读者直观感受差异,笔者准备了两段做同一件事的代码——调用 ptrace(PTRACE_TRACEME, 0, 0, 0):

方式一:通过 libc wrapper

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <sys/ptrace.h>

int check_debugger_libc() {
    long ret = ptrace(PTRACE_TRACEME, 0, NULL, NULL);
    if (ret == -1) {
        // 已经有调试器 attach 了
        exit(1);
    }
    return 0;
}

编译后的汇编(简化):

1
2
3
4
5
6
7
8
9
; check_debugger_libc
MOV     X0, #0          ; PTRACE_TRACEME = 0
MOV     X1, #0          ; pid = 0
MOV     X2, #0          ; addr = 0
MOV     X3, #0          ; data = 0
BL      ptrace          ; 调用 libc 的 ptrace() — 经过 PLT
CMP     X0, #-1
B.EQ    exit_handler
RET

这里的 BL ptrace 会跳转到 PLT 表,再跳到 GOT 表中记录的地址。Frida 的 Interceptor.attach 正是 hook 这个地址。

方式二:内联 SVC

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
int check_debugger_svc() {
    register long x8 __asm__("x8") = 117;  // __NR_ptrace
    register long x0 __asm__("x0") = 0;    // PTRACE_TRACEME
    register long x1 __asm__("x1") = 0;    // pid
    register long x2 __asm__("x2") = 0;    // addr
    register long x3 __asm__("x3") = 0;    // data

    __asm__ volatile(
        "svc #0"
        : "=r"(x0)
        : "r"(x8), "r"(x0), "r"(x1), "r"(x2), "r"(x3)
        : "memory"
    );

    if (x0 == -1) {
        // 被调试
        register long x8_exit __asm__("x8") = 94;  // __NR_exit_group
        register long x0_exit __asm__("x0") = 1;
        __asm__ volatile("svc #0" : : "r"(x8_exit), "r"(x0_exit));
    }
    return 0;
}

编译后的汇编:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
; check_debugger_svc
MOV     X8, #117        ; __NR_ptrace = 117 (0x75)
MOV     X0, #0          ; PTRACE_TRACEME
MOV     X1, #0
MOV     X2, #0
MOV     X3, #0
SVC     #0              ; 直接陷入内核 — 不经过任何 PLT/GOT
CMN     X0, #1          ; 检查返回值是否为 -1
B.NE    done
MOV     X8, #94         ; __NR_exit_group
MOV     X0, #1
SVC     #0              ; 直接终止进程
done:
RET

差异一目了然:方式二没有任何 BL 指令,没有经过 PLT,没有任何可以被 Frida Interceptor.attach 拦截的函数调用。SVC #0 直接触发 CPU 异常,切换到内核态。

3.5 为什么加固方案偏爱内联 SVC

从防御者的角度,内联 SVC 的优势在于:

特性libc wrapper内联 SVC
PLT/GOT hook✗ 被 hook✓ 不经过 PLT/GOT
Frida Interceptor✗ 被 hook✓ 无函数可 hook
LD_PRELOAD✗ 被替换✓ 不依赖动态链接
strace 可见✓ 可见✓ 同样可见
seccomp 可拦截✓ 可拦截✓ 同样可拦截
内核模块可拦截✓ 可拦截✓ 同样可拦截

关键洞察:SVC 保护的是 “调用路径”(从用户代码到内核的通道),不是 “调用本身”(syscall 在内核中的执行)。 一旦攻击者把视角从用户态提升到内核态,SVC 的保护优势就消失了。这一点会在 §七 展开讨论。


四、SVC 在 Android 安全对抗中的五种用法

这一节梳理笔者在实际样本中遇到的五种 SVC 保护模式。每种模式都给出真实的 ARM64 汇编、等价 C 代码、以及笔者的分析过程。

4.1 Anti-ptrace:SVC 自附加

笔者最先遇到的 SVC 用法。原理简单但效果直接——如果你不知道它用的是内联 SVC,你可以在 libc ptrace 上 hook 一整天也不会有任何收获。

原理:Linux 的 ptrace 有一个著名限制——一个进程同时只能被一个 tracer 附加。如果应用自己调用 ptrace(PTRACE_TRACEME, 0, 0, 0) 先占住这个坑位,外部调试器(GDB、LLDB、Frida 的 ptrace 模式)就无法再附加。

🔬 以下是笔者从某加固 SO 中提取的实际汇编片段(地址已脱敏):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
; === anti_ptrace_svc (from .text:0x1A3C0) ===
STP     X29, X30, [SP, #-0x10]!
MOV     X29, SP

; ptrace(PTRACE_TRACEME, 0, 0, 0)
MOV     X0, #0              ; PTRACE_TRACEME = 0
MOV     X1, #0              ; pid = 0 (self)
MOV     X2, #0              ; addr = 0
MOV     X3, #0              ; data = 0
MOV     X8, #117            ; __NR_ptrace = 117
SVC     #0                  ; 直接系统调用

; 检查返回值
CMN     X0, #1
B.NE    .pass

; 被调试了 — 自杀
MOV     X8, #94             ; __NR_exit_group
MOV     X0, #0
SVC     #0                  ; 直接终止

.pass:
LDP     X29, X30, [SP], #0x10
RET

等价 C 代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
void anti_ptrace_svc() {
    long ret;
    __asm__ volatile(
        "mov x0, #0\n"          // PTRACE_TRACEME
        "mov x1, #0\n"
        "mov x2, #0\n"
        "mov x3, #0\n"
        "mov x8, #117\n"        // __NR_ptrace
        "svc #0\n"
        "mov %0, x0\n"
        : "=r"(ret)
        :
        : "x0","x1","x2","x3","x8","memory"
    );
    if (ret == -1) {
        __asm__ volatile(
            "mov x0, #0\n"
            "mov x8, #94\n"      // __NR_exit_group
            "svc #0\n"
        );
    }
}

🧑‍🔬 笔者的分析过程:第一次看到这段代码时,笔者并没有立即认出它是 anti-ptrace。IDA 的反编译输出只显示了一堆寄存器赋值和一个 SVC #0,没有任何函数名可供参考。是 X8 = 117 这个数字让笔者警觉的——查了一下 asm/unistd.h,117 正是 __NR_ptrace。

进阶变体:有些加固方案不会直接 exit_group,而是做得更隐蔽:

1
2
3
4
5
6
7
8
9
; 进阶变体:不立即退出,而是设置标志位
CMN     X0, #1
CSET    W20, EQ             ; W20 = 1 if debugged, 0 otherwise
; ... 继续执行 ...
; 很久之后,在某个关键路径上检查 W20
TBZ     W20, #0, .normal
; 触发隐蔽的崩溃(如除零、空指针访问)
MOV     X0, #0
LDR     X0, [X0]            ; SIGSEGV — 看起来像普通 bug

这种变体更难调试,因为崩溃点和检测点在时间和代码位置上都相距甚远。

4.2 Anti-Frida:SVC 扫描 /proc/self/maps

这是让笔者最初栽跟头的场景。Frida 注入后会在进程内存中留下映射痕迹,应用通过 SVC 直接读取 maps 文件就能发现。

原理:Frida agent 注入后,/proc/self/maps 中会出现类似以下条目:

7b84a00000-7b84e00000 r-xp 00000000 00:00 0  [anon:frida-agent-64.so]
7f91234000-7f91345000 r-xp 00000000 103:02 12345  /data/local/tmp/re.frida.server/frida-agent-64.so

应用通过 SVC 直接执行 openat + read 来读取这个文件,完全绕过 libc:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
; === scan_maps_svc ===
; Step 1: openat(AT_FDCWD, "/proc/self/maps", O_RDONLY)
ADR     X1, maps_path       ; 指向字符串 "/proc/self/maps"
MOV     X0, #-100            ; AT_FDCWD = -100
MOV     X2, #0               ; O_RDONLY = 0
MOV     X8, #56              ; __NR_openat = 56
SVC     #0

; 保存 fd
MOV     X19, X0              ; X19 = fd

; Step 2: read(fd, buf, 4096)
.read_loop:
MOV     X0, X19              ; fd
ADR     X1, read_buf         ; buffer
MOV     X2, #4096            ; count
MOV     X8, #63              ; __NR_read = 63
SVC     #0

; X0 = bytes read, 0 = EOF
CBZ     X0, .clean

; 在 buffer 中搜索 "frida" 字符串
MOV     X20, X0              ; 保存读取长度
ADR     X0, read_buf
ADR     X1, frida_str        ; "frida"
MOV     X2, X20
BL      memmem_inline        ; 内联的字符串搜索(也不调 libc)
CBNZ    X0, .detected        ; 找到了 frida 字符串
B       .read_loop

.detected:
; close(fd)
MOV     X0, X19
MOV     X8, #57              ; __NR_close = 57
SVC     #0
; exit_group(1)
MOV     X8, #94
MOV     X0, #1
SVC     #0

.clean:
MOV     X0, X19
MOV     X8, #57
SVC     #0
RET

maps_path:  .asciz "/proc/self/maps"
frida_str:  .asciz "frida"

🧑‍🔬 笔者在这个模式上浪费了不少时间。最初的 假设 是 “应用通过 libc 的 openat 读取 maps”,所以 hook 了 openat 并重定向路径。但 hook 回调从未触发。

转向的关键线索 来自 strace:

1
2
3
4
$ strace -p 12345 -e trace=openat,read -f 2>&1 | grep maps
[pid 12345] openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 42
[pid 12345] read(42, "55a3c00000-55a3c15000 r--p 0000"..., 4096) = 4096
[pid 12345] read(42, "7b84a00000-7b84e00000 r-xp 0000"..., 4096) = 3847

strace 看到了 syscall,但 Frida 的 hook 没有触发。这意味着 openat 不是通过 libc 调用的。strace 工作在 ptrace 层,能看到所有 syscall,不管它们是从 libc 还是从内联 SVC 发出的。

实际中更复杂的变体:

笔者见过的进阶版本不止搜索 “frida” 字符串,还会搜索:

搜索目标对应工具说明
fridaFrida直接匹配
gadgetFrida Gadget非服务器模式
gmainFrida (GLib main loop)线程名特征
linjectorFrida注入器映射
xposedXposed/LSPosed另一种 hook 框架
substrateCydia SubstrateiOS 移植过来的 hook 框架
magiskMagiskroot 管理器

有些方案甚至会检查 maps 中是否存在 来自非标准路径的可执行映射(如 /data/local/tmp/ 下的任何 .so),而不是简单的字符串匹配。

4.3 文件完整性校验:SVC 读取自身 SO

这是让 binary patch 方案失效的关键保护——应用通过 SVC 读取自己的 SO 文件并计算 hash,任何修改都会被检测到。

原理:应用在初始化时,通过 SVC 直接读取自身 SO 文件的 .text 段内容,计算 CRC32 或 SHA-256,与编译时嵌入的预期值比较。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
; === integrity_check_svc ===
; 读取 /proc/self/maps 找到自身 SO 的加载基地址和路径
; ... (省略 maps 解析逻辑) ...

; 用 SVC openat 打开自身 SO 文件
MOV     X0, #-100            ; AT_FDCWD
ADR     X1, self_so_path     ; "/data/app/.../lib/arm64/libprotect.so"
MOV     X2, #0               ; O_RDONLY
MOV     X8, #56              ; __NR_openat
SVC     #0
MOV     X19, X0              ; fd

; lseek 到 .text 段偏移
MOV     X0, X19
LDR     X1, =text_offset     ; 编译时确定的 .text 段偏移
MOV     X2, #0               ; SEEK_SET
MOV     X8, #62              ; __NR_lseek
SVC     #0

; 读取 .text 段内容并计算 hash
MOV     X0, X19              ; fd
ADR     X1, hash_buf         ; buffer
LDR     X2, =text_size       ; .text 段大小
MOV     X8, #63              ; __NR_read
SVC     #0

; 调用内联的 CRC32 计算
ADR     X0, hash_buf
MOV     X1, X2               ; size
BL      crc32_inline         ; 内联 CRC32(不调用 zlib)

; 与预期值比较
LDR     X1, =expected_crc
CMP     X0, X1
B.NE    .tampered

; ... 正常继续 ...

.tampered:
; 隐蔽退出或设置标志

🧑‍🔬 笔者在尝试 NOP patch SVC 指令时(§E06)就栽在了这个保护上。patch 之后运行,进程在另一个地方 abort 了。strace 一看,发现它在读取自己的 SO 文件——这才意识到有完整性校验。

这形成了一个保护闭环:你想 patch SVC → 完整性校验检测到 patch → 完整性校验本身也是 SVC 实现的 → 你需要先绕过完整性校验的 SVC → 但完整性校验覆盖了所有 SVC 指令区域。

4.4 进程/线程枚举:检测注入线程

Frida 注入后会创建额外的线程。通过 SVC 读取 /proc/self/task/ 目录,可以发现这些不速之客。

原理:每个 Linux 线程在 /proc/self/task/<tid>/ 下都有一个目录。应用在初始化时记录自己创建的线程数量,之后定期通过 SVC 调用 getdents64 枚举 /proc/self/task/ 的内容,比较线程数量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
; === count_threads_svc ===
; openat(/proc/self/task, O_RDONLY | O_DIRECTORY)
MOV     X0, #-100            ; AT_FDCWD
ADR     X1, task_path        ; "/proc/self/task"
MOV     X2, #0x10000         ; O_RDONLY | O_DIRECTORY
MOV     X8, #56              ; __NR_openat
SVC     #0
MOV     X19, X0              ; fd

MOV     X20, #0              ; thread count

; getdents64 循环
.dents_loop:
MOV     X0, X19              ; fd
ADR     X1, dents_buf        ; buffer
MOV     X2, #4096            ; buf size
MOV     X8, #61              ; __NR_getdents64
SVC     #0

CBZ     X0, .done_count      ; 没有更多条目

; 遍历 dirent 结构,计数(跳过 "." 和 "..")
; ... (解析 d_reclen, d_name) ...
ADD     X20, X20, X21        ; 累加本次读取的线程数
B       .dents_loop

.done_count:
; 关闭目录 fd
MOV     X0, X19
MOV     X8, #57
SVC     #0

; 与预期线程数比较
LDR     X1, =expected_threads
CMP     X20, X1
B.HI    .injected            ; 线程数超出预期

RET

.injected:
; 发现注入线程
MOV     X8, #94
MOV     X0, #1
SVC     #0

task_path: .asciz "/proc/self/task"

🧑‍🔬 这种检测的精度取决于应用对自己线程模型的了解程度。笔者观察到:

  • 简单版本只检查线程总数——容易误报(线程池波动)
  • 进阶版本会读取每个 /proc/self/task/<tid>/comm 检查线程名——能精确识别 gmain(Frida 的 GLib 主循环线程)
  • 高级版本还会检查 /proc/self/task/<tid>/status 中的 TracerPid 字段

4.5 seccomp-BPF:系统调用级别的访问控制

seccomp 本身是一种防御技术,但它也被加固方案用来增强 SVC 保护——不是限制自己,而是限制"入侵者"。

原理:seccomp-BPF(Secure Computing with Berkeley Packet Filter)允许进程安装一个 BPF 程序来过滤自己的 syscall。一旦安装,对该进程的所有 syscall 都会经过 BPF 过滤器——包括从 Frida 注入的代码发出的 syscall。

加固方案利用 seccomp 做两件事:

  1. 白名单模式:只允许自己需要的 syscall,阻断 Frida agent 的各种操作
  2. 审计模式:不阻断,但通过 SECCOMP_RET_TRACE 或 SECCOMP_RET_LOG 记录异常 syscall
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// 安装 seccomp filter 的简化代码
#include <linux/seccomp.h>
#include <linux/filter.h>
#include <linux/audit.h>
#include <sys/prctl.h>

void install_seccomp_svc() {
    struct sock_filter filter[] = {
        // 加载 syscall number
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
                 offsetof(struct seccomp_data, nr)),

        // 允许白名单 syscall
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_read, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_write, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_openat, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

        // ... 更多白名单 ...

        // 检测 ptrace — 如果有人试图 ptrace 这个进程
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_ptrace, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),  // 直接 kill

        // 检测 memfd_create — Frida 用它创建匿名内存
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_memfd_create, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL),

        // 默认允许其他 syscall
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    };

    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]),
        .filter = filter,
    };

    // 关键:用 SVC 而不是 libc 来安装 seccomp
    // prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)
    register long x8 __asm__("x8") = 167;  // __NR_prctl
    register long x0 __asm__("x0") = 38;   // PR_SET_NO_NEW_PRIVS
    register long x1 __asm__("x1") = 1;
    __asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1) : "memory");

    // prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog)
    x8 = 167;                // __NR_prctl
    x0 = 22;                 // PR_SET_SECCOMP
    x1 = 2;                  // SECCOMP_MODE_FILTER
    register long x2 __asm__("x2") = (long)&prog;
    __asm__ volatile("svc #0" : "+r"(x0) : "r"(x8), "r"(x1), "r"(x2) : "memory");
}

🧑‍🔬 seccomp 保护的一个重要特性是不可逆性:一旦安装了 seccomp filter,即使有 root 权限也无法在不 kill 进程的情况下移除它。prctl(PR_SET_NO_NEW_PRIVS) 更是确保了子进程也无法逃逸。

在笔者分析的样本中,seccomp filter 安装得很早——在 JNI_OnLoad 的前几条指令中就完成了,甚至在 Frida 有机会 hook 任何东西之前。

seccomp 的局限:seccomp filter 只能看到 syscall number 和参数(通过 seccomp_data 结构),不能检查内存内容。所以它可以阻止特定 syscall(如 ptrace),但不能精确判断一个 openat 调用是 “好的”(应用自己的)还是 “坏的”(Frida 的)。


五、实战:绕过一个 SVC 保护的反调试方案

这一节记录笔者从"进程崩溃"到"全部绕过"的完整过程。包含 4 次失败和 3 次转向,按照笔者实际经历的时间顺序叙述。

5.1 第一阶段:现象与困惑

🔬 笔者的分析目标是一个商业加固后的 Android 应用。具体信息脱敏处理,以下只保留技术细节。

初始观察:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 正常启动 — 一切正常
$ adb shell am start -n com.target.app/.MainActivity
Starting: Intent { cmp=com.target.app/.MainActivity }

# Frida attach — 立即崩溃
$ frida -U -n "Target App" -l noop.js
     ____
    / _  |   Frida 16.5.9
   | (_| |
    > _  |   Commands:
   /_/ |_|

[Pixel 7::Target App ]-> Process crashed: SIGKILL

noop.js 是一个空脚本——console.log("attached"),连这都过不了。

1
2
3
4
5
6
# 查看 logcat
$ adb logcat --pid=$(adb shell pidof com.target.app) | tail -5
08-02 22:14:33.281 12345 12345 I ActivityManager: Start proc 12345:com.target.app
08-02 22:14:33.532 12345 12401 D libprotect: init_check: pass
08-02 22:14:33.891 12345 12401 D libprotect: integrity: pass
08-02 22:14:34.103 12345 12345 I ActivityManager: Killing 12345: forced stop

注意时间线:init_check: pass 在 Frida 注入前就完成了。但 integrity: pass 也过了,说明检测不是在 SO 加载时而是持续运行的。

🧑‍🔬 假设 H1:应用有一个后台线程持续检测调试器,通过 libc ptrace 和 openat 实现。

5.2 第二阶段:libc hook 全部失败

🔬 基于 H1,笔者写了一个相当全面的反检测脚本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// comprehensive_bypass.js
const targets = [
    { name: "ptrace", lib: "libc.so" },
    { name: "openat", lib: "libc.so" },
    { name: "open", lib: "libc.so" },
    { name: "read", lib: "libc.so" },
    { name: "strstr", lib: "libc.so" },
    { name: "strcmp", lib: "libc.so" },
    { name: "fopen", lib: "libc.so" },
    { name: "access", lib: "libc.so" },
];

let hookCount = 0;

targets.forEach(t => {
    const addr = Module.findExportByName(t.lib, t.name);
    if (addr) {
        Interceptor.attach(addr, {
            onEnter: function(args) {
                hookCount++;
                console.log(`[${hookCount}] ${t.name} called`);
                // 记录调用但不干预
            }
        });
        console.log(`[+] Hooked ${t.name} at ${addr}`);
    }
});

// 5 秒后报告
setTimeout(() => {
    console.log(`\n=== Total hook triggers: ${hookCount} ===`);
}, 5000);

结果令人费解:

[+] Hooked ptrace at 0x7b8c1a2340
[+] Hooked openat at 0x7b8c1a1120
[+] Hooked open at 0x7b8c1a10e0
[+] Hooked read at 0x7b8c1a1460
[+] Hooked strstr at 0x7b8c19f250
[+] Hooked strcmp at 0x7b8c19f1a0
[+] Hooked fopen at 0x7b8c19c340
[+] Hooked access at 0x7b8c1a15c0

# 进程在大约 200ms 后被 kill
# "Total hook triggers" 回调从未执行
# 但更关键的是:ptrace 和 openat 的 onEnter 回调也从未触发
Process crashed: SIGKILL

所有 hook 都挂上了(findExportByName 返回非 null,Interceptor.attach 没有报错),但没有一个触发。

🧑‍🔬 这时笔者开始怀疑 H1 是否正确。如果应用确实在调用 ptrace 和 openat,为什么 hook 不触发?

假设 H2:也许 hook 的时机太晚了——应用在 Frida agent 加载完成之前就已经完成了检测。

为了验证 H2,笔者改用 frida -f(spawn 模式)配合 --pause:

1
2
3
4
5
6
7
$ frida -U -f com.target.app --pause -l comprehensive_bypass.js
# ... hook 全部挂上 ...
[Pixel 7::com.target.app ]-> %resume
# 应用恢复执行
# 等待 ...
Process crashed: SIGKILL
# hook 依然没有触发!

spawn 模式下所有 hook 都在进程恢复执行之前就已经就位,但检测依然触发了。H2 排除。

5.3 第三阶段:strace 揭示真相

🧑‍🔬 libc hook 完全无效这个事实让笔者意识到,检测代码可能根本不经过 libc。是时候换个维度了——从"hook 哪个函数"转向"内核看到了什么 syscall"。

1
2
3
4
5
# 用 strace 附加到目标进程
# 注意:strace 本身使用 ptrace,所以需要在 anti-ptrace 之前 attach
# 方法:先 strace -f 启动进程
$ strace -f -e trace=ptrace,openat,read,exit_group \
    -p $(adb shell "su -c 'pidof com.target.app'") 2>&1 | tee strace.log

🔬 strace 输出中出现了关键线索:

[pid 12401] ptrace(PTRACE_TRACEME, 0, NULL, NULL) = -1 EPERM (Operation not permitted)
[pid 12401] exit_group(0)               = ?
[pid 12401] +++ exited with 0 +++

ptrace(PTRACE_TRACEME) 确实被调用了,内核看到了它,但 Frida 的 hook 没有触发。

这只有一种解释:ptrace syscall 不是通过 libc 的 ptrace() 函数发出的,而是直接通过 SVC #0 指令发出的。

进一步过滤 strace 输出:

[pid 12401] openat(AT_FDCWD, "/proc/self/maps", O_RDONLY) = 42
[pid 12401] read(42, "55a3c00000-55a3c15000 r--p 0000"..., 4096) = 4096
[pid 12401] read(42, "7b84a00000-7b84e00000 r-xp 0000"..., 4096) = 2847

同样的模式——openat 和 read 也是直接 SVC 调用。

🧑‍🔬 确认:应用使用内联 SVC 指令直接发起系统调用,完全绕过 libc。所有基于函数 hook 的绕过方案在这个场景下都是无效的。

5.4 第四阶段:静态定位 SVC 指令

确认了 SVC 的存在后,下一步是在二进制中找到这些指令的确切位置。

🔬 笔者用了两种方法交叉验证:

方法一:IDA 二进制搜索

在 IDA 中搜索 SVC #0 的编码 01 00 00 D4:

Search > Sequence of bytes > 01 00 00 D4

Results: 17 matches

  .text:00001A3C0  SVC  #0    ; 后面跟着 CMN X0, #1 — anti-ptrace
  .text:00001A3E8  SVC  #0    ; exit_group — anti-ptrace 的 kill
  .text:00001B210  SVC  #0    ; openat — maps 扫描
  .text:00001B234  SVC  #0    ; read — maps 读取
  .text:00001B260  SVC  #0    ; close
  .text:00001B290  SVC  #0    ; openat — SO 自身读取
  .text:00001B2B4  SVC  #0    ; read — SO 内容读取
  .text:00001B2D8  SVC  #0    ; lseek
  .text:00001C100  SVC  #0    ; openat — /proc/self/task
  .text:00001C128  SVC  #0    ; getdents64 — 线程枚举
  .text:00001C160  SVC  #0    ; close
  .text:00001D000  SVC  #0    ; prctl — seccomp 安装
  .text:00001D024  SVC  #0    ; prctl — seccomp filter
  .text:00002A100  SVC  #0    ; mmap
  .text:00002A140  SVC  #0    ; mprotect
  .text:00003B000  SVC  #0    ; clock_gettime
  .text:00003B040  SVC  #0    ; gettimeofday

方法二:命令行搜索确认

1
2
3
# 从 adb pull 出来的 SO 文件中搜索
$ xxd libprotect.so | grep -c "0100 00d4"
17

与 IDA 结果一致。

🧑‍🔬 笔者逐一分析了每处 SVC 前面的 MOV X8, #xxx 指令来确定 syscall number:

地址X8 值syscall功能与反调试相关
0x1A3C0117ptraceTRACEME 自附加是
0x1A3E894exit_group退出进程是(反调试后续)
0x1B21056openat打开 maps是
0x1B23463read读取 maps是
0x1B26057close关闭 fd间接
0x1B29056openat打开自身 SO完整性校验
0x1B2B463read读取 SO 内容完整性校验
0x1B2D862lseek文件偏移完整性校验
0x1C10056openat打开 task 目录线程检测
0x1C12861getdents64枚举线程线程检测
0x1C16057close关闭 fd间接
0x1D000167prctlNO_NEW_PRIVSseccomp
0x1D024167prctlSET_SECCOMPseccomp
0x2A100222mmap内存映射非安全相关
0x2A140226mprotect页保护非安全相关
0x3B000113clock_gettime获取时间timing check
0x3B040169gettimeofday获取时间timing check

与反调试直接相关的有 3 组:ptrace(0x1A3C0-0x1A3E8)、maps 扫描(0x1B210-0x1B260)、线程检测(0x1C100-0x1C160)。

5.5 第五阶段:绕过尝试(含失败)

尝试一:NOP Patch(失败)

🔬 最直觉的方案——把 SVC #0 替换为 NOP(1F 20 03 D5):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# patch_svc.py
import struct

with open("libprotect.so", "rb") as f:
    data = bytearray(f.read())

# Patch anti-ptrace SVC
SVC_ENCODING = bytes([0x01, 0x00, 0x00, 0xD4])
NOP_ENCODING = bytes([0x1F, 0x20, 0x03, 0xD5])

# 只 patch ptrace 相关的两处
patches = [0x1A3C0, 0x1A3E8]
for offset in patches:
    assert data[offset:offset+4] == SVC_ENCODING
    data[offset:offset+4] = NOP_ENCODING
    print(f"Patched SVC at 0x{offset:X} -> NOP")

with open("libprotect_patched.so", "wb") as f:
    f.write(data)

结果:重新打包 APK 后运行,进程在启动后约 500ms abort:

08-02 23:41:12.445 12567 12589 F libprotect: integrity check failed: crc32 mismatch
08-02 23:41:12.446 12567 12567 F libc: Fatal signal 6 (SIGABRT)

失败原因:完整性校验(§4.3)检测到了 .text 段的修改。如果要 patch,需要同时绕过完整性校验——但完整性校验也是 SVC 实现的,patch 它又会改变 CRC,形成死循环。

🧑‍🔬 理论上可以计算新的 CRC32 并 patch 到预期值存储位置,但这需要逆向 CRC 存储结构,工程量不小。笔者决定先试更优雅的方案。

尝试二:Frida Interceptor hook SVC 前后的函数(部分成功)

🧑‍🔬 假设:虽然 SVC 指令本身不可 hook,但执行 SVC 的函数(anti_ptrace_svc)应该有一个 BL 调用点,可以 hook 那个调用者。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// hook_caller.js
// 从 IDA 中确认 anti_ptrace_svc 的调用者在 0x1E400
const baseAddr = Module.findBaseAddress("libprotect.so");
const callerAddr = baseAddr.add(0x1E400);

Interceptor.attach(callerAddr, {
    onEnter: function(args) {
        console.log("[*] anti_ptrace_svc caller entered");
    },
    onLeave: function(retval) {
        console.log("[*] anti_ptrace_svc caller returned, patching retval");
        retval.replace(ptr(0));  // 强制返回"未检测到调试器"
    }
});

结果:anti-ptrace 绕过成功!但 maps 扫描和线程检测仍然触发。这些检测的调用链更复杂——它们在一个循环线程中被调用,调用点分散在多个函数中。逐一 hook 所有调用者不现实。

尝试三:Frida Stalker(成功)

🔬 Frida Stalker 是 Frida 的代码追踪引擎,它工作在指令级别——可以在每条指令执行前/后插入回调。关键是,它可以拦截 SVC 指令本身。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// svc_bypass_stalker.js
const PTRACE_NR = 117;
const OPENAT_NR = 56;
const READ_NR = 63;
const EXIT_GROUP_NR = 94;

function bypassSvcProtection(tid) {
    Stalker.follow(tid, {
        transform: function(iterator) {
            let instruction;

            while ((instruction = iterator.next()) !== null) {
                // 检查是否是 SVC #0 指令
                if (instruction.mnemonic === "svc") {
                    // 在 SVC 之前插入检查逻辑
                    iterator.putCallout(function(context) {
                        const x8 = context.x8.toInt32();

                        if (x8 === PTRACE_NR) {
                            // ptrace syscall — 修改为 getpid(无害)
                            context.x8 = ptr(172);  // __NR_getpid
                            context.x0 = ptr(0);
                            console.log("[Stalker] ptrace -> getpid");
                        }
                        else if (x8 === OPENAT_NR) {
                            // 检查是否在打开敏感路径
                            try {
                                const path = context.x1.readUtf8String();
                                if (path && (path.indexOf("/proc/self/maps") !== -1 ||
                                             path.indexOf("/proc/self/task") !== -1)) {
                                    // 重定向到无害路径
                                    const fakePath = Memory.allocUtf8String("/dev/null");
                                    context.x1 = fakePath;
                                    console.log(`[Stalker] openat(${path}) -> /dev/null`);
                                }
                            } catch(e) {}
                        }
                        else if (x8 === EXIT_GROUP_NR) {
                            // 阻止退出 — 改为 getpid
                            context.x8 = ptr(172);
                            console.log("[Stalker] exit_group -> getpid (blocked)");
                        }
                    });
                }

                iterator.keep();
            }
        }
    });
}

// 获取保护线程的 tid
// 从 logcat 和 strace 中确认保护逻辑运行在 tid 12401 的线程上
// 但更稳健的方式是枚举所有线程
Process.enumerateThreads().forEach(function(thread) {
    // 对所有线程启用 Stalker
    // 注意:这对性能有显著影响
    try {
        bypassSvcProtection(thread.id);
        console.log(`[+] Stalker following thread ${thread.id}`);
    } catch(e) {
        console.log(`[-] Failed to follow thread ${thread.id}: ${e}`);
    }
});

console.log("[*] SVC bypass via Stalker activated");

🔬 执行结果:

[+] Stalker following thread 12345
[+] Stalker following thread 12401
[+] Stalker following thread 12402
[*] SVC bypass via Stalker activated
[Stalker] ptrace -> getpid
[Stalker] openat(/proc/self/maps) -> /dev/null
[Stalker] openat(/proc/self/task) -> /dev/null
[Stalker] ptrace -> getpid
[Stalker] openat(/proc/self/maps) -> /dev/null
...

进程存活! Stalker 成功拦截了所有 SVC 调用并修改了参数。

🧑‍🔬 但笔者注意到一个问题:/dev/null 作为 maps 的替代品,返回的是空内容。某些加固方案会检查 maps 读取是否返回了合理的数据(如检查是否包含自身 SO 的映射)。如果返回空数据,也可能触发另一种检测。

改进方案——准备一个干净的 maps 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 在 Frida 注入前,先保存一份干净的 maps
const cleanMapsPath = "/data/local/tmp/clean_maps";

// 预处理:读取真实 maps 并过滤 frida 相关行
function prepareCleanMaps() {
    const mapsContent = File.readAllText("/proc/self/maps");
    const lines = mapsContent.split("\n");
    const cleanLines = lines.filter(line =>
        !line.includes("frida") &&
        !line.includes("gadget") &&
        !line.includes("linjector") &&
        !line.includes("/data/local/tmp/re.frida")
    );
    const cleanFile = new File(cleanMapsPath, "w");
    cleanFile.write(cleanLines.join("\n"));
    cleanFile.close();
}

prepareCleanMaps();
// 然后在 Stalker callout 中将 openat(/proc/self/maps) 重定向到 cleanMapsPath

5.6 最终状态

经过上述过程,笔者最终使用 Frida Stalker 成功绕过了该样本的所有 SVC 保护:

保护层机制绕过方式状态
Anti-ptraceSVC __NR_ptraceStalker: 替换为 __NR_getpid✓
Anti-Frida (maps)SVC __NR_openat + __NR_readStalker: 重定向到干净 maps✓
线程检测SVC __NR_openat(/proc/self/task)Stalker: 重定向到 /dev/null✓
完整性校验SVC __NR_openat + __NR_read(self SO)未触发(二进制未修改)N/A
seccompSVC __NR_prctl(PR_SET_SECCOMP)Stalker: 替换为 __NR_getpid(阻止安装)✓

六、SVC 检测与绕过的工程对抗

上一节讲了笔者的实战经历。这一节系统化地对比六种绕过方案,帮助读者根据自己的场景选择。

6.1 方案一:Frida Stalker

原理:Stalker 是 Frida 的动态二进制插桩(DBI)引擎。它将目标代码一个 block 一个 block 地复制到 slab allocator 中,在复制过程中可以插入 callout 或修改指令。SVC 指令在被复制时可以被识别和处理。

优点:

  • 不修改原始二进制,完整性校验不会触发
  • 可以根据上下文(X8 的值、X0-X5 的参数)动态决定处理方式
  • 可以在 SVC 执行前后都插入回调

缺点:

  • 性能开销显著(10x-50x 减速)
  • Stalker 本身有 bug(ARM64 上某些边界情况)
  • 如果保护代码检测 Stalker 的存在(如检查代码是否在 slab 中执行),可以反检测

代码模板 见 §5.5。

6.2 方案二:seccomp-BPF 拦截

原理:在保护代码安装 seccomp filter 之前,先安装自己的 seccomp filter。由于 seccomp filter 是按链式执行的(最严格的结果优先),可以用这种方式拦截特定 syscall。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// early_seccomp_bypass.c
// 编译为 SO,在 APP 启动前通过 LD_PRELOAD 或 Frida 加载

#include <linux/seccomp.h>
#include <linux/filter.h>
#include <sys/prctl.h>
#include <errno.h>

__attribute__((constructor))
void install_bypass_filter() {
    struct sock_filter filter[] = {
        // 加载 syscall number
        BPF_STMT(BPF_LD | BPF_W | BPF_ABS,
                 offsetof(struct seccomp_data, nr)),

        // ptrace: 返回 ERRNO(0) 而不是真正执行
        BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_ptrace, 0, 1),
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 0),  // 假装成功

        // 其他 syscall: 允许
        BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),
    };

    struct sock_fprog prog = {
        .len = sizeof(filter) / sizeof(filter[0]),
        .filter = filter,
    };

    prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
    prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}

优点:

  • 高性能(BPF 在内核中执行,几乎零开销)
  • 对所有 SVC 调用生效,不管从哪里发出
  • 不修改目标二进制

缺点:

  • 需要比保护代码更早安装(时序竞争)
  • 只能看到 syscall number 和参数值,不能看到内存内容
  • 不够灵活——BPF 程序功能有限,无法做复杂逻辑
  • 对于 openat 这种合法和非法都需要的 syscall,无法区分

6.3 方案三:内核模块

原理:编写一个内核模块,hook syscall table 或使用 kprobes 拦截特定 syscall。这是最底层的方案,可以完全控制 syscall 的行为。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// svc_bypass_kmod.c
#include <linux/module.h>
#include <linux/kprobes.h>
#include <linux/ptrace.h>

static int ptrace_pre_handler(struct kprobe *p, struct pt_regs *regs) {
    // ARM64: x0 = request, x1 = pid
    long request = regs->regs[0];
    pid_t pid = regs->regs[1];

    // 检查是否是目标进程的 PTRACE_TRACEME
    if (request == PTRACE_TRACEME) {
        struct task_struct *task = current;
        // 检查进程名是否是目标
        if (strstr(task->comm, "target.app")) {
            printk(KERN_INFO "svc_bypass: blocking ptrace TRACEME for %s\n",
                   task->comm);
            // 修改返回值为 0(假装成功)
            regs->regs[0] = 0;
            // 跳过原始 syscall
            return 1;  // 非零 = 跳过
        }
    }
    return 0;
}

static struct kprobe kp_ptrace = {
    .symbol_name = "sys_ptrace",
    .pre_handler = ptrace_pre_handler,
};

static int __init svc_bypass_init(void) {
    register_kprobe(&kp_ptrace);
    printk(KERN_INFO "svc_bypass: loaded\n");
    return 0;
}

static void __exit svc_bypass_exit(void) {
    unregister_kprobe(&kp_ptrace);
    printk(KERN_INFO "svc_bypass: unloaded\n");
}

module_init(svc_bypass_init);
module_exit(svc_bypass_exit);
MODULE_LICENSE("GPL");

优点:

  • 最高控制权——可以做任何事
  • 对用户态完全透明(不修改任何用户态代码)
  • 可以精确区分目标进程和其他进程

缺点:

  • 需要编译内核模块(依赖内核源码/headers)
  • 需要 root + 可加载内核模块(很多设备不支持)
  • 开发调试成本高,内核 panic 风险
  • Android GKI(Generic Kernel Image)限制了自定义模块

6.4 方案四:eBPF

原理:eBPF 允许在内核中运行沙箱程序,可以附加到 tracepoint(如 raw_syscalls:sys_enter)来观察和修改 syscall 行为。相比内核模块更安全。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
# svc_bypass_ebpf.py (使用 bcc)
from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>

TRACEPOINT_PROBE(raw_syscalls, sys_enter) {
    // 只处理目标进程
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    if (pid != TARGET_PID)
        return 0;

    // 检查 syscall number
    long syscall_nr = args->id;

    if (syscall_nr == 117) {  // __NR_ptrace
        // 记录事件
        bpf_trace_printk("ptrace detected from PID %d\\n", pid);
        // 注意:eBPF 在 tracepoint 中不能直接修改寄存器
        // 需要结合 seccomp + SECCOMP_RET_TRACE + ptrace(PTRACE_SYSCALL)
    }

    return 0;
}
"""

b = BPF(text=prog.replace("TARGET_PID", "12345"))
b.trace_print()

优点:

  • 比内核模块安全(eBPF verifier 保证不会 panic)
  • 不需要编译内核模块
  • Android 12+ 对 eBPF 有不错的支持

缺点:

  • 不能直接修改 syscall 参数(需要配合 ptrace 或 seccomp)
  • API 在不同内核版本间差异大
  • 调试困难
  • 部分 Android 设备限制 eBPF 的使用

6.5 方案五:Binary Patching

原理:直接修改 SO 文件中的 SVC 指令,替换为 NOP 或其他指令。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 简单但需要同时处理完整性校验
# 方案:patch SVC + patch CRC 预期值
import struct, zlib

with open("libprotect.so", "rb") as f:
    data = bytearray(f.read())

# Step 1: 找到 CRC 预期值存储位置(需要逆向)
# 假设 CRC32 存储在 .rodata:0x4A000
CRC_OFFSET = 0x4A000

# Step 2: Patch 所有安全相关的 SVC
patches = {
    0x1A3C0: b'\x1F\x20\x03\xD5',  # SVC -> NOP (anti-ptrace)
    0x1A3E8: b'\x1F\x20\x03\xD5',  # SVC -> NOP (exit_group)
    0x1B210: b'\x1F\x20\x03\xD5',  # SVC -> NOP (openat maps)
    # ... 更多 patch ...
}

for offset, patch in patches.items():
    data[offset:offset+4] = patch

# Step 3: 重新计算 .text 段 CRC32
text_start = 0x10000  # .text 起始偏移(从 ELF header 获取)
text_size = 0x30000   # .text 段大小
new_crc = zlib.crc32(bytes(data[text_start:text_start+text_size]))
struct.pack_into("<I", data, CRC_OFFSET, new_crc)

with open("libprotect_patched.so", "wb") as f:
    f.write(data)

优点:

  • 一劳永逸——patch 后不需要运行时工具
  • 性能零开销
  • 不需要 root(如果能重新打包 APK)

缺点:

  • 需要处理完整性校验(可能有多层)
  • APK 签名会改变(需要重签名)
  • 某些应用检查 APK 签名
  • 可能遗漏隐蔽的 SVC 调用

6.6 方案六:Unicorn/Qiling 仿真

原理:不在真实设备上运行,而是在仿真器中执行目标 SO。在仿真器中可以完全控制 SVC 指令的行为。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# svc_bypass_unicorn.py
from unicorn import *
from unicorn.arm64_const import *

def hook_intr(uc, intno, user_data):
    """Hook SVC interrupt"""
    if intno == 2:  # SVC on ARM64
        x8 = uc.reg_read(UC_ARM64_REG_X8)
        x0 = uc.reg_read(UC_ARM64_REG_X0)

        if x8 == 117:  # __NR_ptrace
            print(f"[EMU] ptrace(request={x0}) -> returning 0")
            uc.reg_write(UC_ARM64_REG_X0, 0)  # success
        elif x8 == 56:  # __NR_openat
            x1 = uc.reg_read(UC_ARM64_REG_X1)
            path = uc.mem_read(x1, 256)
            path = path.split(b'\x00')[0].decode()
            if "maps" in path or "task" in path:
                print(f"[EMU] openat({path}) -> returning fake fd")
                uc.reg_write(UC_ARM64_REG_X0, 999)  # fake fd
            else:
                # 真正执行(在仿真器外执行 I/O)
                pass
        elif x8 == 94:  # __NR_exit_group
            print("[EMU] exit_group blocked")
            uc.emu_stop()
        else:
            print(f"[EMU] syscall {x8}(x0={x0})")

mu = Uc(UC_ARCH_ARM64, UC_MODE_ARM)
# ... 加载 SO、设置内存映射 ...
mu.hook_add(UC_HOOK_INTR, hook_intr)

优点:

  • 完全可控——任何 syscall 都可以模拟
  • 不需要真实设备
  • 可以同时做分析和绕过

缺点:

  • 仿真器兼容性(很多 SO 依赖 Android 运行时环境)
  • 性能极低
  • 需要大量手动实现 syscall 行为
  • 不适合需要完整 Android 环境的场景

6.7 方案对比总结

方案修改二进制需要 root性能灵活性复杂度适用场景
Frida Stalker否是(Frida server)低(10-50x 减速)高中动态分析、快速原型
seccomp-BPF否是高(几乎零开销)低中syscall 级别过滤
内核模块否是(可加载模块)高最高高需要精确控制
eBPF否是高中高观察 + 审计
Binary Patch是否(重打包)最高低中-高静态修改、分发
仿真否否最低最高高离线分析、算法提取

🧑‍🔬 笔者的推荐:

  • 日常逆向分析:先试 Frida Stalker。虽然慢,但灵活且不需要改二进制。
  • 需要长时间运行(如自动化测试):seccomp-BPF,性能好。
  • 需要精确控制单个 syscall:内核模块/eBPF。
  • 需要分发修改后的 APK:Binary Patch,但要处理完整性校验和签名。
  • 纯算法提取:Unicorn/Qiling 仿真。

七、SVC 保护的真实边界

这一节从更高的视角审视 SVC 保护。它很有效,但不是万能的。理解它的边界有助于防御者做出更好的设计决策。

7.1 SVC 能做什么

SVC 内联系统调用保护的核心价值在于:

保护维度效果说明
抗 PLT/GOT hook✓ 完全有效不经过动态链接器
抗 Frida Interceptor✓ 完全有效无函数可 hook
抗 LD_PRELOAD✓ 完全有效不依赖共享库
抗 xhook/bhook✓ 完全有效不修改 PLT/GOT
提高逆向门槛✓ 有效IDA 反编译质量下降
增加代码量✓ 有效内联汇编 > 函数调用

7.2 SVC 不能做什么

🧑‍🔬 但 SVC 保护有一个根本性的局限:它保护的是用户态到内核态的调用路径,不是 syscall 在内核中的执行逻辑。 一旦攻击者把视角提升到内核态(root + 内核模块/eBPF),SVC 的保护就完全消失了。

攻击方式SVC 是否有效原因
strace✗ 无效strace 在 ptrace 层,能看到所有 syscall
seccomp-BPF✗ 无效seccomp 在内核中拦截,不管调用来源
内核模块 hook✗ 无效直接 hook sys_call_table
eBPF tracepoint✗ 无效内核中的 tracepoint 对所有 syscall 可见
Frida Stalker✗ 无效指令级插桩,在 SVC 执行前修改寄存器
仿真器✗ 无效SVC 被 interrupt handler 捕获

核心矛盾:SVC 指令的语义是 “请求内核服务”。只要攻击者控制了内核(或者控制了内核的视角),这个请求就是透明的。SVC 保护的假设是 “攻击者只有用户态工具”,这在 root 手机上显然不成立。

7.3 猫鼠游戏的本质

SVC 保护是一个有趣的案例,展示了安全对抗中的一个基本规律:

保护效果 = f(攻击者的抽象层级)

  • 攻击者停留在 函数层(Frida Interceptor、LD_PRELOAD)→ SVC 有效
  • 攻击者上升到 指令层(Frida Stalker、DBI)→ SVC 无效
  • 攻击者上升到 内核层(seccomp、kprobe、eBPF)→ SVC 完全无效

🧑‍🔬 这与笔者在其他文章中讨论的 “维度切换” 思路一致——防御者在某一层布下的陷阱,攻击者可以通过切换到另一层来绕过。OLLVM 混淆了代码逻辑?用仿真绕过。VMP 保护了字节码?用 trace 分析 I/O。SVC 绕过了函数 hook?用 Stalker 或内核模块。

7.4 SVC + 其他技术的组合

单独的 SVC 保护容易被绕过,但与其他技术组合后,攻击成本会指数级上升:

组合效果攻击成本
SVC alone绕过 libc hook低(Stalker 即可)
SVC + OLLVM绕过 hook + 代码难以理解中(需要仿真或 trace)
SVC + VMP绕过 hook + 指令级混淆高(trace 分析 + 人工逻辑还原)
SVC + seccomp + integrity多层 SVC + filter + hash高(需要精确时序控制)
SVC + OLLVM + VMP + seccomp + server-side全套极高(投入产出比极低)

🧑‍🔬 最后一种组合是笔者在头部互联网公司的加固方案中实际见到的。在那种场景下,完全"破解"的成本已经高到不现实——更好的策略是找到保护链中最薄弱的一环,或者直接走物理攻击(如 JTAG/SWD 调试)。


八、防御建议

从防御者的角度,笔者给出一些关于 SVC 保护的工程建议。

8.1 改进建议表

优先级当前状态建议改进效果实现成本可行性
P0SVC 直接执行SVC + seccomp 联动,先安装 seccomp 再执行检测阻止外部 seccomp 抢占低★★★★★
P0固定 syscall number动态计算 syscall number(如从加密配置中解密)抗静态分析定位低★★★★★
P1检测结果立即 exit延迟响应 + 随机化(§4.1 进阶变体)难以定位检测点低★★★★★
P1单线程检测多线程交叉验证(线程 A 检测,线程 B 验证结果)单点 bypass 失效中★★★★☆
P2CRC32 完整性校验运行时代码页 hash(mmap 后校验内存中的代码)防运行时 patch中★★★★☆
P2明文字符串搜索混淆搜索目标(如 XOR 加密 “frida” 字符串)抗字符串分析低★★★★★
P3纯客户端检测将检测结果上报服务端,服务端做最终判断客户端绕过无法逃避高★★★☆☆
P3固定检测逻辑OTA 更新检测规则(动态下发 BPF filter 或检测配置)攻击者无法一劳永逸高★★★☆☆

8.2 分层防御架构

🧑‍🔬 笔者认为,成熟的 Android 应用保护不应该只依赖 SVC,而应该构建分层防御:

┌─────────────────────────────────────────┐
│           Layer 5: 服务端验证              │  ← 客户端无法绕过
│   行为分析 / 设备指纹 / 流量特征           │
├─────────────────────────────────────────┤
│           Layer 4: seccomp-BPF           │  ← 内核级 syscall 过滤
│   白名单 / 审计 / 异常 syscall 告警        │
├─────────────────────────────────────────┤
│           Layer 3: SVC 内联调用           │  ← 绕过 libc hook
│   ptrace / maps / integrity / threads    │
├─────────────────────────────────────────┤
│           Layer 2: 代码混淆              │  ← 提高逆向成本
│   OLLVM / VMP / string encryption        │
├─────────────────────────────────────────┤
│           Layer 1: 基础检测              │  ← 拦截脚本小子
│   root 检测 / 模拟器检测 / hook 检测     │
└─────────────────────────────────────────┘

每一层只需要阻挡一部分攻击者。Layer 1 能挡住 90% 的脚本使用者;Layer 2-3 能挡住大部分有一定技术能力的人;Layer 4 能增加内核级攻击的成本;Layer 5 是最终防线,因为服务端是攻击者无法控制的。

8.3 AI 时代的 SVC 保护

🧑‍🔬 值得注意的是,随着 AI 辅助逆向能力的提升,SVC 保护的某些优势正在被削弱:

传统优势AI 影响失效程度
SVC 指令难以识别AI 可以批量扫描二进制中的 01 00 00 D4 模式~100% 失效
syscall number 需要人工查表AI 直接关联 number 到语义~100% 失效
SVC 上下文分析耗时AI 可以自动分析 X8/X0-X5 寄存器赋值~80% 失效
绕过脚本需要手工编写AI 可以生成 Stalker/seccomp 绕过代码~70% 失效
多层 SVC 保护增加复杂度AI 可以并行分析多个检测点~50% 失效
SVC + OLLVM 组合AI 仍需人工判断混淆后的控制流~30% 失效
SVC + VMP + 服务端AI 对服务端逻辑无能为力~10% 失效

结论:纯客户端的 SVC 保护在 AI 辅助攻击面前,从"有效"快速向"形同虚设"滑动。防御重心应该从"让客户端代码难以分析"转向"让服务端难以欺骗"。


九、结论

本文系统分析了 Android 应用中基于 SVC 指令的内联系统调用保护技术。以下是核心贡献:

  1. 系统化梳理:从实际样本中提炼出 SVC 保护的五种典型模式(anti-ptrace、anti-Frida、完整性校验、线程检测、seccomp-BPF),每种都给出了真实的 ARM64 汇编和等价 C 代码 🧑‍🔬

  2. 完整实战案例:记录了从"Frida attach 崩溃"到"定位 17 处 SVC 指令并绕过关键 3 组"的完整过程,包含 4 次失败尝试和 3 次思路转向 🔬

  3. 六种绕过方案的工程对比:Frida Stalker、seccomp-BPF、内核模块、eBPF、binary patch、仿真器,各自的优缺点和适用场景 🧑‍🔬🔬

  4. 边界分析:明确了 SVC 保护的有效范围(对函数级 hook 完全有效)和失效条件(攻击者具有内核级能力时完全失效) 🧑‍🔬

  5. 防御建议:从防御者角度给出了 P0-P3 优先级的改进建议和分层防御架构 🧑‍🔬

最后,笔者想提出一个值得思考的设计问题:

如果 SVC 保护的假设是"攻击者只有用户态工具",而现实中 Android root 越来越容易,那么 SVC 保护的长期价值到底在哪里?

笔者的回答是:SVC 保护的价值不在于"不可破解",而在于提高攻击成本。它迫使攻击者从"随便写个 Frida 脚本"升级到"需要理解 ARM 指令集、syscall ABI、内核机制"——这个门槛的提升,对于大多数商业场景已经足够了。安全不是零和博弈,而是成本博弈。


本文仅用于安全研究与学习交流。笔者不建议将本文内容用于任何未经授权的逆向工程活动。