本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Cydia Substrate是一款强大的Native层Hook框架,由Saurik开发,最初用于iOS,后扩展至Android平台。它允许在不修改原始二进制文件的前提下,通过运行时函数替换实现对应用行为的动态修改,广泛应用于调试、性能监控、安全分析和插件化开发。该工具通过干预Dalvik/ART运行时机制,支持C/C++编写的注入代码,具备低侵入性和高灵活性。本文结合cydia_substrate压缩包中的库文件、头文件与示例代码,深入讲解其工作原理与使用方法,帮助开发者掌握在Android系统中实现函数Hook的核心技术。

Cydia Substrate 与 Native Hook 技术深度解析:从原理到实战的全链路实践

在移动安全、逆向工程和系统级定制的世界里,有一项技术如同“暗影中的手术刀”——它能在不修改原始代码的前提下,悄无声息地改变程序行为。这项技术就是 Cydia Substrate ,一个深藏于越狱生态底层却影响深远的运行时 Hook 框架。

你是否曾好奇过:
- 那些自动抢红包的插件是如何“看穿”微信界面并点击弹窗的?
- 安全研究人员如何绕过 SSL Pinning 来抓取加密流量?
- 游戏外挂为何能实时修改内存数值而不崩溃?

答案都指向同一个核心技术: Native 层函数 Hook 。而 Cydia Substrate 正是这把万能钥匙的制造者之一。

今天,我们就来揭开它的神秘面纱,从 ARM 架构下的寄存器约定讲起,一路深入 ELF 文件结构、GOT/PLT 机制、Zygote 注入流程,最终亲手打造一个能够监控敏感文件访问的 Substrate 插件。准备好了吗?🚀


我们先从最基础的问题开始:当一个函数被调用时,CPU 到底发生了什么?

🧠 函数调用的本质:不只是跳转那么简单

想象一下你在写 C 语言:

int add(int a, int b) {
    return a + b;
}

看似简单的一行 return ,背后其实是一整套精密协作的硬件与软件规则。尤其是在 Android 广泛使用的 ARM32 架构上,这套规则被称为 AAPCS(ARM Architecture Procedure Call Standard)

根据 AAPCS,前四个参数通过 r0 ~ r3 寄存器传递,返回值也放在 r0 中。如果超过四个参数,则多余的部分会压入栈中。例如:

add:
    push    {r11, lr}         @ 保存帧指针和返回地址
    add     r0, r0, r1        @ r0 = r0 + r1 (a + b)
    pop     {r11, pc}         @ 恢复并返回(pc 接收 lr 值)

注意这里的 lr (链接寄存器),它保存了函数调用后的返回地址。一旦你在 Hook 时不小心破坏了这个寄存器的状态,整个调用链就会断裂,导致应用直接 crash 💥。

所以任何 Hook 操作的第一铁律是: 尊重调用约定,保护现场状态


📦 ELF 文件:.so 库的“解剖图”

Android 的 Native 库以 .so 形式存在,本质是一个 ELF(Executable and Linkable Format) 文件。要精准定位目标函数,必须读懂它的内部结构。

你可以用 readelf 工具查看一个典型的 .so 文件:

readelf -s libnative.so | grep encrypt_data

输出可能如下:

   124: 0001a3f0    76 FUNC    GLOBAL DEFAULT   12 encrypt_data

这意味着 encrypt_data 函数位于偏移 0x1A3F0 处。但在真实进程中,由于 ASLR(地址空间布局随机化),实际地址需要加上模块基址。

怎么获取基址呢?很简单,读 /proc/self/maps 就行:

void* get_module_base(pid_t pid, const char* module_name) {
    FILE* fp;
    long addr = 0;
    char filename[32];
    char line[1024];

    snprintf(filename, sizeof(filename), "/proc/%d/maps", pid);
    fp = fopen(filename, "r");
    if (fp != NULL) {
        while (fgets(line, sizeof(line), fp)) {
            if (strstr(line, module_name)) {
                char* pch = strtok(line, "-");
                addr = strtoul(pch, NULL, 16);
                break;
            }
        }
        fclose(fp);
    }
    return (void*)addr;
}

然后计算真实地址:

void* base = get_module_base(getpid(), "libnative.so");
void* target_func = (char*)base + 0x1A3F0;

搞定!现在你已经拿到了函数的“物理坐标”,接下来就可以动手改写了。


🔗 GOT/PLT:延迟绑定的秘密通道

现代共享库广泛使用 延迟绑定(Lazy Binding) ,也就是只有第一次调用某个外部函数(如 printf )时才去查找其真实地址。这种机制依赖两个关键表: GOT(Global Offset Table) PLT(Procedure Linkage Table)

它们的工作流程可以用下面这张图表示:

sequenceDiagram
    participant App
    participant PLT
    participant GOT
    participant Linker

    App->>PLT: call plt_entry@printf
    PLT->>GOT: jmp *got_entry@printf
    alt 第一次调用
        GOT-->>Linker: 指向 resolve_stub
        Linker->>Linker: 查找 printf 地址
        Linker->>GOT: 填写真实地址
        Linker->>App: 跳转至 printf
    else 后续调用
        GOT->>App: 直接跳转至 printf
    end

看到了吗?中间有个“中间商” GOT。既然如此,我们能不能偷偷改掉 GOT 里的地址,让它指向我们的代理函数?

当然可以!这就是所谓的 GOT Hook

✅ GOT Hook 示例代码:
#include <link.h>
#include <dlfcn.h>

int got_hook(const char *symbol, void *new_func, void **old_func) {
    struct link_map *lm;
    dlinfo(RTLD_DEFAULT, RTLD_DI_LINKMAP, &lm);

    for (; lm; lm = lm->l_next) {
        Elf32_Dyn *dyn = lm->l_ld;
        Elf32_Sym *symtab = NULL;
        const char *strtab = NULL;
        Elf32_Rel *plt_rel = NULL;
        int rel_cnt = 0;

        while (dyn->d_tag != DT_NULL) {
            switch (dyn->d_tag) {
                case DT_SYMTAB: symtab = (Elf32_Sym *)dyn->d_un.d_ptr; break;
                case DT_STRTAB: strtab = (const char *)dyn->d_un.d_ptr; break;
                case DT_JMPREL: plt_rel = (Elf32_Rel *)dyn->d_un.d_ptr; break;
                case DT_PLTRELSZ: rel_cnt = dyn->d_un.d_val / sizeof(Elf32_Rel); break;
            }
            dyn++;
        }

        if (!symtab || !strtab || !plt_rel) continue;

        for (int i = 0; i < rel_cnt; i++) {
            Elf32_Word sym_idx = ELF32_R_SYM(plt_rel[i].r_info);
            const char *name = strtab + symtab[sym_idx].st_name;

            if (strcmp(name, symbol) == 0) {
                void **got_entry = (void **)(lm->l_addr + plt_rel[i].r_offset);
                *old_func = *got_entry;
                mprotect(got_entry, sizeof(void*), PROT_READ | PROT_WRITE);
                *got_entry = new_func;
                return 0;
            }
        }
    }
    return -1;
}

调用方式也很简单:

void* my_open_proxy(const char* path, int flags, mode_t mode);
void* orig_open;

got_hook("open", my_open_proxy, &orig_open);

从此以后,所有对 open 的调用都会先经过你的代理函数。是不是很酷?😎

但要注意:GOT Hook 只能用于外部函数调用(即导入函数),对于库内部定义的静态函数无效。这时候就得靠另一种更暴力的方式——Inline Hook。


🔪 Inline Hook:直接篡改函数开头

Inline Hook 的思路非常直接:在目标函数开头插入一条跳转指令,强制控制流转向你的 Hook 函数。为了保证原逻辑还能执行,你还得备份被覆盖的原始指令,并构建一个“跳板”(Trampoline)来恢复执行。

以 ARM32 为例,最短跳转指令是 ldr pc, [pc, #-4] ,占 4 字节。所以我们至少要备份 4 字节原指令。

🛠️ 实现步骤:
  1. 分配可执行内存作为 Trampoline
  2. 备份原函数前 N 字节指令
  3. 写入跳转指令指向 Hook 函数
  4. 在 Trampoline 中重建上下文并跳回原函数剩余部分
void inline_hook(void *target_func, void *hook_func, void **orig_ptr) {
    void *trampoline = mmap(NULL, 0x1000,
                            PROT_READ | PROT_WRITE | PROT_EXEC,
                            MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);

    uint32_t original_code[2];
    memcpy(original_code, target_func, 8);

    uint32_t jump_insn = 0xE51FF004; // ldr pc, [pc, #-4]
    uint32_t target_addr = (uint32_t)hook_func;

    mprotect(target_func, 8, PROT_READ | PROT_WRITE | PROT_EXEC);
    *(uint32_t*)target_func = jump_insn;
    *((uint32_t*)target_func + 1) = (uint32_t)&target_addr;

    uint32_t *t = (uint32_t*)trampoline;
    memcpy(t, original_code, 8);
    t[2] = 0xE51FF004;
    t[3] = (uint32_t)((char*)target_func + 8);

    *orig_ptr = trampoline;
}

这种方式适用于所有本地函数,包括那些没有导出符号的私有函数。但它也有风险:如果多个线程同时进入被 Hook 的函数,可能会出现竞态条件。因此建议配合信号量或内存屏障使用。


⚖️ 三种 Hook 方式的对比

方法 原理 优点 缺点
Inline Hook 修改函数开头为跳转指令 精确控制单个函数 易受并发影响,需备份原指令
GOT Hook 修改 GOT 表项指向新地址 全局生效,安全性高 仅能 Hook 导入函数
PLT Hook 操作 PLT 段而非 GOT 可绕过某些加固检测 不同 ABI 差异大,兼容性差

实际项目中往往结合多种方式协同工作。比如先用 GOT Hook 拦截 dlopen ,再在 dlopen 回调中对新加载的库进行 Inline Hook。


🔄 Cydia Substrate 是如何做到跨平台兼容的?

Substrate 最厉害的地方在于它能同时支持 Dalvik ART 运行时环境。要知道,从 Dalvik 到 ART 的转变不仅仅是 JIT → AOT 编译那么简单,整个 JNI 调用机制都变了。

Dalvik:解释执行时代的自由

在 Dalvik 时代,Java 方法大多通过 RegisterNatives 动态注册到 Native 函数。这意味着只要在 JNI_OnLoad 之后、方法首次调用之前完成 Hook,就能成功替换函数指针。

而且 Dalvik 对内存保护较弱,允许 mprotect(PROT_EXEC) 修改 .text 段权限,Inline Hook 很容易实现。

graph TD
    A[Zygote Fork] --> B[加载 libdvm.so]
    B --> C[调用 System.loadLibrary]
    C --> D[执行 JNI_OnLoad]
    D --> E[注册 Native 方法]
    E --> F[MSHookFunction 拦截 dlopen/dlsym]
    F --> G[替换目标函数]

整个流程清晰可控,Substrate 可以轻松注入并建立 Hook。

ART:AOT 编译带来的挑战

到了 ART 时代,问题变得复杂了。应用安装时就被编译成 .oat 文件,Java 方法可能已经被内联或静态绑定。更重要的是,VM 维护了一张 jniCode 表,缓存了 native 方法的入口地址。一旦方法被调用过一次,后续就不再走符号查找流程。

这就意味着: 你必须在方法尚未被调用前完成 Hook,否则将失效

为此,Substrate 引入了 延迟初始化机制 :通过 Hook art::ClassLinker::DefineClass ,监听类加载事件,在类定义完成后、任何方法执行前插入 Hook。

此外,ART 默认禁止写 .text 段,必须调用 mprotect() 提升权限。而在 Android 7+ 上,SELinux 策略还会进一步限制此类操作。解决办法通常是借助 Magisk 模块以 systemless 方式部署,避开系统分区写入限制。


🔍 OAT 文件结构与 Hook 点识别

OAT 文件本质上是一个 ELF 格式的可执行镜像,包含预编译的机器码和元数据。我们可以通过 readelf 查看其结构:

readelf -S liboat.so

输出示例:

Section Headers:
  [Nr] Name              Type            Addr     Off    Size
  [ 1] .oat.data         PROGBITS        00001000 001000 002000
  [ 2] .text             PROGBITS        00003000 003000 008000

.text 存放编译后的代码, .oat.data 包含方法元信息。Substrate 会遍历 .oat.data 中的 OatMethodOffsets 数组,检查每个方法是否调用了目标函数(如 open )。如果是,则在其调用点前后插入探针或重定向控制流。

虽然工作量更大,但精度更高,避免盲目 Hook 导致崩溃。


🚀 Substrate 的启动与注入流程

Substrate 的注入依赖 Zygote 进程。Zygote 是所有应用进程的父进程,Substrate 利用这一点,在 Zygote 启动时加载 libsubstrate.so ,并通过 LD_PRELOAD 或 init 脚本将其注入全局环境。

具体流程如下:

  1. 设备开机后,init 进程读取 init.rc 或 Magisk 模块脚本;
  2. 当 Zygote 启动时,系统自动加载 libsubstrate.so
  3. JNI_OnLoad 被调用,Substrate 初始化钩子引擎;
  4. 扫描 /system/lib(64)/substrate.d/ 目录下的插件 .so 文件;
  5. 根据 MSConfig 声明的目标包名匹配当前进程;
  6. 若匹配成功,则加载插件并执行 Hook 操作。

这种“一次注入,处处生效”的机制极大提高了效率。

验证是否注入成功也很简单:

adb shell ps | grep zygote
adb shell cat /proc/<zygote_pid>/maps | grep substrate

如果有 libsubstrate.so 的映射记录,说明注入成功 ✅。


🧩 Java 层与 Native 层的双向 Hook 支持

Substrate 不止能 Hook Native 函数,还能深入 Java 层,实现真正的“全栈 Hook”。

✨ MSHookActivityClass 实现 Java 方法拦截
static void (*origin_onCreate)(JNIEnv*, jobject, jobject);

void my_onCreate(JNIEnv *env, jobject thiz, jobject bundle) {
    LOGD("Before onCreate");
    origin_onCreate(env, thiz, bundle);
    LOGD("After onCreate");
}

MSInitialize {
    MSAndroidMHookClassLoad("com/example/MainActivity", +[](void *self, JNIEnv *env, jclass clazz) {
        jmethodID method = env->GetMethodID(clazz, "onCreate", "(Landroid/os/Bundle;)V");
        MSHookMethod(env, clazz, method, (void*)my_onCreate, (void**)&origin_onCreate);
    });
}

这段代码会在 MainActivity 加载时自动替换 onCreate 方法,实现生命周期监控。

🔗 JNI 函数表替换

对于声明为 native 的方法,还可以直接 Hook RegisterNatives

static jint (*original_RegisterNatives)(JNIEnv*, jclass, const JNINativeMethod*, jint);

jint my_RegisterNatives(JNIEnv* env, jclass clazz, const JNINativeMethod* methods, jint nMethods) {
    for (int i = 0; i < nMethods; ++i) {
        if (strcmp(methods[i].name, "encrypt") == 0) {
            ((JNINativeMethod*)(methods))[i].fnPtr = my_encrypt_impl;
        }
    }
    return original_RegisterNatives(env, clazz, methods, nMethods);
}

// Hook RegisterNatives 自身
MSHookFunction((void*)env->functions->RegisterNatives, 
               (void*)my_RegisterNatives, 
               (void**)&original_RegisterNatives);

这样一来,所有通过 native 关键字声明的方法都会被提前重定向。


💻 实战项目:开发一个 Substrate 插件拦截 open 调用

我们现在来动手做一个完整的插件,目标是监控所有对敏感文件(如 keystore、password)的访问。

1️⃣ 开发环境准备

使用 NDK 生成独立工具链:

python3 $ANDROID_NDK/build/tools/make_standalone_toolchain.py \
    --arch arm \
    --api 21 \
    --install-dir /opt/arm-toolchain

设置环境变量:

export CC=/opt/arm-toolchain/bin/arm-linux-androideabi-gcc
export CXX=/opt/arm-toolchain/bin/arm-linux-androideabi-g++
2️⃣ 编写插件代码
#include <substrate.h>
#include <android/log.h>

#define LOG_TAG "OpenHook"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)

typedef int (*original_open_t)(const char*, int, ...);
original_open_t original_open = nullptr;

int my_open(const char* pathname, int flags, mode_t mode) {
    if (pathname && (strstr(pathname, "password") || strstr(pathname, "keystore"))) {
        LOGI("[SECURITY] Sensitive file access: %s", pathname);
    }
    return original_open(pathname, flags, mode);
}

MSConfig(MSFilterPackage, "com.android.chrome"); // 仅作用于 Chrome
MSConfig(MSConfigVersion, 0x00020000);

__attribute__((constructor))
static void initialize() {
    LOGI("Substrate plugin loaded.");

    MSImageRef image = MSGetImageByName("/system/lib/libc.so");
    void* open_sym = MSFindSymbol(image, "open");

    if (open_sym) {
        MSHookFunction(open_sym, (void*)my_open, (void**)&original_open);
        LOGI("Hook installed on open().");
    } else {
        LOGI("Failed to find open().");
    }
}
3️⃣ 打包为 .deb 安装包

创建目录结构:

myhook_1.0_arm.deb/
├── DEBIAN/
│   ├── control
│   ├── postinst
├── Library/
    └── MobileSubstrate/
        └── DynamicLibraries/
            ├── MyHook.plist
            └── libhook.so

DEBIAN/control 内容:

Package: com.example.myhook
Name: File Access Monitor
Version: 1.0-1
Architecture: arm
Description: Logs sensitive file access via open()
Maintainer: dev@example.com
Depends: mobilesubstrate
Section: Tweaks

MyHook.plist

<dict>
    <key>Filter</key>
    <dict>
        <key>Bundles</key>
        <array>
            <string>com.android.chrome</string>
        </array>
    </dict>
</dict>

打包安装:

dpkg-deb -Zlzma -b myhook_1.0_arm.deb
adb push myhook_1.0_arm.deb /tmp
adb shell dpkg -i /tmp/myhook_1.0_arm.deb
adb shell killall com.android.chrome
4️⃣ 验证效果

打开 logcat:

adb logcat | grep OpenHook

你会看到类似输出:

I/OpenHook(12345): [SECURITY] Sensitive file access: /data/data/com.chrome/app_keystore

🎉 成功捕获敏感文件访问!


🌐 总结:Substrate 的价值与未来趋势

Cydia Substrate 虽然诞生于越狱 iOS 生态,但它所代表的 运行时动态插件化思想 正在深刻影响着现代 Android 安全与自动化领域。

尽管随着 Android 版本升级(尤其是 Scoped Storage、SELinux 强化、dlopen 符号隐藏等机制),传统 Hook 技术面临越来越多限制,但 Substrate 通过以下方式持续进化:

  • Magisk 模块化部署 :实现 systemless 注入,无需修改系统分区;
  • 多阶段 Hook 时机控制 :精确把握类加载、库加载等关键节点;
  • 统一 API 抽象层 :屏蔽 Dalvik/ART 差异,提升兼容性;
  • Java-Native 双向穿透能力 :构建全栈行为监控体系。

未来,这类技术不仅用于逆向分析,也将更多应用于:
- 移动安全产品的动态防护引擎
- 自动化测试框架的行为模拟
- 性能埋点与用户体验优化
- 游戏辅助与无障碍增强

无论你是安全研究员、逆向工程师,还是系统开发者,掌握 Substrate 与 Native Hook 技术,都将为你打开一扇通往 Android 底层世界的大门。

毕竟,真正理解一个系统的最好方式,就是学会如何“悄悄地改变它”。😉

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:Cydia Substrate是一款强大的Native层Hook框架,由Saurik开发,最初用于iOS,后扩展至Android平台。它允许在不修改原始二进制文件的前提下,通过运行时函数替换实现对应用行为的动态修改,广泛应用于调试、性能监控、安全分析和插件化开发。该工具通过干预Dalvik/ART运行时机制,支持C/C++编写的注入代码,具备低侵入性和高灵活性。本文结合cydia_substrate压缩包中的库文件、头文件与示例代码,深入讲解其工作原理与使用方法,帮助开发者掌握在Android系统中实现函数Hook的核心技术。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐