芥末
发布于 2026-02-02 / 0 阅读
0
0

Android Frida Hook 从环境搭建到 Java 与 Native 实战

Hook 是一种运行时拦截技术。程序已经启动,代码已经在执行,但我们仍然可以在函数调用前后插入自己的逻辑,读取参数、修改参数、替换返回值,甚至直接调用某个原本不会被触发的方法。

在 Android 安全分析、逆向调试和自动化测试中,Hook 常用来解决这些问题:

  • 观察关键函数调用链,例如登录、加密、签名、网络请求。
  • 捕获运行时数据,例如明文参数、密钥、Token、校验结果。
  • 修改函数行为,例如在实验环境中验证 Root 检测、证书校验、环境检测逻辑。
  • 主动调用隐藏函数,例如触发某个未暴露的 Java 方法或 Native 函数。

所有操作都应该限定在授权 App、靶场或自有环境中。Frida 的能力很强,越靠近运行时底层,越需要明确测试边界。

1. Frida 是什么

Android 生态里常见的 Hook 方案主要有 Xposed 和 Frida。两者都能改变运行时行为,但适用方式不同。

框架工作方式优点代价适合场景
Xposed修改系统层框架,模块长期生效持久化能力强,适合系统级增强通常需要刷机、Root 或虚拟环境,变更后可能需要重启系统定制、长期模块开发
Frida动态注入脚本到目标进程注入快、脚本灵活、无需重启 App,支持 Java 和 Native需要运行 frida-server 或使用 Frida Gadget,版本要匹配逆向分析、安全测试、快速验证逻辑

Frida 的常见工作模式是 Client/Server(客户端/服务端):

  • PC 端运行 Frida 客户端,用 Python、命令行工具或 JavaScript 脚本控制目标。
  • Android 端运行 frida-server,接收 PC 端指令,并把脚本注入到目标进程。
  • 脚本进入目标 App 后,可以访问 Java 虚拟机,也可以操作 Native 内存。
flowchart LR
    A[PC: frida / frida-tools] --> B[ADB 端口转发]
    B --> C[Android: frida-server]
    C --> D[目标 App 进程]
    D --> E[Java VM]
    D --> F[Native SO / libc]
    E --> D
    F --> D

这套架构决定了安装时必须处理两件事:PC 端 Frida 版本、Android 端 frida-server 版本。两端版本不一致时,经常会出现连接失败、脚本无法注入、命令执行异常等问题。

2. Frida 环境搭建

2.1 PC 端安装

PC 端需要 Python 环境。Frida 核心库和命令行工具可以直接用 pip 安装:

pip install frida
pip install frida-tools

如果需要固定版本,例如使用 16.7.14

pip install frida==16.7.14
pip install frida-tools

查看当前 Frida 版本:

frida --version

这个版本号后面要和 Android 端 frida-server 保持一致。

2.2 确认 Android 设备架构

连接手机并开启 USB 调试后,用 Android Debug Bridge(ADB,Android 调试桥)查看 CPU 的 Application Binary Interface(ABI,应用二进制接口):

adb shell getprop ro.product.cpu.abi

常见输出与下载版本对应关系如下:

输出对应 frida-server
arm64-v8aandroid-arm64,目前主流真机
armeabi-v7aandroid-arm,老设备或 32 位环境
x86android-x86,部分模拟器
x86_64android-x86_64,部分 64 位模拟器

例如 PC 端 Frida 是 16.7.14,手机是 arm64-v8a,就应该下载:

frida-server-16.7.14-android-arm64.xz

解压后得到可执行文件:

frida-server-16.7.14-android-arm64

2.3 推送并运行 frida-server

把服务端推送到手机的临时目录:

adb push frida-server-16.7.14-android-arm64 /data/local/tmp/

进入手机 Shell,并切换到 Root 权限:

adb shell
su

赋予执行权限并启动:

cd /data/local/tmp
chmod +x frida-server-16.7.14-android-arm64
./frida-server-16.7.14-android-arm64 &

& 表示后台运行,终端断开时不至于立刻阻塞当前 Shell。

为了减少输入长度,也可以改名:

mv frida-server-16.7.14-android-arm64 fs
chmod +x fs
./fs &

简单字符串检测可能会扫描进程名、文件名或默认端口,把文件名改短只能避开很粗糙的检测,不能当作完整对抗方案。

2.4 设置端口转发

Frida 默认使用 2704227043 端口。USB 连接时,PC 端需要把本地端口转发到设备端:

adb forward tcp:27042 tcp:27042
adb forward tcp:27043 tcp:27043

如果默认端口被占用,可以让 frida-server 监听自定义端口:

./fs -l 0.0.0.0:8888 &

PC 端建立对应转发:

adb forward tcp:8888 tcp:8888

连接时指定 Host:

frida -H 127.0.0.1:8888 -l hook.js -f com.example.target

3. 常用 Frida 命令

环境是否正常,先用设备枚举和进程枚举来验证。

命令作用
frida-ls-devices列出当前 Frida 可识别的设备
frida-ps -U通过 USB 列出 Android 设备上正在运行的进程
frida-ps -Uai列出设备上已安装应用,包含包名和应用名
frida-ps -D <device_id>多设备连接时指定设备
frida -U -l hook.js -f <package>Spawn 模式启动并注入
frida -U -l hook.js <process_name_or_pid>Attach 模式附加到运行中进程
frida -H <ip>:<port> -l hook.js -f <package>远程或自定义端口连接

Frida 注入主要有两种方式。

模式命令特征目标标识进程状态适合场景
Spawn使用 -f包名Frida 启动 App 后注入Hook Application.onCreate()、早期检测、Native 初始化
Attach不使用 -f进程名或 PIDApp 已经运行中途观察业务逻辑、降低启动阶段干扰

Spawn 示例:

frida -U -l hook.js -f com.example.target

Attach 示例:

frida -U -l hook.js "Target App Name"

查包名:

frida-ps -Uai

查运行中的进程名或 PID:

# Linux / macOS
frida-ps -U | grep "example"

# Windows
frida-ps -U | findstr "example"

4. Java 层 Hook 基础

Android App 的 Java/Kotlin 代码最终运行在 Android Runtime(ART)上。Frida 提供了 Java API,可以在脚本里获取类、替换方法实现、访问字段、创建对象、枚举堆实例。

Java 层 Hook 的基本骨架是:

setImmediate(function () {
    Java.perform(function () {
        var TargetClass = Java.use("com.example.demo.MainActivity");

        TargetClass.targetMethod.implementation = function (arg1, arg2) {
            console.log("[*] arg1=" + arg1 + ", arg2=" + arg2);

            var newArg1 = "patched";

            var result = this.targetMethod(newArg1, arg2);

            console.log("[*] original result=" + result);
            return result;
        };
    });
});

核心 API 可以按用途理解:

API / 属性作用
Java.perform(fn)把当前 Frida 脚本线程附加到 Java Virtual Machine(VM,虚拟机),Java Hook 逻辑通常写在这里
Java.use(className)获取目标 Java 类的包装对象
method.implementation替换目标方法实现
this.method(...)在 Hook 函数内调用原始方法
Class.field.value读取或修改字段值
Class.$new(...)调用构造方法创建对象
Java.choose(className, callbacks)在堆内存中搜索已存在的对象实例
method.overload(...)指定重载方法的参数签名

4.1 执行时机:setImmediate 与 setTimeout

setImmediate 会在当前 JavaScript 事件循环结束后尽快执行,适合大多数 Java Hook:

setImmediate(function () {
    Java.perform(function () {
        console.log("[*] script loaded");
    });
});

如果脚本注入过早,目标类尚未加载,可能出现 ClassNotFoundException。这时可以延迟执行:

setTimeout(function () {
    Java.perform(function () {
        console.log("[*] run after 1000 ms");
    });
}, 1000);

Spawn 模式下尤其容易遇到时机问题,因为脚本可能早于 Activity 创建完成。

5. Hook 普通实例方法

普通实例方法属于某个对象。Hook 时,implementation 内部的 this 指向当前对象实例。

假设目标代码如下:

// com.ad2001.frida0x1.MainActivity
void check(int i, int i2) {
    if ((i * 2) + 4 == i2) {
        Toast.makeText(getApplicationContext(), "Yey you guessed it right", 1).show();
    } else {
        Toast.makeText(getApplicationContext(), "Try again", 1).show();
    }
}

校验条件是:

(i * 2) + 4 == i2

可以在进入 check 时把参数改成满足条件的值:

setImmediate(function () {
    Java.perform(function () {
        let MainActivity = Java.use("com.ad2001.frida0x1.MainActivity");

        MainActivity["check"].implementation = function (i, i2) {
            console.log(`[*] original args: i=${i}, i2=${i2}`);

            i = 0;
            i2 = 4;

            console.log(`[*] patched args: i=${i}, i2=${i2}`);

            this["check"](i, i2);
        };
    });
});

方括号写法 this["check"](...)this.check(...) 更稳,混淆后的方法名可能包含 $ 等特殊字符,点号写法容易出问题。

6. 调用静态方法

静态方法属于类本身,不依赖对象实例。

假设目标类里有一个静态方法,但 App 自己没有调用它:

public class MainActivity extends AppCompatActivity {
    static TextView t1;

    public static void get_flag(int a) {
        if (a == 4919) {
            t1.setText("FLAG is here...");
        }
    }
}

如果只是写 implementation,方法不被调用,Hook 就不会触发。此时应当主动调用:

setImmediate(function () {
    Java.perform(function () {
        let MainActivity = Java.use("com.ad2001.frida0x2.MainActivity");

        console.log("[*] invoke get_flag(4919)");
        MainActivity["get_flag"](4919);
    });
});

判断使用 Hook 还是主动调用,可以看目标函数是否会自然执行:

场景做法
App 会调用目标方法implementation 拦截并修改参数或返回值
App 不会调用目标方法直接通过类或实例主动调用
静态方法Class.method(...)
实例方法需要对象实例,可用 $new()Java.choose()

7. 修改静态字段

字段不是方法,不能用 implementation 替换。Frida 访问字段值要通过 .value

目标逻辑:

public void onClick(View v) {
    if (Checker.code == 512) {
        Toast.makeText(getApplicationContext(), "YOU WON!!!", 1).show();
    } else {
        Toast.makeText(getApplicationContext(), "TRY AGAIN", 1).show();
    }
}

public class Checker {
    static int code = 0;
}

只需要在点击前把 Checker.code 改成 512

setImmediate(function () {
    Java.perform(function () {
        let Checker = Java.use("com.ad2001.frida0x3.Checker");

        console.log("[*] original code=" + Checker.code.value);

        Checker.code.value = 512;

        console.log("[*] patched code=" + Checker.code.value);
    });
});

直接打印 Checker.code 得到的是 Frida 字段包装对象,不是字段值;读取真实数据必须使用 Checker.code.value

8. 创建对象并调用非静态方法

有些目标方法是实例方法,但 App 当前流程没有创建该类对象。没有现成对象时,可以用 $new() 主动构造。

目标代码:

public class Check {
    public String get_flag(int a) {
        if (a == 1337) {
            return "FLAG{...}";
        }
        return "";
    }
}

Frida 脚本:

setImmediate(function () {
    Java.perform(function () {
        let CheckClass = Java.use("com.ad2001.frida0x4.Check");

        let checkInstance = CheckClass.$new();

        let flag = checkInstance.get_flag(1337);

        console.log("[*] result=" + flag);
    });
});

$new() 等价于 Java 里的 new Check()。如果构造方法有参数,就把参数传进去:

let obj = CheckClass.$new(1, "abc");

9. 获取当前内存中的实例

Android 的 Activity、Service、Application 等对象由系统管理,不应该随便 $new()。自己创建的 Activity 不代表当前屏幕上的 Activity,也不能控制当前 UI。

这种情况要用 Java.choose() 从堆里找已有实例。

目标代码:

public class MainActivity extends AppCompatActivity {
    public void flag(int code) {
        if (code == 1337) {
            // update UI and show flag
        }
    }
}

脚本:

setTimeout(function () {
    Java.perform(function () {
        Java.choose("com.ad2001.frida0x5.MainActivity", {
            onMatch: function (instance) {
                console.log("[*] found instance: " + instance);

                instance.flag(1337);

                return "stop";
            },
            onComplete: function () {
                console.log("[*] search complete");
            }
        });
    });
}, 1000);

Java.choose() 的工作流程如下:

flowchart TD
    A[开始堆扫描] --> B{发现目标类实例?}
    B -- 是 --> C[调用 onMatch(instance)]
    C --> D{是否 return stop?}
    D -- 是 --> E[停止扫描]
    D -- 否 --> B
    B -- 否 --> F[继续遍历堆]
    F --> B
    E --> G[调用 onComplete]
    B -- 扫描结束 --> G

return "stop" 很适合 Activity 或单例对象,找到第一个就停止,减少扫描开销。

10. 调用带对象参数的方法

实际业务里,方法参数常常是自定义对象,而不是简单的 intString。调用这类方法时,要构造参数对象并设置字段。

目标代码:

public class MainActivity extends AppCompatActivity {
    public void get_flag(Checker A) {
        if (1234 == A.num1 && 4321 == A.num2) {
            // success
        }
    }
}

public class Checker {
    int num1;
    int num2;
}

调用者是当前 MainActivity 实例,参数是 Checker 对象。脚本需要完成三步:

  1. 找到 MainActivity 实例。
  2. 创建 Checker 对象。
  3. 设置 num1num2 后调用 get_flag()
setTimeout(function () {
    Java.perform(function () {
        Java.choose("com.ad2001.frida0x6.MainActivity", {
            onMatch: function (instance) {
                let CheckerClass = Java.use("com.ad2001.frida0x6.Checker");

                let checkerObj = CheckerClass.$new();

                checkerObj.num1.value = 1234;
                checkerObj.num2.value = 4321;

                instance["get_flag"](checkerObj);

                return "stop";
            },
            onComplete: function () {
                console.log("[*] search complete");
            }
        });
    });
}, 1000);

字段赋值同样要使用 .value

11. Hook 构造方法

Java 构造方法在 Frida 中对应 $init

目标代码:

public class Checker {
    int num1;
    int num2;

    public Checker(int a, int b) {
        this.num1 = a;
        this.num2 = b;
    }
}

public class MainActivity extends AppCompatActivity {
    public void onCreate(Bundle savedInstanceState) {
        Checker ch = new Checker(123, 321);
        flag(ch);
    }

    public void flag(Checker A) {
        if (A.num1 > 512 && A.num2 > 512) {
            // success
        }
    }
}

Checker 初始化为 (123, 321),不满足条件。可以在对象创建源头修改参数:

setImmediate(function () {
    Java.perform(function () {
        let Checker = Java.use("com.ad2001.frida0x7.Checker");

        Checker["$init"].implementation = function (a, b) {
            console.log(`[*] Checker.$init: a=${a}, b=${b}`);

            a = 999;
            b = 999;

            console.log(`[*] patched: a=${a}, b=${b}`);

            this["$init"](a, b);
        };
    });
});

如果构造方法存在重载,必须指定参数签名:

Checker["$init"].overload("int", "int").implementation = function (a, b) {
    return this["$init"](999, 999);
};

也可以不改构造方法,而是在 flag() 执行前替换对象:

setImmediate(function () {
    Java.perform(function () {
        let MainActivity = Java.use("com.ad2001.frida0x7.MainActivity");
        let Checker = Java.use("com.ad2001.frida0x7.Checker");

        MainActivity["flag"].implementation = function (A) {
            let newChecker = Checker.$new(999, 999);
            this["flag"](newChecker);
        };
    });
});

两种方案的影响范围不同:

方案影响范围适合场景
Hook flag() 参数只影响该校验方法不希望改变其他 Checker 创建逻辑
Hook $init 构造方法所有 new Checker(...) 都会受影响目标类只服务于当前校验,或需要从源头改数据

12. Hook 重载方法

Java 支持方法重载,同名方法可以有不同参数列表:

private void check(String str) {
    Toast.makeText(this, "String: " + str, 0).show();
}

private void check(int i) {
    Toast.makeText(this, "Int: " + i, 0).show();
}

Frida 如果只写:

Challenge4Activity["check"].implementation = function (arg) {};

会因为不知道目标是哪一个重载版本而报歧义错误。正确做法是用 .overload() 指定签名:

setImmediate(function () {
    Java.perform(function () {
        let Activity = Java.use("com.xiusi.fridastudy.Challenge4Activity");

        Activity["check"].overload("java.lang.String").implementation = function (str) {
            console.log("[*] check(String): " + str);
            this["check"]("patched string");
        };

        Activity["check"].overload("int").implementation = function (i) {
            console.log("[*] check(int): " + i);
            this["check"](99999);
        };
    });
});

签名书写规则:

Java 类型Frida overload 写法
int"int"
boolean"boolean"
float"float"
java.lang.String"java.lang.String"
android.os.Bundle"android.os.Bundle"
byte[]"[B"
String[]"[Ljava.lang.String;"
自定义类完整包名,例如 "com.example.Checker"

不确定签名时,可以先故意不写或写错 .overload()。Frida 的报错信息通常会列出所有可用重载签名,把正确签名复制回来即可。

13. Native 层 Hook 基础

Native 层指 C/C++ 编译出的 Shared Object(SO,共享库),文件一般是 .so。Java 层通过 Java Native Interface(JNI,Java 本地接口)调用 Native 函数。

Native Hook 面对的是函数地址、指针、寄存器和内存。Frida 主要用这些 API:

API作用
Module.findExportByName(so, name)根据导出符号查函数地址,找不到返回 null
Module.getExportByName(so, name)根据导出符号查函数地址,找不到抛异常
Module.findBaseAddress(so)获取 SO 加载基址
Module.enumerateExports(so, callbacks)枚举导出符号
Module.enumerateImports(so, callbacks)枚举导入符号
Interceptor.attach(address, callbacks)拦截 Native 函数
NativeFunction(address, ret, args)把 Native 地址包装成可调用函数
Memory.read* / write*读写内存
hexdump(ptr, options)打印内存块

Native Hook 的典型流程:

flowchart TD
    A[确认目标 SO 已加载] --> B{目标函数是否有导出符号?}
    B -- 有 --> C[Module.findExportByName]
    B -- 无 --> D[Module.findBaseAddress + offset]
    C --> E[得到函数绝对地址]
    D --> E
    E --> F[Interceptor.attach 或 NativeFunction]
    F --> G[读取参数 / 修改返回值 / 主动调用]

13.1 Interceptor.attach 基本模板

strcmp 为例:

function hookNative() {
    var funcPtr = Module.findExportByName("libc.so", "strcmp");

    console.log("[*] strcmp address: " + funcPtr);

    if (!funcPtr) {
        console.log("[-] strcmp not found");
        return;
    }

    Interceptor.attach(funcPtr, {
        onEnter: function (args) {
            var s1 = Memory.readUtf8String(args[0]);
            var s2 = Memory.readUtf8String(args[1]);

            console.log("[*] strcmp");
            console.log("    s1=" + s1);
            console.log("    s2=" + s2);
        },
        onLeave: function (retval) {
            console.log("[*] retval=" + retval.toInt32());
        }
    });
}

onEnter 在函数执行前触发,适合读取或修改参数。onLeave 在函数返回前触发,适合读取或替换返回值。

13.2 处理 SO 加载时机

App 自带 SO 通常不是进程启动时立刻加载,而是在 System.loadLibrary() 执行时加载。SO 没加载前,找不到内部函数地址。

可以 Hook java.lang.Runtime.loadLibrary0,等目标库加载后再执行 Native Hook:

setImmediate(function () {
    Java.perform(function () {
        var Runtime = Java.use("java.lang.Runtime");

        Runtime.loadLibrary0
            .overload("java.lang.Class", "java.lang.String")
            .implementation = function (loader, libname) {
                var ret = this.loadLibrary0(loader, libname);

                console.log("[*] loaded library: " + libname);

                if (libname.indexOf("targetlib") >= 0) {
                    hookNative();
                }

                return ret;
            };
    });
});

部分 Android 版本或 Frida 版本中,loadLibrary0 签名可能不同。遇到 overload 错误时,打印可用重载签名再调整。

14. Native 内存读写

Native 参数通常是指针,args[0]args[1] 得到的是 NativePointer,不是 JavaScript 字符串或数字。需要用 Memory API 读取指针指向的数据。

API用途示例
Memory.readUtf8String(ptr)读取 char * 字符串Memory.readUtf8String(args[0])
Memory.writeUtf8String(ptr, str)写入字符串Memory.writeUtf8String(args[0], "abc")
Memory.readInt(ptr)读取 int * 指向的整数Memory.readInt(args[1])
Memory.writeInt(ptr, value)写入整数Memory.writeInt(args[1], 1337)
Memory.readByteArray(ptr, len)读取二进制数据Memory.readByteArray(args[2], 16)
hexdump(ptr, options)十六进制打印内存hexdump(args[0], { length: 64 })

综合示例:

Interceptor.attach(targetAddr, {
    onEnter: function (args) {
        var strArg = Memory.readUtf8String(args[0]);
        var count = Memory.readInt(args[1]);

        console.log(`[*] str=${strArg}, count=${count}`);

        Memory.writeInt(args[1], 9999);

        console.log(hexdump(args[0], {
            offset: 0,
            length: 32,
            header: true,
            ansi: true
        }));
    }
});

写内存时要确认目标缓冲区大小。如果原缓冲区只有 4 字节,却写入更长字符串,很容易覆盖相邻内存导致崩溃。

15. Native 辅助枚举脚本

逆向初期经常不知道目标 SO 名称、导出符号或导入函数。先枚举,再定位。

15.1 枚举已加载模块

setImmediate(function () {
    console.log("[*] modules:");

    Process.enumerateModules({
        onMatch: function (module) {
            console.log(
                module.name.padEnd(40) +
                module.base.toString().padEnd(20) +
                module.size
            );
        },
        onComplete: function () {
            console.log("[*] done");
        }
    });
});

15.2 枚举指定 SO 的导出函数

setImmediate(function () {
    var targetSo = "libtarget.so";
    var module = Process.findModuleByName(targetSo);

    if (!module) {
        console.log("[-] module not found: " + targetSo);
        return;
    }

    Module.enumerateExports(targetSo, {
        onMatch: function (exp) {
            if (exp.name.indexOf("flag") >= 0) {
                console.log(exp.name + " " + exp.address + " " + exp.type);
            }
        },
        onComplete: function () {
            console.log("[*] exports done");
        }
    });
});

15.3 枚举指定 SO 的导入函数

setImmediate(function () {
    var targetSo = "libtarget.so";
    var module = Process.findModuleByName(targetSo);

    if (!module) {
        console.log("[-] module not found: " + targetSo);
        return;
    }

    Module.enumerateImports(targetSo, {
        onMatch: function (imp) {
            console.log(
                imp.name + " " +
                imp.address + " " +
                imp.type + " from " +
                imp.module
            );
        },
        onComplete: function () {
            console.log("[*] imports done");
        }
    });
});

16. Hook 有符号函数

有符号函数指能在导出表找到名字的函数,常见两类:

  • 系统库函数,例如 libc.so 里的 openreadstrcmp
  • JNI 导出函数,例如 Java_com_example_MainActivity_checkFlag

目标 Java 代码:

public class MainActivity extends AppCompatActivity {
    public native int cmpstr(String str);

    static {
        System.loadLibrary("frida0x8");
    }

    public void onClick(View v) {
        int res = cmpstr(input);
        if (res == 1) {
            // success
        }
    }
}

Native 层内部大致逻辑:

// 伪代码
for (i = 0; i < len; i++) {
    s2[i] = encrypted[i] - 1;
}

int v = strcmp(input, s2);
return v == 0;

既然最终调用了 strcmp(input, s2),就可以 Hook strcmp,观察两个比较参数。系统中 strcmp 调用很多,必须过滤日志,否则会刷屏。

function hookNative() {
    var strcmpPtr = Module.findExportByName("libc.so", "strcmp");

    if (!strcmpPtr) {
        console.log("[-] strcmp not found");
        return;
    }

    Interceptor.attach(strcmpPtr, {
        onEnter: function (args) {
            var s1 = Memory.readUtf8String(args[0]);
            var s2 = Memory.readUtf8String(args[1]);

            if (s1 && s1.indexOf("666") >= 0) {
                console.log("[+] strcmp hit");
                console.log("    input=" + s1);
                console.log("    candidate=" + s2);
            }
        }
    });
}

setImmediate(function () {
    Java.perform(function () {
        var Runtime = Java.use("java.lang.Runtime");

        Runtime.loadLibrary0
            .overload("java.lang.Class", "java.lang.String")
            .implementation = function (loader, libname) {
                var ret = this.loadLibrary0(loader, libname);

                if (libname.indexOf("frida0x8") >= 0) {
                    hookNative();
                }

                return ret;
            };
    });
});

这里约定在输入框输入 666,只打印包含该特征的调用,能显著降低噪声。

17. 修改 Native 返回值

有些 Native 函数没有可控参数,只能改返回值。

Java 层:

public class MainActivity extends AppCompatActivity {
    public native int check_flag();

    static {
        System.loadLibrary("a0x9");
    }

    public void onClick(View v) {
        if (check_flag() == 1337) {
            // success
        } else {
            // fail
        }
    }
}

Native 伪代码:

long Java_com_ad2001_a0x9_MainActivity_check_1flag() {
    return 1;
}

目标要求 1337,实际返回 1。在 onLeave 里使用 retval.replace()

function hookNative() {
    var funcName = "Java_com_ad2001_a0x9_MainActivity_check_1flag";
    var checkPtr = Module.findExportByName("liba0x9.so", funcName);

    if (!checkPtr) {
        console.log("[-] function not found");
        return;
    }

    Interceptor.attach(checkPtr, {
        onEnter: function (args) {},
        onLeave: function (retval) {
            console.log("[*] original retval=" + retval.toInt32());

            retval.replace(1337);

            console.log("[+] retval patched to 1337");
        }
    });
}

setImmediate(function () {
    Java.perform(function () {
        var Runtime = Java.use("java.lang.Runtime");

        Runtime.loadLibrary0
            .overload("java.lang.Class", "java.lang.String")
            .implementation = function (loader, libname) {
                var ret = this.loadLibrary0(loader, libname);

                if (libname.indexOf("a0x9") >= 0) {
                    hookNative();
                }

                return ret;
            };
    });
});

JNI 命名有转义规则,例如 Java 方法名里的 _ 在 JNI 符号里可能变成 _1。如果 findExportByName() 找不到函数,先用 Module.enumerateExports() 打印真实导出名。

18. 主动调用 Native 有符号函数

有些 Native 函数包含关键逻辑,但 Java 层没有声明,也不会主动调用。只要能拿到函数地址并知道参数类型,就可以用 NativeFunction 包装后直接调用。

假设导出表里发现可疑函数:

_Z8get_flagii

这是 C++ Name Mangling(名称修饰)后的符号名:

  • _Z 表示 C++ 修饰符号。
  • 8get_flag 表示函数名 get_flag 长度为 8。
  • ii 表示两个 int 参数。

Native 伪代码:

long get_flag(int a, int b) {
    if (a + b == 3) {
        __android_log_print(3, "FLAG", "Decrypted Flag: %s", decrypted_flag);
    }
    return 0;
}

脚本:

function invokeNative() {
    var funcPtr = Module.findExportByName("libfrida0xa.so", "_Z8get_flagii");

    if (!funcPtr) {
        console.log("[-] get_flag not found");
        return;
    }

    console.log("[*] get_flag address=" + funcPtr);

    var getFlag = new NativeFunction(funcPtr, "long", ["int", "int"]);

    getFlag(1, 2);

    console.log("[*] invoked get_flag(1, 2)");
}

setTimeout(function () {
    Java.perform(function () {
        invokeNative();
    });
}, 1000);

NativeFunction 原型:

new NativeFunction(address, returnType, argTypes[, abi])

常见类型:

C/C++ 类型Frida 类型
void"void"
指针"pointer"
int"int"
unsigned int"uint"
long"long"
unsigned long"ulong"
char"char"
unsigned char"uchar"
float"float"
double"double"

如果函数内部把结果写到 Logcat,需要用 adb logcat 或 Android Studio 的日志面板查看输出。

19. Hook 无符号函数:基址 + 偏移

生产环境中的 SO 经常被 Strip,符号表被移除后,函数名可能只剩 sub_151C0 这类地址标识,无法用 findExportByName() 定位。

这时使用公式:

函数运行时绝对地址 = SO 运行时基址 + IDA/Ghidra 中的函数偏移

例如在 IDA Pro 或 Ghidra 里看到目标函数偏移是:

0x1DD60

脚本:

function invokeByOffset() {
    var moduleName = "libfrida0xa.so";
    var baseAddr = Module.findBaseAddress(moduleName);

    if (!baseAddr) {
        console.log("[-] module not found: " + moduleName);
        return;
    }

    console.log("[*] base=" + baseAddr);

    var offset = 0x1dd60;
    var targetAddr = baseAddr.add(offset);

    if (Process.arch === "arm") {
        // 32 位 ARM 且目标函数是 Thumb 指令时才需要 +1
        // targetAddr = targetAddr.add(1);
    }

    console.log("[*] target=" + targetAddr);

    var fn = new NativeFunction(targetAddr, "long", ["int", "int"]);

    fn(1, 2);
}

setTimeout(function () {
    Java.perform(function () {
        invokeByOffset();
    });
}, 1000);

32 位 ARM 上有一个常见坑:Thumb 指令地址最低位需要置 1。如果目标函数是 Thumb 模式,地址要 +1。ARM64 不需要处理。

20. 修改汇编指令:Code Patching

Hook 是在函数入口或出口插入逻辑。遇到这些情况,直接 Patch 指令更合适:

  • 函数内部有反调试或死循环。
  • 关键判断位于函数中间,改返回值太晚。
  • 要跳过某条条件跳转,例如 B.NECBZCBNZ
  • 目标函数没有稳定入口,或入口处逻辑太复杂。

代码段通常是只读可执行(r-x),写入前要改内存页权限:

var pageSize = Process.pageSize;
var pageStart = targetAddr.and(ptr(pageSize - 1).not());

Memory.protect(pageStart, pageSize, "rwx");

ARM64 写 NOP 的基本模板:

var writer = new Arm64Writer(targetAddr);

try {
    writer.putNop();
    writer.flush();
} finally {
    writer.dispose();
}

关键 API:

API作用
Memory.protect(ptr, size, prot)修改内存权限,ptr 必须页对齐
Arm64WriterARM64 指令写入器
X86Writerx86 指令写入器
writer.putNop()写入空指令
writer.putBImm(addr)写入跳转指令
writer.flush()把缓冲区指令写入内存并刷新指令缓存
writer.dispose()释放 Writer 资源

一个典型场景:Native 函数内部存在条件跳转,阻止程序进入成功分支。

汇编片段:

MOV   W8, #0xDEADBEEF
STUR  W8, [X29,#var_24]
LDUR  W8, [X29,#var_24]
SUBS  W8, W8, #0x539
B.NE  loc_1532C

逻辑含义:

  • 0x539 是十进制 1337
  • 0xDEADBEEF - 1337 不为 0。
  • B.NE 条件成立,程序跳到失败分支。
  • 如果把 B.NE 改成 NOP,程序会继续向下执行成功路径。

Patch 脚本:

function patchCode() {
    var moduleName = "libfrida0xb.so";
    var baseAddr = Module.findBaseAddress(moduleName);

    if (!baseAddr) {
        console.log("[-] module not found");
        return;
    }

    var offset = 0x15248;
    var targetAddr = baseAddr.add(offset);

    console.log("[*] patch address=" + targetAddr);

    var pageSize = Process.pageSize;
    var pageStart = targetAddr.and(ptr(pageSize - 1).not());

    Memory.protect(pageStart, pageSize, "rwx");

    var writer = new Arm64Writer(targetAddr);

    try {
        writer.putNop();
        writer.flush();

        console.log("[+] patch success");
    } catch (e) {
        console.log("[-] patch failed: " + e);
    } finally {
        writer.dispose();
    }
}

setTimeout(function () {
    Java.perform(function () {
        patchCode();
    });
}, 1000);

Patch 指令前必须确认架构、偏移和指令长度。ARM64 每条指令固定 4 字节,写一个 NOP 刚好覆盖一条指令;x86/x64 指令长度不固定,误覆盖半条指令会直接崩溃。

21. 常见问题与排查

问题常见原因处理方式
frida-ps -U 连接失败frida-server 未启动、端口未转发、USB 调试异常检查 adb devices、重启 frida-server、执行 adb forward
版本不匹配PC 端 frida 与手机端 frida-server 版本不同两端统一版本
ClassNotFoundException注入太早,类还没加载setTimeout 延迟,或 Hook 类加载点
overload 歧义方法或构造方法存在多个签名使用 .overload(...) 指定参数类型
field 打印不是值直接打印了字段包装对象使用 .value 读写字段
Java.choose 找不到实例实例尚未创建或已经销毁延迟执行,确认页面已打开
Native 函数找不到SO 未加载、符号名错误、库被 Strip监听 loadLibrary0,枚举 exports,或使用基址 + 偏移
Hook strcmp 日志爆炸系统调用频率太高加特征字符串或调用栈过滤
Patch 后崩溃偏移错误、架构不匹配、权限未改、覆盖指令长度错误校验基址、偏移、指令集和页权限

22. 一套实用分析路径

面对一个 Android 目标,Frida 分析可以按这条路径推进:

flowchart TD
    A[确认目标行为] --> B[反编译 Java 层]
    B --> C{关键逻辑在 Java?}
    C -- 是 --> D[Java.use 定位类和方法]
    D --> E{方法会被调用?}
    E -- 是 --> F[implementation 修改参数或返回值]
    E -- 否 --> G[$new 或 Java.choose 主动调用]
    C -- 否 --> H[定位 SO 与 JNI 方法]
    H --> I{函数有符号?}
    I -- 是 --> J[findExportByName]
    I -- 否 --> K[base + offset]
    J --> L[Interceptor / NativeFunction]
    K --> L
    L --> M{入口出口 Hook 够用?}
    M -- 是 --> N[读取参数或替换返回值]
    M -- 否 --> O[Code Patch 修改指令]

Java 层优先看类、方法、字段和生命周期;Native 层优先看 SO 加载、导出符号、JNI 映射和字符串引用。Hook 不是孤立技巧,它依赖静态分析给出的类名、方法名、函数偏移和参数类型。静态分析负责找到位置,Frida 负责在运行时验证和控制。


评论