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

《安卓逆向实战:从 SO 层入手定位并绕过常见签名校验与反调试机制》

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

安卓逆向实战:从 SO 层入手定位并绕过常见签名校验与反调试机制

很多 Android 应用把关键校验逻辑从 Java 层下沉到 Native,也就是 .so 里。原因很直接:Java 层太“透明”,被 Jadx 一扫基本就能看个七七八八;而 SO 层至少还能靠 JNI、字符串混淆、反调试拖慢分析速度。

这篇文章我想换一个更贴近实战的角度:不从“代码漂亮不漂亮”入手,而是从“怎么尽快找到关键点并验证思路”入手。目标是带你完成一条比较完整的链路:

  1. 识别 App 是否把签名校验放到了 SO 层
  2. 在 JNI 注册或导出函数附近定位校验逻辑
  3. 分析常见签名比对方式
  4. 识别并处理常见反调试
  5. 用动态插桩完成验证

说明:本文内容用于安全研究、加固分析、攻防演练与合规测试,请勿用于未授权目标。


背景与问题

在实际项目里,SO 层常见的两类保护是:

  • 签名校验:检查 APK 当前签名是否与预期一致,防止重打包
  • 反调试:阻止你附加调试器、阻止 Frida/Xposed 一类动态分析工具

很多同学卡住,通常不是不会用工具,而是不知道先看哪里。尤其是 JNI 层代码一旦经过 OLLVM、字符串拆分、导出表裁剪,看上去像一团麻。

我当时最常踩的坑有两个:

  • 一上来就硬啃反编译伪代码,结果浪费很多时间
  • 看到 ptrace/proc/self/statusTracerPid 就想一把梭 patch,结果程序后面还有二次检测

所以这篇教程强调一个策略:先建立“定位路径”,再决定是 patch、hook 还是绕过环境检测。


前置知识

建议你至少具备这些基础:

  • 会用 jadx 看 Java 层调用关系
  • 知道 JNI 的基本结构:Java_xxx 导出函数、JNI_OnLoad、动态注册
  • 会用 adb logcat
  • 用过至少一种动态工具:Frida / gdbserver / IDA debugger

如果你对 ELF、JNI 签名还不熟,也没关系,本文会尽量边走边解释。


环境准备

一个常见的分析环境可以是:

  • Android 模拟器或测试机
  • adb
  • jadx
  • IDAGhidra
  • readelf / nm / strings
  • Frida
  • Python 3

示例命令行工具准备:

adb devices
readelf --version
python3 --version
frida --version

分析总流程

先给一张全局图,避免中途迷路。

flowchart TD
    A[用 Jadx 找 native 调用点] --> B[确认加载了哪些 so]
    B --> C[看导出符号和 JNI_OnLoad]
    C --> D[定位签名校验逻辑]
    D --> E[识别反调试点]
    E --> F[选择验证手段]
    F --> G[Frida Hook]
    F --> H[二进制 Patch]
    G --> I[验证功能是否恢复]
    H --> I

背景样例:Java 层如何把你引到 SO

现实中经常能看到类似代码:

public class SecurityBridge {
    static {
        System.loadLibrary("sec");
    }

    public native boolean checkSignature(android.content.Context context);

    public native int antiDebug();

    public boolean verify(android.content.Context context) {
        return checkSignature(context) && antiDebug() == 0;
    }
}

这段代码已经告诉我们两件事:

  1. 核心逻辑在 libsec.so
  2. Java 层只是桥,真正的判定要去 Native 看

如果是静态注册,你会在导出表里直接看到类似:

  • Java_com_xxx_SecurityBridge_checkSignature
  • Java_com_xxx_SecurityBridge_antiDebug

但很多应用用的是动态注册,这时你在导出表里未必找得到目标函数名。


核心原理

这一节不讲太散,只盯住最常见的几个点。

1. SO 层签名校验通常怎么做

Native 层签名校验常见路线大概有三类:

路线 A:通过 PackageManager 拿签名摘要再比对

流程通常是:

  • 通过 JNI 调 Java API
  • Context.getPackageManager()
  • PackageManager.getPackageInfo()
  • signaturessigningInfo
  • 对签名做 MD5/SHA1/SHA256
  • 与 SO 内硬编码值比较

这类逻辑的特点是:一定会出现大量 JNI 调用,比如:

  • FindClass
  • GetMethodID
  • CallObjectMethod
  • GetArrayLength
  • GetObjectArrayElement
  • GetByteArrayElements

路线 B:直接读取 APK / META-INF

有些应用会直接解析 APK 里的证书文件或 V2/V3 签名块。
这种方式更“底层”,但复杂度高一些,通常会出现:

  • ZIP 解析
  • 证书解析
  • 哈希函数调用

路线 C:Java 层拿到结果,SO 层只做对比

比如 Java 层先取签名字符串,再传给 Native 比较。
这种方式的优点是开发方便,缺点是入口更明显。


2. SO 层反调试通常怎么做

最常见的反调试手法其实就那么几类:

  • ptrace(PTRACE_TRACEME, ...)
  • /proc/self/statusTracerPid
  • /proc/self/maps 查 Frida/Xposed 关键字
  • 枚举端口、线程名、进程名
  • 时间差检测,防单步调试
  • 检测 ro.debuggablero.secure 等系统属性

这些检测常常不是只做一次,而是:

  • 启动时做一次
  • 关键功能调用前再做一次
  • 后台线程循环做

这就是为什么只 patch 一个位置经常不够。


3. 为什么 JNI_OnLoad 是高价值入口

动态注册一般会在 JNI_OnLoad 里做:

  • 找类
  • JNINativeMethod
  • RegisterNatives

所以只要你定位到 JNI_OnLoad,大概率就能顺着 JNINativeMethod 找到:

  • Java 方法名
  • JNI 签名
  • Native 函数地址

这比盲目在整个 SO 里搜字符串效率高太多。


定位思路:从 JNI_OnLoad 找到目标函数

先看一个典型流程图。

sequenceDiagram
    participant Java as Java层
    participant Loader as System.loadLibrary
    participant SO as libsec.so
    participant JNI as JNI_OnLoad
    participant Reg as RegisterNatives

    Java->>Loader: loadLibrary("sec")
    Loader->>SO: 加载 ELF
    SO->>JNI: 调用 JNI_OnLoad
    JNI->>Reg: RegisterNatives(methods)
    Reg-->>Java: native 方法与地址绑定
    Java->>SO: 调用 checkSignature/antiDebug

步骤 1:确认目标 SO

adb shell pm path com.example.target
adb pull /data/app/~~xxx/base.apk .
unzip -l base.apk | grep '\.so'

如果是多 ABI,优先看设备实际加载的架构,比如 arm64-v8a


步骤 2:看导出符号

readelf -Ws libsec.so | grep -E 'JNI_OnLoad|Java_'

如果能看到 Java_...checkSignature,说明是静态注册,直接进函数。
如果只有 JNI_OnLoad,大概率是动态注册。


步骤 3:在 IDA/Ghidra 中跟 JNI_OnLoad

动态注册常见伪代码像这样:

static JNINativeMethod methods[] = {
    {"checkSignature", "(Landroid/content/Context;)Z", (void *)sub_1234},
    {"antiDebug", "()I", (void *)sub_2345}
};

如果字符串被拆了,也别慌。你仍然可以看:

  • RegisterNatives 的参数
  • 数组内每项是否呈现“方法名指针 / 签名指针 / 函数地址”结构
  • 附近是否有 FindClass("com/example/SecurityBridge")

实战代码:用 Frida 快速验证签名校验与反调试点

下面给一套可运行的 Frida 脚本,用来做两件事:

  1. Hook RegisterNatives,打印动态注册信息
  2. Hook ptrace/proc/self/status 读取,辅助定位反调试

使用前请确保目标环境允许 Frida 注入,包名按实际替换。

1)打印动态注册的 native 方法

// file: hook_register_natives.js
'use strict';

function hookRegisterNatives() {
    const addr = Module.findExportByName(null, 'RegisterNatives');
    if (addr) {
        console.log('[!] RegisterNatives exported directly:', addr);
    }

    Java.perform(function () {
        const env = Java.vm.getEnv();
        console.log('[*] Java VM Env ready:', env);
    });

    const libart = Process.findModuleByName('libart.so');
    if (!libart) {
        console.log('[-] libart.so not found');
        return;
    }

    const symbols = libart.enumerateSymbols();
    let target = null;
    for (const s of symbols) {
        if (s.name.indexOf('RegisterNatives') !== -1 &&
            s.name.indexOf('JNI') !== -1) {
            target = s.address;
            console.log('[+] Found candidate:', s.name, s.address);
            break;
        }
    }

    if (!target) {
        console.log('[-] RegisterNatives symbol not found in libart');
        return;
    }

    Interceptor.attach(target, {
        onEnter(args) {
            const env = args[0];
            const jclass = args[1];
            const methods = args[2];
            const count = args[3].toInt32();

            console.log('\n[*] RegisterNatives called, count =', count);

            for (let i = 0; i < count; i++) {
                const base = methods.add(i * Process.pointerSize * 3);
                const namePtr = base.readPointer();
                const sigPtr = base.add(Process.pointerSize).readPointer();
                const fnPtr = base.add(Process.pointerSize * 2).readPointer();

                let name = '';
                let sig = '';
                try { name = namePtr.readCString(); } catch (e) {}
                try { sig = sigPtr.readCString(); } catch (e) {}

                const module = Process.findModuleByAddress(fnPtr);
                console.log(`  [${i}] ${name} ${sig} => ${fnPtr} ${module ? module.name : ''}`);
            }
        }
    });
}

setImmediate(hookRegisterNatives);

运行方式:

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

如果输出里看到了:

  • checkSignature (Landroid/content/Context;)Z => 0x...
  • antiDebug ()I => 0x...

那后面就可以直接对这些地址继续下手。


2)Hook 常见反调试接口

// file: hook_antidebug.js
'use strict';

function hookPtrace() {
    const ptraceAddr = Module.findExportByName(null, 'ptrace');
    if (!ptraceAddr) {
        console.log('[-] ptrace not found');
        return;
    }

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

    console.log('[+] ptrace replaced');
}

function hookOpen() {
    const openAddr = Module.findExportByName(null, 'open');
    if (!openAddr) {
        console.log('[-] open not found');
        return;
    }

    Interceptor.attach(openAddr, {
        onEnter(args) {
            this.path = args[0].readCString();
            if (this.path.indexOf('/proc/self/status') !== -1 ||
                this.path.indexOf('/proc/self/maps') !== -1) {
                console.log('[*] open =>', this.path);
            }
        }
    });

    console.log('[+] open hooked');
}

function hookRead() {
    const readAddr = Module.findExportByName(null, 'read');
    if (!readAddr) {
        console.log('[-] read not found');
        return;
    }

    Interceptor.attach(readAddr, {
        onEnter(args) {
            this.fd = args[0].toInt32();
            this.buf = args[1];
        },
        onLeave(retval) {
            const size = retval.toInt32();
            if (size > 0 && size < 4096) {
                try {
                    const text = this.buf.readUtf8String(size);
                    if (text && text.indexOf('TracerPid:') !== -1) {
                        const patched = text.replace(/TracerPid:\s*\d+/g, 'TracerPid:\t0');
                        Memory.writeUtf8String(this.buf, patched);
                        console.log('[+] Patched TracerPid => 0');
                    }
                } catch (e) {}
            }
        }
    });

    console.log('[+] read hooked');
}

setImmediate(function () {
    hookPtrace();
    hookOpen();
    hookRead();
});

运行:

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

实战代码:直接 Hook Native 函数返回值

如果你已经通过 RegisterNatives 找到了 checkSignatureantiDebug 的函数地址,可以直接改返回值完成验证。

下面演示一个更常用的写法:等待目标 SO 加载,再按偏移 Hook。

偏移值需要你自己在 IDA/Ghidra 中确认。

// file: hook_native_return.js
'use strict';

const soName = 'libsec.so';
const checkOffset = 0x1234;   // 示例偏移
const antiOffset = 0x2345;    // 示例偏移

function waitForModule(name, callback) {
    const timer = setInterval(function () {
        const m = Process.findModuleByName(name);
        if (m) {
            clearInterval(timer);
            callback(m);
        }
    }, 100);
}

waitForModule(soName, function (m) {
    console.log('[+] module loaded:', m.name, m.base);

    const checkAddr = m.base.add(checkOffset);
    const antiAddr = m.base.add(antiOffset);

    Interceptor.attach(checkAddr, {
        onLeave(retval) {
            console.log('[*] checkSignature original =>', retval.toInt32());
            retval.replace(1);
            console.log('[+] checkSignature forced => 1');
        }
    });

    Interceptor.attach(antiAddr, {
        onLeave(retval) {
            console.log('[*] antiDebug original =>', retval.toInt32());
            retval.replace(0);
            console.log('[+] antiDebug forced => 0');
        }
    });
});

运行:

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

这个脚本的意义在于:
先验证你的定位是否正确,再决定要不要落地 patch。

我个人很少上来就改二进制,通常都会先这样动态验证一次。因为只要验证通过,后续无论是 NOP 某个分支,还是改常量,心里都更有底。


如果要做静态 Patch,通常改哪里

这部分只讲高层思路,不展开危险细节。

常见 patch 点:

  • 签名比对结果处,把失败分支改成功分支
  • 反调试函数统一返回“未检测到调试器”
  • ptrace 调用前后直接跳过
  • strcmp/memcmp 结果判断处强制置零

一个典型判断逻辑可能是:

if (memcmp(calc_digest, expected_digest, 32) != 0) {
    return 0;
}
return 1;

此时 patch 的思路可能有两种:

  1. 改条件跳转,让失败不成立
  2. 直接让函数末尾固定返回成功

从维护性来说,我更推荐尽量 patch 最终判断,而不是中间数据流,因为中间数据流容易牵连更多副作用。


如何识别“签名校验”而不是别的校验

很多人会把设备完整性校验、环境检测、资源校验误认为签名校验。可以从特征入手。

classDiagram
    class SignatureCheck {
        +PackageManager
        +PackageInfo
        +SigningInfo/Signature
        +Digest compare
    }

    class AntiDebug {
        +ptrace
        +TracerPid
        +maps scan
        +thread/process scan
    }

    class EnvCheck {
        +ro.debuggable
        +su path
        +magisk artifact
        +test-keys
    }

签名校验的高频特征

  • 出现包名相关 API
  • 出现 PackageInfoSignatureSigningInfo
  • 出现证书摘要算法 SHA1SHA256
  • 出现与固定摘要值的比较

反调试的高频特征

  • ptrace
  • /proc/self/status
  • TracerPid
  • 线程轮询
  • 调试端口或 Frida 特征字符串

环境检测的高频特征

  • /system/bin/su
  • magisk
  • test-keys
  • ro.debuggable

把这三类区分开,分析会快很多。


逐步验证清单

建议按下面顺序做,每一步都留证据,不要跳步。

第 1 步:确认 Native 是否参与决策

  • Java 层是否有 native 方法
  • 是否 System.loadLibrary
  • 崩溃或弹窗前是否调用 Native 方法

第 2 步:找到入口函数

  • 导出表是否有 Java_
  • 没有则看 JNI_OnLoad
  • Hook RegisterNatives

第 3 步:判断是哪类校验

  • 是签名校验?
  • 是环境检测?
  • 还是反调试?

第 4 步:动态验证

  • Hook 返回值
  • Hook ptrace
  • Hook /proc/self/status

第 5 步:决定最终策略

  • 仅研究验证:Frida 即可
  • 需要离线样本分析:二进制 patch
  • 需要长期自动化:脚本化定位流程

常见坑与排查

这部分很重要,很多时间其实都花在这些坑上。

1. 只 Hook 了 Java 层,结果没效果

原因通常是:

  • Java 层只是桥
  • 真正判断在 SO 层
  • 甚至 Java 层返回值会被 Native 二次确认

排查方法:

adb logcat | grep -i -E 'jni|native|sig|debug'

再配合 RegisterNatives Hook 看实际调用函数。


2. 找到了 ptrace,绕过后还是闪退

常见原因:

  • 还有 TracerPid 检测
  • 还有 /proc/self/maps 扫描 Frida
  • 有 watchdog 线程在循环检查
  • Native 层检测到 Hook 痕迹

处理思路:

  • 同时 Hook ptraceopenread
  • 观察是否有后台线程持续访问 /proc
  • 关注 pthread_create 创建的线程入口

3. IDA 里偏移对不上,Frida Hook 失败

原因通常包括:

  • 你看的是文件偏移,不是虚拟地址相对偏移
  • arm32/thumb 地址最低位问题
  • 目标实际加载的是另一个 ABI 的 SO
  • App 有壳,真正 SO 是运行时解密释放的

建议这样确认:

Process.enumerateModules().forEach(function (m) {
    if (m.name.indexOf('sec') !== -1) {
        console.log(m.name, m.base, m.size);
    }
});

如果是 32 位 ARM,还要确认 Thumb 模式地址问题。


4. Hook 太早或太晚

  • 太早:SO 还没加载
  • 太晚:校验已经做完,App 已经退出

处理方式:

  • -f 启动并 --no-pause
  • 轮询等待模块加载
  • 必要时 Hook android_dlopen_ext

示例:

// file: hook_dlopen.js
'use strict';

const dlopen = Module.findExportByName(null, 'android_dlopen_ext');
if (dlopen) {
    Interceptor.attach(dlopen, {
        onEnter(args) {
            this.name = args[0].readCString();
        },
        onLeave(retval) {
            if (this.name && this.name.indexOf('libsec.so') !== -1) {
                console.log('[+] loaded:', this.name);
            }
        }
    });
}

5. 看到很多字符串比较,不知道哪个才是关键

经验上优先看:

  • 最终返回值附近的 strcmp/memcmp
  • 与证书摘要长度匹配的比较
  • 失败后直接导致 exit/abort/raise(SIGKILL) 的分支

不要一上来就追所有字符串比较,不然很容易陷进去。


安全/性能最佳实践

虽然这是逆向分析文章,但在操作层面也有“最佳实践”。

1. 先动态验证,后静态 Patch

这是最重要的一条。
原因很简单:

  • 动态验证成本低
  • 回滚方便
  • 更容易确认哪个点是“最小有效改动”

如果动态改返回值都无效,说明你定位点大概率不对,或者还有联动校验。


2. 尽量最小化 Hook 范围

不要无脑全局 Hook 大量 libc API,尤其在性能敏感或多线程场景中。
建议:

  • 先精确 Hook 某个 Native 目标函数
  • 再按需补 ptrace/open/read
  • 打印日志时控制频率

3. 注意多线程与重复检测

反调试常常由后台线程触发。
如果你只在主线程行为上做验证,很可能误判“已经绕过”。

可以关注:

  • pthread_create
  • 定时轮询
  • prctl 设置线程名

4. 区分研究环境与生产环境

  • 测试机可以 root、可以 Frida
  • 真实设备环境可能有 SELinux、厂商 ROM 差异、ABI 差异

所以结论一定要带边界条件。
例如:某个 Hook 在模拟器有效,不代表在所有真机都有效。


5. 对开发者的反向建议

如果你是从防护视角看这篇文章,那么建议是:

  • 不要只做单点签名校验
  • 不要只依赖 ptrace
  • 重要校验应多点、多时机触发
  • 校验结果不要只走单一布尔返回值
  • 尽量把校验和业务状态绑定,而不是“校验失败就弹 toast”

单点防护,在动态插桩面前通常撑不了多久。


一个更实用的定位策略总结

我把自己常用的方法压缩成一句话:

先抓注册,再抓返回,再抓检测点,最后才考虑 patch。

对应到行动上就是:

  1. Jadx 找 native 入口
  2. readelfJNI_OnLoad / 导出函数
  3. Frida Hook RegisterNatives 拿真实函数地址
  4. 先强改返回值验证
  5. 再补反调试绕过
  6. 最后决定是否做静态 patch

这个路径的好处是,不容易一头扎进伪代码细节里出不来。


总结

从 SO 层定位并绕过签名校验与反调试,核心不是“把所有代码都看懂”,而是建立一条可靠的分析路径:

  • 签名校验优先看 JNI 调 Java 取签名、摘要计算、最终比较
  • 反调试优先看 ptraceTracerPidmaps 扫描、后台线程
  • 动态注册优先从 JNI_OnLoad -> RegisterNatives 入手
  • 验证策略优先用 Frida 改返回值,而不是直接 patch 二进制

如果你是第一次系统做这类分析,我建议你先拿一个结构比较简单的练手样本,只做三件事:

  1. 打印 RegisterNatives
  2. 找到一个签名校验函数
  3. 找到一个反调试函数并动态绕过

把这三个动作跑通,后面遇到混淆更重的 SO,思路也不会乱。

最后再强调一次边界:本文方法适合授权测试、安全研究、样本分析。在复杂商业壳、虚拟化保护、内联混淆很重的场景下,单纯依靠本文流程未必一步到位,但它仍然是非常稳的起点。


分享到:

上一篇
《Spring Boot 3 实战:基于 Spring Security 与 JWT 的前后端分离鉴权体系搭建与权限控制》
下一篇
《大模型应用落地实战:基于 RAG 构建企业知识库问答系统的关键技术与性能优化》