Frida Hook Android手册

Frida Hook Android手册

write by ppl on 2025/4

Frida的安装与配置

​ 网上教程一大堆,略

连接 Android 设备

运行Frida服务端

$> adb shell
su
cd /data/local/tmp
./frida-server

进行端口转发

$> adb forward tcp:27042 tcp:27042

​ 27042 用于与frida-server通信的默认端口号,之后的每个端口对应每个注入的进程。

检测Frida是否成功运行

$> frida-ps -U

​ 可以列出所有进程的PID,包名。

连接模式

  1. spawn 模式:启动新进程
$> frida -U -f cn.binary.frida -l script.js

# 暂停进程
$> frida -U -f cn.binary.frida -l script.js --pause
  1. attach 模式:附加到运行中的进程
# 附加到进程 pid
$> frida -U -p 1234 -l script.js
# 附加到进程名
$> frida -U -n com.example.demo -l script.js
# 附加到前台进程
$> frida -U -F -l script.js

常用参数

  • -U:连接到远程 USB 设备
  • -p:指定进程 PID
  • -n:指定进程包名
  • -l:指定注入的 js 代码文件
  • -f:启动新进程
  • -F:附加到前台进程

注入方法

1. JS 脚本注入

​ 使用-l命令参数,通过 frida 工具将写好的Js脚本注入到目标进程中。

2. Bash 命令行

​ 不使用-l命令参数,则使用Frida命令会打开Frida的Bash 命令行逐行输入Js代码交互。

3. Python代码注入

​ Frida 提供了 Python 接口,可以使用 Frida 的 Python API 可以更方便地操作和动态注入脚本。

import frida
import sys


target = "<pid_or_process_name>"


js_code = """
var targetFunction = Module.findExportByName(null, 'target_function_name');

Interceptor.attach(targetFunction, {
    onEnter: function(args) {
        console.log('Intercepted function call!');
        args[0] = ptr('0x12345678');
    },
    onLeave: function(retval) {
        console.log('Function returned with value: ' + retval);
        retval.replace(0xabcdef);
    }
});
"""


def on_message(message, data):
    print(message)
    
    
    
if __name__ == '__main__':

    device = frida.get_usb_device()
    session = device.attach(2595)
    script = session.create_script(jscode)
    script.on('message', on_message)
    script.load()
    
    sys.stdin.read()

ApkLab与APK的重打包

​ APKLab是一个基于VS Code的Android逆向工程扩展,继承了多个强大的工具 :

  • apktool:反编译APK资源
  • dex2jar:将DEX转换为JAR
  • jadx:反编译Java字节码文件
  • keytool:管理密钥和证书

​ 集成了多个工具,可以方便的进行反编译、查看资源文件、修改代码、重新打包、签名等操作。

听说cursor可以用APKLab自动化逆向apk

Java层Hook

基本结构

​ Frida Hook Java脚本的执行是基于 Java.perform() 的,它确保 Java 环境在执行你的脚本时已经初始化。Java.use() 用来获取 Java 类的引用,这样可以对该类进行操作。implementation 是用来重写 Java 方法实现的关键字,可以在这里插入自己的代码,比如打印日志、修改参数、改变返回值等。

Java.perform(function () {
    var myClass = Java.use('com.example.MyClass');  // 获取类的引用

    // Hook doSomething 方法
    myClass.doSomething.overload().implementation = function () {
        console.log('doSomething 方法被调用');
        // 可以修改返回值或进行其他操作
        var result = this.doSomething();  // 调用原本的方法
        console.log('原始返回值:' + result);
        return result;  // 或者返回修改后的值
    };
});

​ 在获取类的引用要写明类的路径,即 完整的包名+类名 (即使是Java自带的类)。它会返回一个 Java 类的代理对象,你可以通过它来访问类的字段、方法等。

获取入参、重载方法

​ 可以使用 overload() 方法来钩取某个方法的特定重载版本。如果想获取函数的入参,可以通过 args 来访问。这些入参通常是一个数组,你可以根据方法签名提取出具体的值。

var TargetClass = Java.use("com.example.targetclass");  
var method = TargetClass.methodName.overload('java.lang.String', 'int');  // 指定重载方法签名

method.implementation = function (str, num) {
    console.log("Original args: str = " + str + ", num = " + num);
};

或者

var TargetClass = Java.use("com.example.targetclass");  
TargetClass.methodName.overload('java.lang.String', 'int').implementation = function (str, num) {
    console.log("Original args: str = " + str + ", num = " + num);
};

​ 钩取某个方法的特定重载版本要通过参数类型的不同指明。 overload() 的参数数量应与Hook原函数的参数数量一致。参数类型:如果是基本类型直接是类型名的字符串,如果是非基本类型则为 包名+类名 的字符串。

​ 如果参数是数组:基本类型数组,用左中括号接上基本类型的缩写;对象数组,用左中括号接上完整类名再接上分号

  targetClass.methodName.overload('[I').implementation = function(arr) {
        // 通过 Java Array API 访问数组的每个元素
        let length = arr.length;
        for (let i = 0; i < length; i++) {
            console.log('元素 ' + i + ': ' + arr[i]);
        }
    };
基本类型 缩写
boolean Z
byte B
char C
double D
float F
int I
long J
short S

​ 对象数组:例如 '[java.lang.String;'

thissuper

this 代表的是当前类的实例,而 super 代表父类的实例。调用当前类的方法,通过 this 来调用当前类的实例方法或构造函数。调用父类的方法,通过 super 来调用父类的实例方法。

构造函数

$init 是构造函数的特殊表示, 是所有类构造函数的标识符。

var MyClass = Java.use('com.example.MyClass');
MyClass.$init.overload('java.lang.String').implementation = function(arg) {
    console.log('Constructing MyClass with argument:', arg);
    return this.$init(arg);  // 调用构造函数
};

​ 通常需要调用原构造函数。

获取类字段(属性)

​ 可以使用 getset 来访问 Java 类的字段。字段是通过 value 访问的。

var MyClass = Java.use('com.example.MyClass');
var fieldValue = MyClass.someField.value;  // 获取字段值
console.log('Field value: ', fieldValue);

MyClass.someField.value = 42;  // 修改字段值
console.log('Field value after modification: ', MyClass.someField.value);

数组实例

​ 使用Java.array()创建一个 Java 数组实例。第一个参数为数组类型,第二个参数为数组内容。

var StringArray = Java.array('java.lang.String', ['a', 'b', 'c']);

​ 题外话:Java传参的数组通常是bytes数组,将bytes转换为十六进制并输出:

function bytesToHex(bytes){
    let hex = [];
    for(let i=0; i < bytes.length;;i++){
        let current = bytes[i] & 0xff;
        let hexValue = current.toString(16);
        if(hexValue.length == 1){
            hexValue = "0" + hexValue;
        }
        hex.push(hexValue);
    }
    return hex.join(" ");
}

静态方法

​ 静态方法是属于类本身的,因此你可以直接通过类来访问和 hook 静态方法调试。实例方法是属于对象实例的,因此在 hook 实例方法时,首先你需要通过 Java.use() 获取类的引用,当创建该类的实例时,或者在 hook 代码中通过实例方法进行调用。

​ 被动hook写法上似乎没有区别。。。。。但在主动调用时,静态方法不需要实例化对象。

主动调用

​ 以上都是被动调用,当原函数被调用时,hook函数才会发生作用。主动调用(或者说是直接调用)指的是在脚本中通过 Frida 代码主动触发目标程序的函数或方法。

​ 主动调用实例化方法:

Java.perform(function () {
    const MyClass = Java.use('com.example.MyClass');  // 获取目标类的引用

    // 创建对象实例
    const myObject = MyClass.$new();  // 使用 $new() 创建对象实例

    // 主动调用实例方法
    myObject.someInstanceMethod('Hello, Frida!');  

    // 修改实例方法的返回值
    MyClass.someInstanceMethod.implementation = function(arg) {
        console.log('Method called with argument:', arg);
        return 'Modified return value';  // 修改返回值
    };
});

​ 主动调用静态方法:

Java.perform(function () {
    const MyClass = Java.use('com.example.MyClass');  // 获取目标类的引用

    // 主动调用静态方法
    MyClass.staticMethod('Hello, static method!');  // 直接调用静态方法

    MyClass.staticMethod.implementation = function(arg) {
        console.log('Static method called with argument:', arg);
        return 'Modified static return value';  // 修改返回值
    };
});

遍历其他数据结构

Map

const mapField= this.stringMap.value; // 获取Map字段
if(mapField != null){
    const keySet = mapField.keySet();
    let iterator = keySet.iterator(); // 获取迭代器
    while(iterator.hasNext()){
        let key = iterator.next();
        let value = mapField.get(key);;
        console.log("  ["+ key + "=>" + value + "]");
    }
}

​ 之前做过的某题,使用Java.castthis转换为CollectionTraversal类型,然后就是用.value获取不同数据结构对应的字段

Java.perform(function () {
    var CollectionTraversal = Java.use("cn.binary.frida.CollectionTraversal");

    CollectionTraversal.traverseCollections.implementation = function () {
        console.log("[*] traverseCollections called");
        // 获取当前实例的字段
        var listField = this.stringList.value;
        var mapField = this.stringMap.value;
        var arrayField = this.stringArray.value;
        // 遍历 List<String>
        if (listField !== null) {
            var size = listField.size();
            console.log("[-] stringList:");
            for (var i = 0; i < size; i++) {
                var item = listField.get(i);
                console.log("   [" + i + "]: " + item);
            }
        }
        // 遍历 Map<String, String>
        if (mapField !== null) {
            console.log("[-] stringMap:");

            try {
                var keySet = mapField.keySet();
                var iterator = keySet.iterator(); // 获取迭代器
                while (iterator.hasNext()) {
                    var key = iterator.next(); // 获取下一个元素
                    var value = mapField.get(key); // 获取键对应的值
                    console.log("   [" + key + " => " + value + "]");
                }
            } catch (e) {
                console.log("   Error in stringMap: " + e.message);
            }
        }
        // 遍历 String[]
        if (arrayField !== null) {
            var arrayLength = arrayField.length;
            console.log("[-] stringArray:");
            for (var j = 0; j < arrayLength; j++) { // 遍历数组
                var val = arrayField[j]; // 获取数组元素
                console.log("   [" + j + "]: " + val); // 打印数组元素
            }
        }

        // 调用原始函数
        return this.traverseCollections.call(this);
    };
});

调用栈打印

什么是调用栈?
调用栈是函数调用过程中形成的堆栈信息,记录了函数调用的顺序和位置。说人话就是看xx方法由谁调用,然后层层递进,直到最开始的调用者。在逆向工程中,了解是谁调用了某个函数非常重要,通过分析调用栈(CallStack),我们可以快速定位调用路径,从而更好地还原程序逻辑。

function print_callstack( {
const Log = Java.use("android.util.Log");
const Exception = Java.use("java.lang.Exception");
console.log(Log.getStackTraceString(Exception.$new()));

​ 它的原理是主动调用Java代码,生成一个异常对象,然后打印异常对象的堆栈信息。
​ 等价的Java代码:

android.util.Log().getStackTraceString(new java.lang.Exception())

​ 可以在任何Hook函数中调用print_callstack()来输出谁调用了它。

Native层Hook

​ 关于Native层是什么、加载方式就不细说了,这里主要讨论Frida Hook Native

Native模块遍历

​ Frida 提供了Process.enumerateModules方法列出所有模块。

Process.enumerateModules().forEach(function(module){
    console.log("Module "+module.name+", Base Address:"+module.base.toString());
});

​ 当so文件拥有符号时,可采用Moudle.findExportByName的方法查找地址

const func_addr = Module.findExportByName('libnative.so','target_function');
console.log(func);

​ 没有符号时,函数地址 = 模块基地址 + 偏移量 (通常在IDA中将起始地址设为0,则函数在IDA中的地址即为偏移量)

const offset = 0x1234;
const module_base = Module.findBaseAddress('libnative.so','target_function');
const func_addr = module_base.add(offest);

​ Frida对指针变量存放进行了封装,实现了多种内存操作的辅助函数(这也是为什么上面代码不用加号运算符的原因),如可以使用NativePointer新建指针

const addr = new NativePointer('0x1234');

​ 此外,使用NativeFunction类可以创建Native函数的引用

const func = new NativeFunction(addr,'int',['pointer','int']); // 地址、返回值、参数列表

​ hexdump可以打印内存。不过个人感觉用ida动调要方便些,

//读取128字节的内存内容
var data = Memory.readByteArray(addr,128);
console.log(hexdump(addr,{
	offset : 0,
	length : 128,
	header : true,
	anssi : true   
}));

基本结构

Interceptor.attach 是 Frida 提供的一个功能强大的 API,用于hook目标函数。当目标函数被调用时,onEnteronLeave 回调函数会被触发。

  • onEnter 在目标函数被调用时执行,允许你访问传入的参数。
  • onLeave 在目标函数返回时执行,允许你访问返回值,并且可以修改返回值。
Interceptor.attach(targetAddress, {
    onEnter: function(args) {
        // 在这里处理函数调用前的逻辑
    },
    onLeave: function(retval) {
        // 在这里处理函数返回时的逻辑
    }
})

​ 在 onEnter 回调中,我们可以访问函数的输入参数。这些参数通过 args 数组传递,每个参数是一个 NativePointer 对象。可以通过以下方式打印或修改这些参数:

  • args[index].toInt32():将参数转换为 32 位整数。
  • args[index].toString():将参数转换为字符串。
  • args[index].readUtf8String():如果参数是字符串指针,可以读取字符串内容。

onLeave 回调中,我们可以访问和修改函数的返回值。

  • retval.toInt32():获取返回值作为整数。
  • retval.replace(value):修改返回值,value 可以是任何合法的值或 NativePointer 对象。

​ 例如这里hook了open函数,致使当程序试图打开包含"hack"字样的文件时,打开失败

var openFunc = Module.findExportByName(null, 'open');
console.log('[*] open: ' + openFunc);
Interceptor.attach(openFunc, {
    onEnter: function(args) {
        console.log('[*] open 被调用');

        const filename_ptr = args[0];
        const filename = Memory.readUtf8String(filename_ptr);

        console.log('[*] filename: ' + filename);

        if (filename.includes('hack')) {
            console.log('[*] 禁止打开文件: ' + filename);
            this.forbid = true;
        } else {
            console.log('[*] 允许打开文件: ' + filename);
            this.forbid = false;
        }
    },
    onLeave: function(retval) {
        if (this.forbid) {
            retval.replace(-1);
        }
    }
});

​ 这里的this与hook java层的this不同。Java层的this指代Java 对象本身,而这里的this指代函数被hook时调用一次的过程,生命周期随目标函数调用的开始而开始,随调用结束而结束。因此可以利用js的动态性向this中添加属性,即this.forbid,使得forbid能够同时在两个回调函数中调用。(不嫌污染全局变量就全用var一样的)

文件重定向

var openFunc = Module.findExportByName(null, 'open');
console.log('[*] open: ' + openFunc);
Interceptor.attach(openFunc, {
    onEnter: function(args) {
        console.log('[*] open 被调用');
        var filename_ptr = args[0];
        var filename = Memory.readUtf8String(filename_ptr);
        console.log('[*] filename: ' + filename);

        if (filename.includes('/proc/self/status')) {
            console.log('[*] 重定向到 /data/local/tmp/status.txt');

            // 重新申请内存
            var new_filename_ptr = Memory.allocUtf8String('/data/local/tmp/status.txt');
            args[0] = new_filename_ptr;
            this.new_filename_ptr = new_filename_ptr; 
        }
    },
});

​ 使用Memory.allocUtf8String申请内存写入字符串,替换arg[0]为新的内存地址

主动调用

var getLicense = Module.findExportByName("libfrida.so", '_Z10getLicenseiPKc');
console.log('[*] getLicense: ' + getLicense);

var getLicenseFunc = new NativeFunction(getLicense, 'pointer', ['int', 'pointer']);

for (var i = 0; i < 3; i++) {
    var password_ptr = Memory.allocUtf8String("password");
    var license = getLicenseFunc(i, password_ptr);
    console.log('[*] license: ' + Memory.readUtf8String(license));
}

​ 使用NativeFunction创建函数引用,方便用于主动调用。然后用 Memory.allocUtf8String分配内存,用于传递字符串参数,最后使用Memory.readUtf8String读取返回的字符串值。

NativeFunction创建函数引用

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

address函数地址,传入NativePointer类型;returnType返回值类型,传入string类型;argTypes参数类型,传入array;abi调用约定,通常默认

内存搜索

const pattern = "66 6c 61 67 7b 46 49 4e 44 5f 4d 45 5f"; // ASCII: flag{FIND_ME_

Process.enumerateRanges("--rw-").forEach(range => {
    try {
        Memory.scan(range.base, range.size, pattern, {
            onMatch: function (address, size) {
                const str = Memory.readUtf8String(address);
                console.log("[*] Found flag:", str);
            },
            onError: function (reason) {
                console.error("Memory scan error:", reason);
            },
            onComplete: function () {
                
            }
        });
    } catch (error) {
        console.error("Memory scan error:", error);
    }
});

​ Process.enumerateRanges("--rw-")枚举当前进程中所有具备 读/写权限rw)的内存段,不包括可执行(x)段。 flag 通常保存在数据段或堆中,而不是代码段。遍历这些内存范围,在每个内存段内扫描是否存在该 hex 字节模式。

​ onMatch:如果匹配到,就将该地址的数据当作 UTF-8 字符串读取,并打印出来。

​ onError:如果扫描出错,输出错误原因。

​ onComplete:扫描该段内存结束后的回调(这里为空)。

​ 用try catch包裹整个扫描过程,防止因某段内存访问异常而中断整个流程。

Patch(Arm64Writer)

Frida CModule

Frida检测与对抗

Frida Stalker Trace

posted @ 2025-11-17 17:24  纸飞机低空飞行  阅读(49)  评论(0)    收藏  举报