定制 ART 以绕过反调试
为什么要定制 ART
在 Android 逆向工程中,反调试(Anti-Debugging)是 APP 最常见的自我保护手段之一。APP 通过各种方式检测自己是否正在被调试,如果检测到调试器的存在,就会采取保护措施(如退出进程、返回错误数据、触发反作弊等)。
常见的反调试手段包括:
- 检查
TracerPid字段 - 检测
ptrace系统调用 - 时间差检测
- 检测调试端口
- 检测 Frida / Xposed 等工具
虽然可以通过 Hook 等手段在应用层绕过这些检测,但存在以下问题:
- Hook 本身也可能被检测:APP 可以检查 Hook 框架的特征
- 检测时机不可控:APP 可能在多个位置、多个时机进行检测
- 绕过成本高:每个 APP 都需要单独编写绕过脚本
定制 ART(Android Runtime)是更彻底的解决方案——直接在系统层面修改,让所有反调试检测"天然"失效。这种方法对上层应用透明,一次修改,全局生效。
常见反调试技术及其原理
1. TracerPid 检测
TracerPid 是 /proc/self/status 文件中的一个字段。当一个进程被 ptrace 附加(如调试器附加)时,TracerPid 字段会显示调试器的 PID。
# 正常进程
$ cat /proc/1234/status | grep TracerPid
TracerPid: 0
# 被调试的进程
$ cat /proc/1234/status | grep TracerPid
TracerPid: 5678 # 5678 是调试器的 PID
APP 通过读取并检查 TracerPid 是否为 0 来判断是否被调试:
// C 层实现
int is_debugged() {
FILE* f = fopen("/proc/self/status", "r");
char line[256];
while (fgets(line, sizeof(line), f)) {
if (strncmp(line, "TracerPid:", 10) == 0) {
int pid = atoi(line + 10);
fclose(f);
return pid != 0; // TracerPid 非零说明被调试
}
}
fclose(f);
return 0;
}
// Java 层实现
public boolean isDebugged() {
try {
BufferedReader reader = new BufferedReader(
new FileReader("/proc/self/status"));
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("TracerPid:")) {
int pid = Integer.parseInt(line.split(":")[1].trim());
reader.close();
return pid != 0;
}
}
reader.close();
} catch (Exception e) {}
return false;
}
2. ptrace 检测
Linux 中,一个进程只能被一个调试器 ptrace 附加。APP 可以通过自己 ptrace 自己来"占位",阻止其他调试器附加:
// 反调试的经典技巧:自己 ptrace 自己
void anti_ptrace() {
if (ptrace(PTRACE_TRACEME, 0, 1, 0) < 0) {
// ptrace 失败,说明已有调试器附加
exit(0); // 退出进程
}
// 成功后不能再被其他调试器附加
}
更高级的 ptrace 检测会检查 ptrace 返回值的 errno:
void advanced_ptrace_check() {
// 先尝试 PTRACE_ATTACH 自己的子进程
pid_t child = fork();
if (child == 0) {
// 子进程
sleep(1);
exit(0);
}
// 父进程尝试 ptrace 子进程
int ret = ptrace(PTRACE_ATTACH, child, NULL, NULL);
if (ret < 0 && errno == EPERM) {
// 权限被拒绝,可能是因为子进程已经被调试
kill(child, SIGKILL);
exit(0);
}
waitpid(child, NULL, 0);
ptrace(PTRACE_DETACH, child, NULL, NULL);
}
3. 时间检测
调试器在执行时会产生额外的时间开销。APP 通过测量关键代码段的执行时间,如果时间异常地长,则认为正在被调试:
// C 层时间检测
void time_check() {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
// 执行一段计算
volatile int sum = 0;
for (int i = 0; i < 10000; i++) {
sum += i;
}
clock_gettime(CLOCK_MONOTONIC, &end);
long elapsed_ns = (end.tv_sec - start.tv_sec) * 1000000000L
+ (end.tv_nsec - start.tv_nsec);
if (elapsed_ns > 10000000L) { // 超过 10ms 认为被调试
// 正常执行不应超过 1ms
exit(0);
}
}
4. 检测调试端口和特征文件
APP 可能检查以下调试特征:
void check_debug_features() {
// 检查 Frida 默认端口
FILE* f = fopen("/proc/net/tcp", "r");
// 检查 27042 端口是否开放(Frida 默认端口)
// 检查 Frida 特征文件
const char* frida_files[] = {
"/tmp/frida-server",
"/data/local/tmp/frida-server",
"/data/local/tmp/frida-socket",
NULL
};
// 检查 Xposed 特征
const char* xposed_files[] = {
"/system/framework/XposedBridge.jar",
"/system/lib/libxposed_art.so",
NULL
};
// 检查 Maps 文件中的可疑条目
f = fopen("/proc/self/maps", "r");
// 搜索 "frida" "xposed" "substrate" 等关键字
}
在 ART 源码中定位反调试检测点
AOSP 源码获取
# 获取 Android 源码(以 Android 12 为例)
repo init -u https://android.googlesource.com/platform/manifest -b android-12.0.0_r34
repo sync
# ART 源码路径
cd art/
关键文件位置
ART 中与反调试相关的关键文件:
art/
├── runtime/
│ ├── art_method.cc # JNI/Java 方法执行
│ ├── debugger.cc # JDWP 调试器支持
│ ├── jni/
│ │ ├── java_lang_Runtime.cc # Runtime.exec 等实现
│ │ └── java_lang_Process.cc # 进程相关实现
│ ├── native/
│ │ └── java_lang_Runtime.cc # Runtime 类的 native 方法
│ ├── os.h # 系统调用封装
│ ├── procfs.cc # /proc 文件系统操作
│ └── thread.cc # 线程管理
└── libcore/
└── luni/
└── src/
└── main/
└── java/
└── java/
└── lang/
└── Runtime.java
修改 ART 绕过 ptrace 检测
定位 ptrace 调用
在 ART 中,ptrace 调用可能出现在多个位置。首先搜索源码:
cd art/
grep -rn "ptrace" --include="*.cc" --include="*.h"
主要关注以下调用:
PTRACE_TRACEME:进程主动请求被跟踪PTRACE_ATTACH:附加到目标进程PTRACE_DETACH:分离
修改 ptrace 返回值
在 runtime/os.h 或 runtime/oat/runtime_arch.cc 中找到 ptrace 的系统调用封装:
// 原始代码(简化)
pid_t Syscall::ptrace(int request, pid_t pid, void* addr, void* data) {
return ::ptrace(request, pid, addr, data);
}
修改为始终返回成功:
// 修改后:绕过 ptrace 检测
pid_t Syscall::ptrace(int request, pid_t pid, void* addr, void* data) {
// 对于 TRACEME 请求,始终返回成功
if (request == PTRACE_TRACEME) {
return 0; // 假装 ptrace 成功
}
// 对于 ATTACH 请求,也返回成功
if (request == PTRACE_ATTACH) {
return 0; // 不阻止调试器附加
}
// 其他请求正常执行
return ::ptrace(request, pid, addr, data);
}
在 ART 级别拦截
另一种方式是在 ART 的 JNI 层面拦截:
// 修改 art/runtime/jni/java_lang_Runtime.cc
// 拦截 Runtime.exec(),防止 APP 通过 fork + exec 启动反调试进程
static jobject Runtime_execInternal(JNIEnv* env, jobject, jobjectArray cmdArray, ...) {
// 检查执行的命令是否包含反调试检测
// ...
return original_exec(env, cmdArray, ...);
}
伪造 /proc/self/status 信息
定位文件读取逻辑
APP 读取 /proc/self/status 通常通过以下路径:
- C 层:
fopen("/proc/self/status", "r")→ 系统调用openat+read - Java 层:
new FileReader("/proc/self/status")→ 最终也是系统调用
方法一:内核层面修改
如果你有内核源码访问权限,可以在内核中修改 proc 文件系统的输出:
// 内核源码:fs/proc/array.c
static int proc_pid_status(struct seq_file *m, struct pid_namespace *ns,
struct pid *pid, struct task_struct *task) {
// ...
seq_printf(m, "TracerPid:\t%d\n", ...);
// ...
}
将 TracerPid 始终输出为 0:
// 修改后
seq_printf(m, "TracerPid:\t0\n"); // 始终返回 0
方法二:ART 层面拦截文件读取
在 ART 中拦截 /proc/self/status 的读取:
// 修改 art/runtime/runtime.cc 或 art/runtime/thread.cc
// 在文件读取时检查路径,如果是 /proc/self/status,则过滤 TracerPid
static ssize_t hooked_read(int fd, void* buf, size_t count) {
ssize_t ret = original_read(fd, buf, count);
// 检查是否读取的是 /proc/self/status
char path[256];
char link[256];
snprintf(link, sizeof(link), "/proc/self/fd/%d", fd);
ssize_t len = readlink(link, path, sizeof(path) - 1);
if (len > 0) {
path[len] = '\0';
if (strcmp(path, "/proc/self/status") == 0 ||
strstr(path, "/proc/") != NULL) {
// 在缓冲区中查找并替换 TracerPid
char* content = (char*)buf;
char* tracer = strstr(content, "TracerPid:");
if (tracer != NULL) {
// 找到 TracerPid 行,替换为 0
char* newline = strchr(tracer, '\n');
if (newline != NULL) {
// 保留 "TracerPid:\t",将后面的数字替换为 0
char* tab = strchr(tracer, '\t');
if (tab != NULL) {
// 移动内容,将 PID 替换为 0
int pid_len = newline - tab - 1;
memmove(tab + 2, newline, strlen(newline) + 1);
tab[1] = '0';
tab[2] = '\n';
ret -= (pid_len - 1);
}
}
}
}
}
return ret;
}
方法三:使用 seccomp-bpf 过滤
在 ART 启动时安装 seccomp-bpf 规则,过滤对 /proc/self/status 的访问:
// 在 ART 初始化时安装 seccomp 过滤
void install_proc_filter() {
struct sock_filter filter[] = {
// 允许除了 openat /proc/self/status 之外的所有操作
BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),
// ... BPF 规则定义
};
struct sock_fprog prog = {
.len = sizeof(filter) / sizeof(filter[0]),
.filter = filter,
};
syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog);
}
处理时间检测反调试
时间检测反调试通过测量执行时间来判断是否被调试。在 ART 层面处理:
修改 clock_gettime
// 修改 art/runtime/runtime.cc 或系统调用层
// 记录时间检测的基线
static bool time_check_active = false;
static struct timespec time_check_start;
// Hook clock_gettime
static int hooked_clock_gettime(clockid_t clk_id, struct timespec* tp) {
int ret = original_clock_gettime(clk_id, tp);
if (clk_id == CLOCK_MONOTONIC && time_check_active) {
// 缩短时间差,使时间检测失效
// 保留时间精度但压缩差值
// 注意:这只是一种方案,需要更精细的控制
}
return ret;
}
更优雅的方案:在编译器层面优化
在 ART 编译 Dalvik 字节码时,识别出时间检测模式并消除:
// 修改 art/compiler/dex/optimizing_compiler.cc
// 识别以下模式:
// invoke-static System.currentTimeMillis() → vA
// invoke-static System.nanoTime() → vB
// ... 一些代码 ...
// invoke-static System.currentTimeMillis() → vC
// sub vD, vC, vA
// if-gt vD, threshold → exit
//
// 将其优化为 NOP(空操作)
处理 fork / Runtime.exec 的反调试
APP 可能通过 fork() 创建子进程来进行反调试检测(子进程检查父进程的 TracerPid,或子进程 ptrace 父进程)。
拦截 fork
// 修改 ART 的 fork 实现
// art/runtime/runtime.cc
pid_t Runtime::ForkAndSpecializeCommon(...) {
// 在 fork 之前,临时清除 TracerPid
// fork 之后,子进程中 TracerPid 为 0
pid_t pid = fork();
if (pid == 0) {
// 子进程
// 确保子进程无法检测到调试
// 可以修改子进程的 /proc/pid/status
return pid;
}
return pid;
}
拦截 Runtime.exec
// 修改 libcore/luni/src/main/java/java/lang/Runtime.java
public Process exec(String[] cmdarray, ...) throws IOException {
// 检查命令是否是反调试相关的
for (String cmd : cmdarray) {
if (is_anti_debug_command(cmd)) {
// 返回一个假的 Process,不实际执行
return createFakeProcess();
}
}
return execInternal(cmdarray, ...);
}
编译和刷入定制 ART
编译 ART
# 在 AOSP 源码根目录
source build/envsetup.sh
lunch aosp_arm64-userdebug
# 仅编译 ART 模块
make art -j$(nproc)
# 编译产物路径
# out/target/product/<device>/system/lib64/libart.so
# out/target/product/<device>/system/lib/libart.so
编译完整系统镜像
# 编译完整系统(包含定制 ART)
make -j$(nproc)
# 产物
# out/target/product/<device>/system.img
刷入设备
# 进入 fastboot 模式
adb reboot bootloader
# 刷入系统镜像
fastboot flash system system.img
# 重启
fastboot reboot
仅替换 ART SO 文件(更快的方式)
如果你只想替换 ART 的 SO 文件而不刷入完整系统:
# 重新挂载 system 为可写
adb root
adb remount
# 推送定制的 libart.so
adb push out/target/product/<device>/system/lib64/libart.so /system/lib64/
adb push out/target/product/<device>/system/lib/libart.so /system/lib/
# 重启
adb reboot
测试验证绕过效果
测试 TracerPid 检测
// 测试代码:读取 /proc/self/status 中的 TracerPid
public void testTracerPid() {
try {
BufferedReader reader = new BufferedReader(
new FileReader("/proc/self/status"));
String line;
while ((line = reader.readLine()) != null) {
if (line.startsWith("TracerPid:")) {
Log.d("TEST", line);
// 预期:TracerPid: 0(即使正在被调试)
break;
}
}
reader.close();
} catch (Exception e) {
e.printStackTrace();
}
}
使用 IDA 附加到进程后运行此测试,确认 TracerPid 始终返回 0。
测试 ptrace 检测
// 测试代码:尝试 ptrace 自己
public native int testPtrace();
// Native 实现
int testPtrace() {
long ret = ptrace(PTRACE_TRACEME, 0, NULL, NULL);
// 预期:ret == 0(始终成功)
return (int)ret;
}
测试时间检测
// 测试代码
public void testTimeCheck() {
long start = System.nanoTime();
// 模拟被调试时的延迟
volatile int sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += i;
}
long end = System.nanoTime();
long elapsed = end - start;
Log.d("TEST", "Elapsed: " + elapsed + " ns");
// 即使在调试状态下,时间也应该正常
}
综合测试
编写一个包含多种反调试检测的测试 APP,在定制 ART 环境下运行:
public class AntiDebugTest extends Activity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
boolean debugged = false;
// 测试1:TracerPid
if (checkTracerPid()) debugged = true;
// 测试2:ptrace
if (checkPtrace()) debugged = true;
// 测试3:时间检测
if (checkTime()) debugged = true;
// 测试4:端口检测
if (checkPorts()) debugged = true;
// 测试5:特征文件检测
if (checkFiles()) debugged = true;
Log.d("TEST", "Debugged: " + debugged);
// 预期:即使附加了调试器,所有检测都应返回 false
}
}
注意事项与风险
稳定性风险
- 修改 ART 可能导致系统不稳定
- 部分修改可能导致某些正常功能异常
- 建议在测试设备上进行,不要在生产设备上使用
版本兼容性
- 不同 Android 版本的 ART 源码差异较大
- 修改需要针对特定版本进行
- Android 系统更新后需要重新编译
法律和道德考量
- 定制 ART 仅应用于安全研究和学习
- 不得用于非法目的
- 尊重软件开发者的知识产权
总结
定制 ART 绕过反调试是一种系统级的解决方案。通过修改 ptrace 返回值、伪造 /proc/self/status 中的 TracerPid、处理时间检测和 fork/exec 反调试等手段,可以在 ART 层面彻底绕过常见的反调试检测。相比应用层的 Hook 方案,定制 ART 更加隐蔽和通用,但需要更多的编译和刷机工作。在实际项目中,需要根据具体需求选择合适的绕过方案。