优秀的编程知识分享平台

网站首页 > 技术文章 正文

Frida底层原理详解

nanyue 2024-11-22 18:35:24 技术文章 2 ℃

1 root权限

frida-server 在 Android 设备上运行时需要 root 权限,这主要是由于它的工作涉及到操作其他应用程序的内存、修改进程状态、拦截系统调用等功能。而在 Android 系统(以及其他类 Unix 系统)中,操作系统的权限管理机制决定了只有具有 root 权限的进程才可以进行这些敏感的操作。

1.1为什么需要 root 权限?

frida-server 需要 root 权限的主要原因是:

  1. 访问其他进程的内存
  2. 在 Linux/Android 系统中,每个进程都有自己独立的虚拟内存空间。普通用户进程不能随意访问或修改其他进程的内存,以保证系统的安全性和稳定性。
  3. frida-server 通过使用 ptrace 系统调用来注入和操作目标进程。ptrace 是一个用于监视和控制其他进程的系统调用,常用于调试器(如 gdb)来控制被调试进程。只有 root 用户或有特定权限的用户才能对其他具有不同用户权限的进程调用 ptrace
  4. 如果没有 root 权限,Frida 将无法附加到其他进程,也无法读取或修改其内存。
  5. 注入动态库和修改进程行为
  6. Frida 需要将其引擎(动态库)注入到目标进程的内存空间中,这需要对目标进程的内存进行修改和写入。注入后,Frida 的引擎会加载并执行 JavaScript 代码,操控目标进程的行为。
  7. 修改进程的地址空间(如在目标进程中注入动态库)是一种敏感操作,普通权限的用户进程是无法完成的。
  8. 挂钩系统 API 和函数
  9. Frida 通过其动态库注入技术,能够在目标进程中挂钩函数、拦截函数调用。这些操作涉及到修改函数指针或改变进程的执行流。通常,操作系统只允许 root 权限或管理员权限的进程进行这种操作,以防止恶意程序篡改其他进程的行为。
  10. 与其他进程建立调试连接
  11. 在类 Unix 系统中,调试进程(如使用 ptrace 调试)和被调试进程必须具有相同的用户权限,或者调试进程具有更高的权限(如 root)。
  12. frida-server 需要充当调试器,才能向目标进程发送命令、读取寄存器和内存状态,因此它需要具有 root 权限。

1.2底层原理

frida-server 使用的一些底层技术依赖于系统的低级功能,这些功能通常只有 root 权限才能访问。以下是 frida-server 依赖的一些底层原理:

1.ptrace 系统调用

ptrace 是 Linux 中的一个系统调用,用于允许一个进程(通常是调试器)控制另一个进程的执行。它提供了调试和进程监控的功能,例如:

  • 读取和写入目标进程的内存。
  • 检查和修改目标进程的寄存器。
  • 拦截和修改系统调用。
  • 控制目标进程的执行流(如单步执行)。

权限要求:在 Android 中,一个进程只有在以下条件之一下才能对另一个进程调用 ptrace

  • 调试进程和目标进程是同一个用户,且处于同一个 Android 应用的 UID 下。
  • 调试进程具有 root 权限。

对于 Android 系统中的大多数用户进程,由于它们运行在不同的 UID 下,因此普通用户是没有权限使用 ptrace 来调试其他应用进程的。

2.访问/proc文件系统

/proc 文件系统包含了每个进程的各种信息(如内存映射、寄存器状态等)。frida-server 需要读取 /proc 文件系统中的信息来确定目标进程的内存布局、加载的动态库等。

权限要求:在 Android 中,访问 /proc 中的其他进程的信息通常需要 root 权限,尤其是当你试图读取进程的内存映射(如 /proc/<pid>/maps)或修改其状态时。

3.修改进程地址空间

frida-server 的主要功能之一是将 Frida 的核心引擎(动态库)注入到目标进程中。这需要以下操作:

  • 分配内存:在目标进程的地址空间中分配内存来存储 Frida 引擎。
  • 写入动态库路径:将 Frida 动态库的路径写入目标进程的地址空间,以便目标进程能够加载它。
  • 调用 dlopen:在目标进程中执行 dlopen 调用来加载动态库。

这些操作都涉及到对目标进程地址空间的直接操作,而这在操作系统的安全机制下是被严格限制的。通常情况下,这些操作需要 root 权限。

4.动态 Hook 和函数拦截

frida-server 通过 Hook 技术可以拦截目标进程的函数调用。为了实现这一点,Frida 会:

  • 修改目标函数的入口地址,使其跳转到 Frida 的处理代码。
  • 修改目标进程的堆栈和寄存器来执行 Hook 逻辑。

这些底层操作需要对目标进程的指令流和内存进行修改,因此也需要 root 权限。

2 Hook 过程

Frida 的 Hook 技术主要依赖于其动态库注入内存修改的能力,通过动态注入自己的动态库(Frida 的引擎库)来修改目标进程的函数指针或方法,从而实现对目标进程的函数调用进行拦截。下面通过具体的例子来详细说明 Frida 如何在目标进程中挂钩(Hook)函数。

示例:在 Android 应用中 Hookopen系统调用

我们以在 Android 应用中 Hook open 系统调用为例进行讲解。open 是一个常见的文件操作函数,它的原型是:

int open(const char *pathname, int flags);

我们希望通过 Frida 来拦截对 open 函数的调用,打印出每次打开的文件路径和标志参数。

2.1 在目标进程中 Hookopen函数

我们将使用 Frida 的 JavaScript 脚本来实现对目标进程的 Hook:

// hook_open.js
Java.perform(function () {
    // 从目标进程中获取 libc.so 动态库的基址
    var libc = Module.findBaseAddress('libc.so');
    if (libc === null) {
        console.log('libc.so not found');
        return;
    }

    // 获取 open 函数的实际地址
    var openPtr = Module.findExportByName('libc.so', 'open');
    if (openPtr === null) {
        console.log('open function not found');
        return;
    }

    console.log('open function address:', openPtr);

    // 使用 Interceptor.attach 来 Hook open 函数
    Interceptor.attach(openPtr, {
        onEnter: function (args) {
            // 打印出打开的文件路径
            var path = Memory.readCString(args[0]);
            console.log('open called with path:', path);

            // 打印 open 函数的 flags 参数
            var flags = args[1].toInt32();
            console.log('open called with flags:', flags);
        },
        onLeave: function (retval) {
            // 打印 open 函数的返回值
            console.log('open returned:', retval.toInt32());
        }
    });
});

2.2 启动 Frida Server

  1. 在目标 Android 设备上启动 frida-server,通常需要 root 权限。
  2. adb push frida-server /data/local/tmp/ adb shell su chmod +x /data/local/tmp/frida-server /data/local/tmp/frida-server &

2.3 使用 Frida 客户端运行脚本

在 PC 上,通过 Frida 客户端将脚本注入到目标进程中(假设目标进程的 PID 为 1234):

frida -U -n <package_name> -l hook_open.js

或者你可以直接使用目标应用的包名进行 Hook:

frida -U -f <package_name> -l hook_open.js --no-pause

2.4 Hook 过程的实现原理

1.查找目标函数地址

Frida 使用 Module.findExportByName 来查找目标进程中导出的 open 函数的地址。这个步骤的背后涉及到读取目标进程的内存,并解析动态库的符号表,找到目标函数的实际地址。

2.插入 Hook

Frida 的核心原理之一是通过 Interceptor.attach 来插入 Hook。这个方法会在目标函数的入口地址上插入一个跳转指令,使得每次调用目标函数时,都会先执行 Frida 插入的 Hook 逻辑。

  • onEnter:在目标函数被调用之前执行。
  • onLeave:在目标函数返回之后执行。

3.操作函数参数和返回值

onEnter 回调中,我们可以读取传递给 open 函数的参数(路径名和标志),并进行相应的操作。Frida 提供了丰富的 API 访问内存,例如 Memory.readCString 来读取指针指向的字符串。

onLeave 回调中,我们可以读取目标函数的返回值,并根据需要进行修改。

5. 更高级的 Hook:修改函数参数或返回值

除了打印函数调用的参数和返回值,Frida 还允许我们修改这些值。例如,我们可以修改传递给 open 函数的文件路径,使得目标进程总是尝试打开一个不同的文件:

Interceptor.attach(openPtr, {
    onEnter: function (args) {
        // 修改文件路径
        var originalPath = Memory.readCString(args[0]);
        console.log('Original open path:', originalPath);

        var newPath = '/new/fake/path.txt';
        args[0] = Memory.allocUtf8String(newPath);

        console.log('Modified open path:', newPath);
    },
    onLeave: function (retval) {
        console.log('open returned:', retval.toInt32());
    }
});

Hook 实现背后的技术原理

  1. 动态库注入:在 Android 系统中,通过 frida-server 将 Frida 的引擎动态库注入到目标应用的进程空间中。这允许 Frida 在目标进程内执行 JavaScript 代码,并访问和修改进程的内存。
  2. 拦截函数调用:通过 Frida 的 Interceptor.attach 方法,在目标函数的入口处插入一个跳转指令,这个跳转指令会跳到 Frida 预先注入的代码位置。通过这个跳转机制,Frida 可以在目标函数被调用之前或之后执行自己的逻辑。
  3. 访问进程内存:Frida 提供了一些内存操作函数(如 Memory.readCStringMemory.allocUtf8String)来读取或修改目标进程的内存。这些操作基于 Frida 的内存访问 API,允许我们在目标进程中安全地操作内存。
  4. 修改返回值和参数:Frida 可以在 onEnteronLeave 回调中修改函数参数和返回值。通过直接修改目标进程的内存或寄存器值,Frida 可以改变函数的执行逻辑。

3 注入ELF 文件

Module.findExportByName 是 Frida 提供的一个非常强大的 API,它能够在运行时查找目标进程中某个模块(如动态库)导出的函数或符号的地址。这个功能是 Frida 动态注入和 Hook 技术的基础。

3.1理解 ELF 文件格式

在 Android 和大多数 Linux 系统中,应用程序和动态库的文件格式都是 ELF(Executable and Linkable Format)。ELF 文件包含了以下几部分:

  • ELF 头部:存储文件的基本信息(如类型、字节序、入口地址等)。
  • 程序头表:描述了进程加载 ELF 文件时所需的内存布局。
  • 节头表(Section Header Table):描述了文件中每个节的属性(如 .text.data.symtab 等)。
  • 符号表:存储了文件中所有符号的名称、类型、绑定属性和地址信息。

当 ELF 文件被加载到内存中(即进程运行时),动态链接器会将这些符号表加载进来,并解析符号的实际地址。

3.2动态链接和符号解析

在应用程序运行时,操作系统会加载可执行文件和所有需要的动态库。在这个过程中,动态链接器(如 Android 上的 ld.so)负责:

  • 加载 ELF 文件和所有依赖的动态库
  • 解析符号表,并将符号名称映射到实际的内存地址。

符号解析的结果会存储在进程的内存空间中。在运行时,所有已解析的符号可以通过符号表(如 .dynsym.symtab)来访问。

3.3Frida 如何查找导出的符号

Module.findExportByName 这个函数的核心工作是:读取目标进程中指定模块的符号表,并在其中查找特定名称的符号,获取该符号在内存中的地址

具体步骤如下:

  1. 获取模块的基地址
  2. 当 Frida 调用 Module.findBaseAddress('libc.so') 时,它实际上是查询了当前进程中所有加载的模块,并找到指定模块的基地址。Frida 通过读取 /proc/<pid>/maps 文件,来获取所有模块的加载信息。
  3. cat /proc/<pid>/maps
  4. /proc/<pid>/maps 文件包含了每个进程的内存映射信息,例如:
  5. 7f83a2a000-7f83a3b000 r-xp 00000000 fd:01 1719337 /lib/x86_64-linux-gnu/libc-2.27.so
  6. 通过解析这个文件,Frida 可以获取 libc.so 的基地址。例如,libc.so 的基地址可能是 0x7f83a2a000
  7. 读取模块的符号表
  8. 当获取了模块的基地址后,Frida 需要解析 ELF 文件中的符号表。对于一个加载在内存中的 ELF 文件,Frida 通过读取并解析 .dynsym(动态符号表)或者 .symtab(静态符号表) 来查找导出的符号。
  9. Frida 使用的底层原理类似于解析 ELF 文件格式中的符号表。符号表中的每个条目(Elf32_SymElf64_Sym 结构)包括了以下信息:
  10. 符号名称的索引(在 .strtab 字符串表中)。
  11. 符号的地址。
  12. 符号的类型(如函数、对象)。
  13. 符号的大小。
  14. Frida 通过在这些条目中查找匹配的符号名称(如 open),并返回其对应的地址。
  15. 在符号表中查找匹配的符号
  16. 当 Frida 调用 Module.findExportByName('libc.so', 'open') 时,它会执行以下步骤:
  17. 使用模块的基地址和 ELF 文件格式信息来找到符号表的位置(通常是 .dynsym)。
  18. 遍历符号表中的所有符号,并检查符号的名称是否匹配 open
  19. 如果找到匹配的符号,计算其在内存中的实际地址,并返回。
  20. 这个过程中,Frida 需要读取目标进程的内存,并解析 ELF 文件的结构。这通常需要依赖 Frida 的内存访问能力和对 ELF 文件格式的深度理解。

3.4底层细节:解析 ELF 和内存访问

在 Frida 的实现中,它使用了一些底层技术来解析和访问目标进程的内存。具体包括:

  1. 内存读取(Memory Access)
  2. Frida 可以通过 ptrace 系统调用(或其他操作)读取目标进程的内存数据。这是因为在 Linux 系统中,ptrace 可以允许一个进程读取另一个进程的内存。Frida 可以通过这种方式读取 ELF 文件的符号表和字符串表。
  3. ELF 文件解析(ELF Parsing)
  4. Frida 内部实现了对 ELF 文件格式的解析逻辑。它能够根据模块的基地址、ELF 头、程序头和节头表等信息,找到符号表和字符串表的位置,并遍历这些表来查找导出的符号。
  5. 计算符号的内存地址
  6. 在解析 ELF 文件的符号表时,Frida 获取了符号的偏移地址。结合模块的基地址,Frida 可以计算出符号在内存中的实际地址。

4 Java 层的函数

在 Android 应用中,Java 层的函数并不像 C/C++ 函数那样直接存储在 ELF 文件的符号表中。Java 层的函数主要由 Android 虚拟机(Dalvik/ART)管理。为了查找并 Hook Java 层的函数,Frida 提供了对 Java 虚拟机的直接操作接口。下面我将详细解释在 Android 的 Java 层查找并 Hook 一个函数的过程。

我们以一个常见的 Android Java 层的示例为例:假设目标应用中有一个 Java 类 com.example.MyClass,其中有一个方法 void myMethod(String arg),我们希望使用 Frida 来查找并 Hook 这个方法。

4.1 Frida 查找 Java 层函数的原理

  1. Java.perform:这是 Frida 提供的 API,用于确保在 Java 虚拟机(Dalvik/ART)环境中执行代码。它会确保所有的 Java 类和方法都已经加载并初始化完成。
  2. Java.use:通过类名来加载并获取一个 Java 类的引用。
  3. 替换方法:使用 Frida 提供的 overloadimplementation 接口来替换 Java 方法的实现,从而实现 Hook。

4.2 示例代码:Hook Java 层的函数

假设我们有以下 Android 应用中的 Java 类和方法:

// com/example/MyClass.java
package com.example;

public class MyClass {
    public void myMethod(String arg) {
        System.out.println("Original myMethod called with arg: " + arg);
    }
}

我们希望通过 Frida 来 Hook 这个 myMethod 方法,打印出调用时的参数,并在函数执行后修改参数或返回值。

4.3 使用 Frida 来 Hook Java 层的函数

1. 编写 Frida 的 JavaScript 脚本

// hook_java_method.js

Java.perform(function () {
    // 使用 Java.use 来获取目标类
    var MyClass = Java.use("com.example.MyClass");

    // Hook 目标方法 myMethod
    MyClass.myMethod.overload("java.lang.String").implementation = function (arg) {
        // 打印调用时的参数
        console.log("Hooked myMethod called with arg: " + arg);

        // 调用原始方法
        var result = this.myMethod(arg);

        // 打印方法执行后的结果
        console.log("Original myMethod executed");

        // 可以根据需要修改参数或返回值
        return result;
    };
});

2. 启动目标 Android 应用并加载脚本

在 PC 上使用 Frida 客户端将脚本注入到目标应用中,假设目标应用的包名为 com.example.targetapp

frida -U -f com.example.targetapp -l hook_java_method.js --no-pause

-U 表示连接到 USB 设备,-f 表示启动目标应用,-l 表示加载指定的脚本文件,--no-pause 表示注入后不暂停应用。

详细解释

1.Java.perform的作用

Java.perform 是 Frida 提供的一个非常重要的函数,它会确保你在访问 Java 类和方法时,Java 虚拟机已经准备好所有的类和方法。这个函数的实现原理是,Frida 在底层通过 JNI(Java Native Interface)与 Android 虚拟机(Dalvik/ART)进行交互。

2.Java.use查找并使用 Java 类

Java.use 函数会根据指定的类名来查找并返回一个 Java 类的引用。这个过程的底层原理是:

  • Frida 通过 JNI 调用 Android 虚拟机提供的 FindClass 函数来查找指定的 Java 类。
  • 找到类之后,Frida 会通过 JNI 进一步查询类中的所有方法,并在内部维护一个方法表(Method Table),方便后续进行方法的替换或调用。

3.使用 overload 和 implementation 替换方法

在 Frida 中,每个 Java 方法都有一个 overload 属性,它表示这个方法的不同重载版本。通过指定参数类型(如 java.lang.String),我们可以精确找到我们希望 Hook 的具体方法。

implementation 属性用于替换方法的实现,Frida 在底层通过 JNI 操作 Dalvik/ART 虚拟机的内部结构来完成这个替换过程:

  • Frida 使用 JNI 调用来获取目标方法的原始地址。
  • 然后,它将一个自定义的回调函数(在 JavaScript 中编写的)替换为原始方法的实现地址。
  • 当目标应用调用这个方法时,它会跳转到 Frida 注入的回调函数中,从而实现 Hook。

4.4 原理细节

1.JNI(Java Native Interface)调用

Frida 与 Android 虚拟机的交互主要依赖于 JNI。JNI 是 Java 虚拟机与原生 C/C++ 代码之间的桥梁,Frida 通过 JNI 函数来实现对 Java 类和方法的查找和操作。

  • FindClass:查找指定名称的 Java 类。
  • GetMethodID:获取某个类中的方法 ID。
  • CallMethod:调用 Java 方法。
  • SetMethodID:设置或修改某个 Java 方法的实现地址。

2.方法表的修改(Method Table Manipulation)

在 Android 虚拟机(Dalvik/ART)中,每个 Java 类都有一个方法表(Method Table),用于存储类中所有方法的相关信息,包括方法的名称、参数类型、返回值类型以及方法的实际内存地址。

Frida 通过 JNI 和虚拟机内部 API 读取并修改这个方法表,将某个方法的入口地址替换为自己的回调函数的地址,从而实现方法的拦截。
Frida 在 Android 系统中 Hook Java 层的函数时,主要是通过 JNI 与 ART(Android Runtime) 或 Dalvik 进行交互。JNI 是 Java 虚拟机(JVM)与本地(Native)代码交互的一种接口标准,允许本地代码(如 C/C++)访问 Java 虚拟机中的类、方法和对象。

3.主要流程

Frida 注入到目标应用进程: Frida 的 frida-server 通过进程注入技术,将自己的动态库注入到目标应用的进程空间中。这个注入过程通常通过低级系统调用(如 ptrace)实现。

在目标进程中启动 Frida 引擎: 一旦注入成功,Frida 会启动一个嵌入的 JavaScript 引擎(如 V8)来执行用户编写的 JavaScript Hook 脚本。

通过 JNI 与 ART/Dalvik 交互: Frida 内部实现了对 JNI 的一系列封装。
通过 JNI,Frida 可以调用 Android 系统提供的底层接口来:

查找 Java 类(通过 FindClass)。
获取方法 ID(通过 GetMethodID 或 GetStaticMethodID)。
调用 Java 方法(通过 CallMethod)。
修改方法的实现(通过修改虚拟机的 Method Table)。Java 方法的存储与管理不同于本地方法:在 Java 层,所有的 Java 类和方法是由 Android 的 ART/Dalvik 虚拟机来管理的,而不是像本地方法那样直接存储在 ELF 文件的符号表中。因此,Java 层的方法是虚拟机内部的结构,而非 ELF 文件中的符号。

Frida 的 JNI 调用与虚拟机交互:Frida 的 Java Hook 主要是通过 JNI 来操作 ART/Dalvik 虚拟机。Frida 使用 JNI 来查找 Java 类和方法,调用和替换方法的实现,而这些操作不需要解析 ELF 文件。

最近发表
标签列表