跳转到内容
123xiao | 无名键客

《安卓逆向实战:基于 Frida 定位与绕过常见反调试机制的方法解析》

字数: 0 阅读时长: 1 分钟

安卓逆向实战:基于 Frida 定位与绕过常见反调试机制的方法解析

做 Android 逆向时,最容易让人“刚连上就掉线”的,不是复杂算法,也不是混淆代码,而是各种反调试机制。你可能刚把 Frida attach 上去,应用立刻闪退;或者 Java 层没问题,一碰 native 层就直接自杀;再或者脚本明明注入成功,但关键逻辑就是不走。

这篇文章我不打算只罗列“有哪些反调试”,而是从定位思路 + 绕过方法 + 实战脚本三个层面,带你完整走一遍。目标不是“背答案”,而是你下次遇到新样本时,能自己判断它卡在哪一层,再决定该 hook 什么。


背景与问题

Android 应用常见反调试,大致会落在下面几类:

  1. Java 层反调试

    • Debug.isDebuggerConnected()
    • Debug.waitingForDebugger()
    • 检查 ApplicationInfo.FLAG_DEBUGGABLE
    • 检测开发者选项、USB 调试状态
  2. Native 层反调试

    • 读取 /proc/self/status 查看 TracerPid
    • 调用 ptrace(PTRACE_TRACEME, ...)
    • 检查 ro.debuggablero.secure
    • 读取 /proc/net/tcp、进程 maps、线程名等识别 Frida/调试器
  3. 行为联动型反调试

    • attach 后延迟崩溃
    • 多线程循环检测
    • Java 与 JNI 双层校验
    • 检测端口、检测 frida-server、检测 gadget 特征

这些机制真正麻烦的地方在于:它们经常不是单点存在,而是组合使用。

比如我以前碰到一个样本,先在 Java 层用 Debug.isDebuggerConnected() 挡一遍,绕过后 native 再去读 /proc/self/status,最后还扫描线程名里有没有 gum-js-loop。如果只改一处,看起来像“成功了”,但业务逻辑仍会在后面悄悄退出。

所以本文的核心目标是:

  • 定位到底是哪类反调试生效;
  • 再用 Frida 做分层 hook;
  • 最后补上排查手段和边界条件,避免“脚本跑了但没效果”。

前置知识

建议你至少熟悉以下内容:

  • Android 基本组件与 APK 结构
  • Java 层与 JNI 调用关系
  • Frida 的基本使用:spawnattachJava.perform
  • Linux /proc 文件系统基础

如果你对 Frida 还比较生疏,可以先记住一个原则:

先用最小侵入方式观察,再做精准修改。

也就是先打印、再替换,先定位、再绕过。很多人一上来写一大坨“万能 bypass 脚本”,结果把 app 本身逻辑也打坏了。


环境准备

本文默认环境如下:

  • Android 真机或模拟器
  • 已运行 frida-server
  • PC 侧安装:
    • frida-tools
    • adb
    • Python 3(可选,用于启动脚本)
  • 目标 App 包名:com.example.target

常用命令:

adb shell ps | grep target
frida-ps -U
frida -U -f com.example.target -l anti_bypass.js --no-pause

这里我建议优先使用 spawn 模式

frida -U -f com.example.target -l anti_bypass.js --no-pause

因为很多反调试会在应用启动早期执行,attach 太晚,你甚至来不及 hook。


核心原理

反调试绕过的本质,不是“强行让 App 不检测”,而是篡改它看到的世界
也就是说:

  • 它问“是否连了调试器?”——你返回 false
  • 它读 TracerPid——你给它 0
  • 它调用 ptrace——你让它看起来调用成功,但实际没建立调试关系
  • 它扫描 Frida 痕迹——你截断关键字符串或隐藏可疑线程/端口信息

可以把整个流程理解成一个分层对抗:

flowchart TD
    A[目标应用启动] --> B[Java层检测]
    B --> C[JNI/Native层检测]
    C --> D[/proc 与系统属性检测]
    D --> E[Frida痕迹扫描]
    E --> F[崩溃/退出/功能禁用]

    X[Frida脚本] --> B
    X --> C
    X --> D
    X --> E

1. Java 层检测原理

Java 层通常调用 Android SDK 接口,例如:

  • android.os.Debug.isDebuggerConnected()
  • android.os.Debug.waitingForDebugger()

这类方法最适合直接 hook 返回值,因为调用点稳定、改动风险低。

2. Native 层检测原理

native 层通常更“接地气”,直接碰系统接口,例如:

  • ptrace
  • open/read/fgets 读取 /proc/self/status
  • __system_property_get
  • strcmp/strstr 检测 fridagum-js-loop

这部分难点不在 hook 技术本身,而在于你要先知道它用的是哪条路径。
我的经验是:

  • 先抓 libc 常用函数
  • 看参数
  • 再决定是不是替换返回值

3. 为什么要“定位优先”

不是每个样本都需要全量绕过。
如果你一口气把 open/read/strstr/ptrace/system_property_get 全改了,虽然“看起来很全面”,但副作用也会明显变大,比如:

  • 配置文件读取异常
  • 某些合法字符串匹配失效
  • 逻辑分支被误导
  • 性能下降

所以更稳妥的方法是:先观察调用,再做最小修改。


反调试定位路径

在实战里,我一般按下面顺序排查:

sequenceDiagram
    participant U as 分析者
    participant F as Frida
    participant A as 目标App
    participant N as Native/libc

    U->>F: spawn 注入脚本
    F->>A: Hook Java反调试接口
    A-->>F: 若仍异常
    F->>N: Hook ptrace/open/read/strstr/property_get
    N-->>F: 打印关键参数
    F-->>U: 输出命中路径
    U->>F: 精准替换返回值
    F->>A: 保持业务逻辑继续

具体判断依据可以这么看:

场景 A:一启动就闪退

优先怀疑:

  • attach 太晚
  • 启动早期 native 反调试
  • 多进程/壳保护提前检测

场景 B:注入成功,但点击功能按钮才退出

优先怀疑:

  • 关键页面才触发检测
  • 延迟线程轮询检测
  • JNI 业务函数内嵌反调试

场景 C:Java hook 全做了还是失败

优先怀疑:

  • native 层读 /proc
  • ptrace
  • Frida 痕迹扫描

实战代码(可运行)

下面给出一份可直接运行的 Frida 脚本,包含:

  • Java 层常见反调试 hook
  • native 层 ptrace 拦截
  • /proc/self/statusTracerPid 伪造
  • 系统属性伪造
  • frida 关键字匹配规避的基础示例

保存为 anti_bypass.js

'use strict';

function log(msg) {
    console.log('[*] ' + msg);
}

function hookJavaAntiDebug() {
    Java.perform(function () {
        try {
            var Debug = Java.use('android.os.Debug');

            Debug.isDebuggerConnected.implementation = function () {
                log('Debug.isDebuggerConnected() called -> false');
                return false;
            };

            Debug.waitingForDebugger.implementation = function () {
                log('Debug.waitingForDebugger() called -> false');
                return false;
            };

            log('Hooked android.os.Debug');
        } catch (e) {
            log('Hook android.os.Debug failed: ' + e);
        }

        try {
            var SettingsSecure = Java.use('android.provider.Settings$Secure');
            SettingsSecure.getInt.overload(
                'android.content.ContentResolver',
                'java.lang.String',
                'int'
            ).implementation = function (resolver, name, def) {
                var key = name ? name.toString() : '';
                if (key === 'adb_enabled') {
                    log('Settings.Secure.getInt(adb_enabled) -> 0');
                    return 0;
                }
                return this.getInt(resolver, name, def);
            };
            log('Hooked Settings.Secure.getInt');
        } catch (e) {
            log('Hook Settings.Secure failed: ' + e);
        }

        try {
            var AppInfo = Java.use('android.content.pm.ApplicationInfo');
            log('ApplicationInfo available: ' + AppInfo);
        } catch (e) {
            log('ApplicationInfo inspect failed: ' + e);
        }
    });
}

function hookPtrace() {
    var ptrace = Module.findExportByName(null, 'ptrace');
    if (!ptrace) {
        log('ptrace not found');
        return;
    }

    Interceptor.replace(ptrace, new NativeCallback(function (request, pid, addr, data) {
        log('ptrace called, request=' + request + ', pid=' + pid + ' -> return 0');
        return 0;
    }, 'int', ['int', 'int', 'pointer', 'pointer']));

    log('Hooked ptrace');
}

function hookSystemPropertyGet() {
    var sym = Module.findExportByName('libc.so', '__system_property_get');
    if (!sym) {
        log('__system_property_get not found');
        return;
    }

    Interceptor.attach(sym, {
        onEnter: function (args) {
            this.name = args[0].readCString();
            this.buf = args[1];
        },
        onLeave: function (retval) {
            if (!this.name) return;

            if (this.name === 'ro.debuggable') {
                this.buf.writeUtf8String('0');
                retval.replace(1);
                log('__system_property_get(ro.debuggable) -> 0');
            }

            if (this.name === 'ro.secure') {
                this.buf.writeUtf8String('1');
                retval.replace(1);
                log('__system_property_get(ro.secure) -> 1');
            }
        }
    });

    log('Hooked __system_property_get');
}

function hookOpenAndReadProc() {
    var openPtr = Module.findExportByName(null, 'open');
    var readPtr = Module.findExportByName(null, 'read');
    var closePtr = Module.findExportByName(null, 'close');

    if (!openPtr || !readPtr || !closePtr) {
        log('open/read/close not found');
        return;
    }

    var fdMap = {};

    Interceptor.attach(openPtr, {
        onEnter: function (args) {
            this.path = args[0].readCString();
        },
        onLeave: function (retval) {
            var fd = retval.toInt32();
            if (fd > 0 && this.path && this.path.indexOf('/proc/self/status') !== -1) {
                fdMap[fd] = this.path;
                log('open suspicious path: ' + this.path + ' fd=' + fd);
            }
        }
    });

    Interceptor.attach(readPtr, {
        onEnter: function (args) {
            this.fd = args[0].toInt32();
            this.buf = args[1];
            this.size = args[2].toInt32();
        },
        onLeave: function (retval) {
            var fd = this.fd;
            var len = retval.toInt32();

            if (len > 0 && fdMap[fd]) {
                try {
                    var content = Memory.readUtf8String(this.buf, len);
                    if (content.indexOf('TracerPid:') !== -1) {
                        var newContent = content.replace(/TracerPid:\s*\d+/g, 'TracerPid:\t0');
                        Memory.writeUtf8String(this.buf, newContent);
                        retval.replace(newContent.length);
                        log('Patched TracerPid -> 0');
                    }
                } catch (e) {
                    log('Patch /proc/self/status failed: ' + e);
                }
            }
        }
    });

    Interceptor.attach(closePtr, {
        onEnter: function (args) {
            var fd = args[0].toInt32();
            if (fdMap[fd]) {
                delete fdMap[fd];
            }
        }
    });

    log('Hooked open/read/close for /proc/self/status');
}

function hookStrstr() {
    var strstrPtr = Module.findExportByName(null, 'strstr');
    if (!strstrPtr) {
        log('strstr not found');
        return;
    }

    Interceptor.attach(strstrPtr, {
        onEnter: function (args) {
            this.haystack = args[0];
            this.needle = args[1];
            this.shouldFake = false;

            try {
                var needleStr = this.needle.readCString();
                if (
                    needleStr.indexOf('frida') !== -1 ||
                    needleStr.indexOf('gum-js-loop') !== -1 ||
                    needleStr.indexOf('gmain') !== -1
                ) {
                    this.shouldFake = true;
                    log('strstr check keyword: ' + needleStr);
                }
            } catch (e) {}
        },
        onLeave: function (retval) {
            if (this.shouldFake && !retval.isNull()) {
                retval.replace(ptr(0));
                log('strstr result forced to NULL');
            }
        }
    });

    log('Hooked strstr');
}

function main() {
    hookPtrace();
    hookSystemPropertyGet();
    hookOpenAndReadProc();
    hookStrstr();

    if (Java.available) {
        hookJavaAntiDebug();
    } else {
        log('Java not available');
    }
}

setImmediate(main);

启动命令:

frida -U -f com.example.target -l anti_bypass.js --no-pause

逐步验证清单

不要脚本一跑就默认“绕过成功”,建议按这个顺序验证。

第一步:先看 Java 层是否命中

如果日志里出现:

Debug.isDebuggerConnected() called -> false

说明 Java 层检测已经被截获。

第二步:观察 native 关键调用

重点看是否有下面日志:

ptrace called, request=0, pid=0 -> return 0
open suspicious path: /proc/self/status fd=xx
Patched TracerPid -> 0
__system_property_get(ro.debuggable) -> 0

如果这些完全没有命中,就不要死盯当前脚本,说明样本可能走了别的路径,比如:

  • fopen/fgets
  • syscall
  • openat
  • 直接内联读取
  • 检测 /proc/<pid>/task/*/status

第三步:验证业务功能是否恢复

有些应用不会闪退,而是“静默失效”,比如:

  • 登录后卡住
  • 某按钮点击无反应
  • 接口返回加密错误

这通常说明反调试已经影响到后续业务分支了。
这时你要继续定位哪个检测决定了逻辑流,而不是只看进程活着没。


常见反调试点的扩展处理

上面的脚本覆盖了常见路径,但实际中还经常会碰到下面几类。

1. fopen / fgets 读取 /proc/self/status

有些库不会用 open/read,而是 fopen/fgets。此时可以这样补:

'use strict';

var targetStreams = new Set();

var fopenPtr = Module.findExportByName(null, 'fopen');
var fgetsPtr = Module.findExportByName(null, 'fgets');

if (fopenPtr) {
    Interceptor.attach(fopenPtr, {
        onEnter: function (args) {
            this.path = args[0].readCString();
        },
        onLeave: function (retval) {
            if (!retval.isNull() && this.path.indexOf('/proc/self/status') !== -1) {
                targetStreams.add(retval.toString());
                console.log('[*] fopen /proc/self/status stream=' + retval);
            }
        }
    });
}

if (fgetsPtr) {
    Interceptor.attach(fgetsPtr, {
        onEnter: function (args) {
            this.buf = args[0];
            this.size = args[1].toInt32();
            this.stream = args[2];
        },
        onLeave: function (retval) {
            if (!retval.isNull() && targetStreams.has(this.stream.toString())) {
                try {
                    var line = this.buf.readCString();
                    if (line.indexOf('TracerPid:') !== -1) {
                        this.buf.writeUtf8String('TracerPid:\t0');
                        console.log('[*] fgets patched TracerPid');
                    }
                } catch (e) {}
            }
        }
    });
}

2. 检测 isDebuggerConnected 的调用链

有时你想知道是谁在调用,而不是只改返回值。
这时可以打印堆栈:

Java.perform(function () {
    var Debug = Java.use('android.os.Debug');
    var Log = Java.use('android.util.Log');
    var Exception = Java.use('java.lang.Exception');

    Debug.isDebuggerConnected.implementation = function () {
        console.log(Log.getStackTraceString(Exception.$new()));
        return false;
    };
});

这招非常适合定位“哪一个页面、哪一个 SDK”在做检查。

3. 检测 Frida 端口或线程名

一些样本会读取:

  • /proc/self/task/*/status
  • /proc/self/maps
  • 本地端口信息

如果你已经看到 gum-js-loopgmainfrida 等关键字命中,说明确实在做痕迹识别。
这时一般有两条路:

  • 轻量路子:继续 hook 字符串匹配类函数,如 strstrstrcmp
  • 稳妥路子:从注入方式、server 名称、端口暴露、gadget 配置上做隐藏

后者更工程化,但超出本文重点。


常见坑与排查

这一节很重要,因为很多“脚本无效”并不是脚本写错,而是场景判断错了。

坑 1:attach 模式太晚

现象:

  • frida -U -n 包名 一 attach 就崩
  • 或 attach 后完全抓不到关键调用

原因:

  • 反调试在 Application.attach 前后就执行了

处理:

  • 改用 spawn
  • 必要时 hook android_dlopen_ext,等待目标 so 加载后再下 native hook

示例:

'use strict';

var dlopen = Module.findExportByName(null, 'android_dlopen_ext');
if (dlopen) {
    Interceptor.attach(dlopen, {
        onEnter: function (args) {
            this.path = args[0].isNull() ? '' : args[0].readCString();
        },
        onLeave: function (retval) {
            if (this.path.indexOf('libtarget.so') !== -1) {
                console.log('[*] libtarget.so loaded');
            }
        }
    });
}

坑 2:只 hook 了 open,没 hook openat

现象:

  • 明明知道它会读 /proc/self/status
  • 但日志始终没命中

原因:

  • Android 上很多库会走 openat

处理:

  • 补 hook openat
'use strict';

var openatPtr = Module.findExportByName(null, 'openat');
if (openatPtr) {
    Interceptor.attach(openatPtr, {
        onEnter: function (args) {
            this.path = args[1].readCString();
        },
        onLeave: function (retval) {
            console.log('[*] openat path=' + this.path + ' fd=' + retval.toInt32());
        }
    });
}

坑 3:脚本把正常逻辑也破坏了

现象:

  • App 不崩了,但功能异常
  • 网络请求参数错乱
  • 页面空白

原因:

  • strstrstrcmp 这种基础函数 hook 过宽
  • 误伤正常字符串匹配

处理:

  • 加路径条件、线程条件、调用栈条件
  • 只在可疑场景下返回假结果

这是我个人很常踩的坑:为了图快,先全局拦 strstr("frida"),结果目标 App 某些日志过滤或配置解析也依赖同类接口,最后业务流程出问题。
所以越底层的 hook,越要小心范围。

坑 4:目标是 32 位/64 位不匹配

现象:

  • 注入失败
  • 能注入但关键 so 根本不在预期位置

处理:

  • 确认目标进程架构
  • 使用对应架构的 frida-server

查看方式:

adb shell getprop ro.product.cpu.abi
adb shell ps -A | grep target

坑 5:多进程导致你 hook 错了进程

现象:

  • 主进程正常,某功能一触发就失效
  • 日志看起来“啥都没发生”

原因:

  • 关键逻辑跑在子进程中

处理:

  • 枚举进程
  • frida-ps -Uai 看清楚
  • 对目标子进程单独注入

安全/性能最佳实践

逆向时我们关注“能不能绕过”,但从工程角度,脚本是否稳定也很关键。

stateDiagram-v2
    [*] --> 观察
    观察 --> 精准Hook
    精准Hook --> 验证功能
    验证功能 --> 扩大覆盖: 若仍被拦截
    扩大覆盖 --> 再验证
    验证功能 --> [*]: 功能恢复且副作用可控

1. 先观察后替换

优先级建议:

  1. 打印调用参数
  2. 确认检测点
  3. 只改必要返回值

不要一上来就“万能 bypass”。

2. 优先高层接口,谨慎动底层 libc

  • Java 层接口:改动小,影响面可控
  • libc 基础函数:覆盖广,但副作用大

如果 Java 层能解决,就不要先去全局拦 strstr

3. 给 hook 加条件

例如:

  • 只对 /proc/self/status 生效
  • 只对 ro.debuggable 生效
  • 只在返回值非空时替换
  • 只处理包含 frida 的 needle

这会显著减少误伤。

4. 处理时序问题

如果目标 so 是延迟加载的,你的 hook 太早或太晚都可能无效。
建议对 dlopen/android_dlopen_ext 加观察,确认 so 装载时机。

5. 输出日志要克制

日志太多会拖慢应用,甚至影响时序。
我通常建议:

  • 定位阶段:多打日志
  • 稳定阶段:保留关键命中日志
  • 批量分析:关闭大部分输出

6. 明确边界条件

Frida 适合做动态定位和轻量绕过,但对于下面场景不一定总是最优:

  • 强壳/虚拟化保护
  • 自定义 syscall 内联实现
  • 完全自修改代码
  • 多层 watchdog 互相拉起

这时可能需要配合:

  • 静态补丁
  • inline hook 框架
  • so 重打包
  • 内核级观测

一个更稳妥的实战思路模板

如果你下次拿到一个“疑似有反调试”的 APK,可以直接按这个流程走:

第 1 步:确认现象

  • 是启动闪退?
  • 还是功能点击后退出?
  • 是主进程还是子进程?

第 2 步:spawn 注入基础观察脚本

先 hook:

  • Debug.isDebuggerConnected
  • ptrace
  • open/openat
  • __system_property_get
  • strstr

第 3 步:找命中最高的检测点

例如日志反复显示:

  • 连续读取 /proc/self/status
  • 持续匹配 gum-js-loop
  • 启动时马上 ptrace

就优先围绕这一点做精准处理。

第 4 步:只改必要路径

例如只 patch TracerPid,不要全局魔改所有 /proc 内容。

第 5 步:验证核心业务功能

不是“进程没死”就算成功,而是:

  • 页面能打开
  • 核心功能可用
  • 网络请求正常
  • JNI 逻辑能继续执行

总结

Android 反调试绕过,真正关键的不是“收集多少 hook 代码”,而是形成一套分层定位思维

  • Java 层先看 Debug 类接口
  • native 层重点盯 ptrace/proc/self/status、系统属性
  • Frida 痕迹识别则从字符串匹配、线程名、maps、端口几个方向排查

如果你只记住一条建议,我会建议你记这个:

先用 spawn 抢时机,再用日志找路径,最后做最小范围绕过。

这样做虽然比“万能脚本一把梭”慢一点,但稳定得多,也更适合中级逆向分析者真正建立自己的方法论。

如果当前样本仍然绕不过,别急着怀疑 Frida,本质上大多是以下三件事之一:

  • hook 时机不对
  • 检测路径没找全
  • hook 范围过大导致误伤

把这三个问题逐一排掉,绝大多数常见 Android 反调试都能被拆开看清楚。


分享到:

上一篇
《Node.js 中基于 BullMQ 与 Redis 的高可靠任务队列实战:重试、延迟任务与失败恢复设计》
下一篇
《自动化测试中的稳定性治理实战:从脆弱用例定位到测试流水线降噪优化》