【Android JNI】我的第一次PLT-HOOK尝试

1,483 阅读16分钟

好!我学!

了解PLT、GOT

在ELF文件中,PLT是用于管理外部函数调用的表,它在程序启动时并不知道每个动态链接函数的确切内存地址,而是在第一次调用时通过GOT表来进行解析。GOT表中存储了这些函数的实际内存地址。当函数被调用时,它会先跳转到PLT中的一个入口,该入口会进一步跳转到GOT中的对应项,然后才会跳转到实际的函数地址。

首先,通过IDA打开文件一个SO文件,查看他的段视图(视图->打开子视图->段)

image.png

首先我们需要了解他们都是干什么的:

在ELF文件中,“段”(Segment)和“节”(Section)是两个基本的概念。它们都是文件的不同部分,但它们的目的和用法有所不同。

段(Segment)

段是ELF文件在内存中的映射,它们是为了程序的执行而设计的。一个段可以包含多个节,它定义了一系列连续的内存区域,以及如何将这些区域加载到内存中。段通常与内存管理和程序执行直接相关。例如,一个可执行的段会被加载到内存中并被标记为可执行,而数据段则可能被标记为可读写。

ELF文件中的段信息是通过程序头表(Program Header Table)来描述的。这个表列出了所有的段,以及它们在文件中的位置、在内存中的位置、它们的大小和它们应该如何被处理。

为什么要分段

分段的主要原因是为了内存管理的效率和权限控制。不同的段可以有不同的读、写、执行权限,这有助于操作系统提供内存保护,防止程序的数据段被执行或程序的代码段被修改。

段的类型和作用

  • LOAD: 这是一个通用的段类型,表示这部分内容需要被加载到内存中。通常,至少有两个LOAD段,一个是只读的(通常包含代码),另一个是可读写的(通常包含数据)。
  • rodata(只读数据) : 存储程序的只读数据,比如字符串常量和其他常量数据。
  • gcc_except_table: GCC编译器用来存储异常处理表的地方,这是C++异常处理机制的一部分。
  • eh_frame_hdr: 存储异常处理帧的头信息,用于快速搜索异常处理帧。
  • eh_frame: 存储异常处理帧的信息,用于在程序抛出异常时,恢复栈的状态。
  • text: 存储程序的机器代码,即实际执行的指令。
  • PLT(Procedure Linkage Table) : 用于动态链接,存储程序调用动态链接库函数时的跳转指令。
  • data_rel_ro(只读数据,可重定位) : 存储初始化的只读数据,这些数据可能在程序启动时被动态链接器修改。
  • fini_array 和 init_array: 存储构造函数和析构函数的函数指针数组,分别在程序启动和退出时调用。
  • got(Global Offset Table) : 用于动态链接,存储全局变量和静态变量的地址。
  • got plt: 与PLT一起使用,用于动态链接,存储动态链接库函数的地址。
  • data: 存储已初始化的全局变量和静态变量。
  • bss: 存储未初始化的全局变量和静态变量。在文件中不占用空间,但在内存中需要分配空间。
  • extern: 这不是标准的ELF段名,它可能是特定编译器或链接器定义的,用于存储外部引用的符号。

这些段的信息(起始地址、结束地址、大小等)保存在ELF文件的节头表(Section Header Table)和程序头表中。节头表描述了文件中所有的节,程序头表描述了如何将这些节映射到内存中的段。通常,程序头表用于执行时的内存映射,而节头表则用于链接和调试。

通过IDA查看Progrem Header,可以看到段的起始地址、段类型、段的大小等信息,具体如图:

image.png

在一些情况下,GOT 表可能不会被明确标记为一个独立的节,特别是在节头表被剥离的情况下。在这种情况下,你可能需要手动查看与动态链接相关的重定位条目来推断 GOT 表的位置。当然这种事情IDA会帮我们做。(视图->打开子视图->段->找到.got)

解析GOT表项

PLT: image.png

PLT跳转到GOT: image.png

调用av_find_input_format的地方: image.png

函数调用大致流程:

函数调用->执行PLT对应代码取到GOT对应的条目的函数地址->跳转到该函数地址 对于库内函数GOT保存的是该函数的实现地址 对于库外函数GOT保存的是extern段的地址,这个段项会保存着目标函数的实际内存地址 image.png 对于外部函数地址需要等待该函数第一次执行时,才会初始化这个值

加载原理: 在DYNAMIC段中(这个段的信息在PHT中有),会有类型为DT_NEEDED的信息,这表示当前库需要依赖这些库。 image.png 共享库中的IMPORT值通常会在库加载到内存时就被动态链接器解析并赋值。这意味着在运行时之前,这些值已经被初始化。当操作系统加载一个共享库时,动态链接器会执行以下步骤:

  1. 加载共享库:将共享库加载到进程的地址空间中。
  2. 符号解析:对于共享库中的所有符号引用(如函数和全局变量),动态链接器会在所有已加> 载的共享库中查找这些符号的定义,并将它们的地址解析出来。
  3. 重定位:将解析出来的符号地址填充到共享库的全局偏移表(GOT)中。

GOT和GOT.PLT表的区别

我们查看段时,会有两个关于got的表:got和got.plt

我们跳转函数时,和plt相关的表是got.plt,那got这个表又是干什么用的呢?

据实践发现,当一个函数存在作为函数变量赋值给一个引用时,这个函数地址就会存在got表中,而不是got.plt表。

比如有的函数不会在当前库中调用,而是把该函数作为变量赋值给某一个引用,那么这个函数的地址值就会保存在GOT中,由于我们没有调用到该函数,所以不会出现在GOT.plt中,如果调用到该函数,同时也会出现在GOT.plt中。

如果一个外部函数是inline类型(比如被:_LIBCPP_HIDE_FROM_ABI标记)的,他的实现则会在此so库中,而不是在外部库,所以也不会在extern段中出现。

ELF Symbol Table

后面是符号表 image.png

每个符号项大小都是固定的,都表示一个结构体:Elf64_Sym,具体如图: image.png

我们使用IDA查看Symbol Table,如果遇到st_value值为0,表示这是一个会动态链接的函数,但是即使在动态链接完成后st_value会仍然为0.

Elf64_Sym解释: 一个 Elf64_Sym共占用0x18个字节,也就是24个字节, 结构体通常包含以下信息:

typedef struct {
    uint32_t    st_name;  /* Symbol name (string tbl index) */
    unsigned char   st_info;   /* Symbol type and binding */
    unsigned char   st_other;  /* Symbol visibility */
    uint16_t    st_shndx;  /* Section index */
    uint64_t    st_value;  /* Symbol value */
    uint64_t    st_size;   /* Symbol size */
} Elf64_Sym;
  • st_name: 符号名在字符串表中的偏移量。
  • st_info: 符号的类型和绑定属性。
  • st_other: 其他信息,通常是符号的可见性。
  • st_shndx: 符号所在的段(section)。
  • st_value: 符号的值,对于函数和变量,这通常是它们的地址。(对于导入函数这时候这个值可能还是0)
  • st_size: 符号的大小,对于函数来说通常是其代码的大小,对于变量是数据的大小。

这里的byte_5AC8是String Table的起始地址,aCxaFinalize - byte_5AC8表示的就是以byte_5AC8的偏移位数。

Symbol Table表项有个很重要的属性 st_info,表示我们指向的地址处的类型是什么; st_info占用一个字节,高4位表示绑定类型,低4位表示类型;

  • 绑定类型:
    /* bind */
    #define STB_LOCAL  0  /* Local symbol */
    #define STB_GLOBAL 1  /* Global symbol */
    #define STB_WEAK   2  /* like global - lower precedence */
    #define STB_LOOS   10 /* Start of operating system reserved range. */
    #define STB_GNU_UNIQUE 10 /* Unique symbol (GNU) */
    #define STB_HIOS   12 /* End of operating system reserved range. */
    #define STB_LOPROC 13 /* reserved range for processor */
    #define STB_HIPROC 15 /*   specific semantics. */
    
  • 类型:
    /* type */
    #define STT_NOTYPE 0  /* Unspecified type. */
    #define STT_OBJECT 1  /* Data object. */
    #define STT_FUNC   2  /* Function. */
    #define STT_SECTION    3  /* Section. */
    #define STT_FILE   4  /* Source file. */
    #define STT_COMMON 5  /* Uninitialized common block. */
    #define STT_TLS       6  /* TLS object. */
    #define STT_NUM       7
    #define STT_LOOS   10 /* Reserved range for operating system */
    #define STT_GNU_IFUNC  10
    #define STT_HIOS   12 /*   specific semantics. */
    #define STT_LOPROC 13 /* Start of processor reserved range. */
    #define STT_SPARC_REGISTER 13  /* SPARC register information. */
    #define STT_HIPROC 15 /* End of processor reserved range. */
    

举例子:

image.png

0x22: bind:STB_WEAK , type:STT_FUNC

STB_WEAK表示:

比如我们通过:void ppp() __attribute__((weak));或者__attribute__((weak)) void ppp(){}声明/定义的函数,那么如果又存在一个void ppp();或者void ppp(){}没有使用__attribute__((weak))声明或者定义的一个函数,那么在调用ppp()时,就会优先调用没有使用__attribute__((weak))的函数,这样的函数称为STB_GLOBAL的绑定类型。而被__attribute__((weak))声明的类型为STB_WEAK的绑定类型。除了使用__attribute__((weak))还可以使用__declspec(weak)

也就是说:使用__attribute__((weak))声明的类型,可以有多个同名符号的实现,而不会报错,优先级低于没有使用__attribute__((weak))声明的类型。但是我们无法在同一个cpp文件中同时去声明一个有__attribute__((weak))和一个没有__attribute__((weak))声明的类型,只支持在不同源文件去使用。

image.png

STT_FUNC表示这是一个函数

image.png

0x12表示全局函数

重定位 Relocation

linker会对so库进行重定位,他会更新plt所指向的got中所指向的函数的实际地址。

对应的表:ELF JMPREL Relocation Table

image.png

这个表的每个表项都会对应got.plt表项

对于重定向表,还有一个表是:ELF RELA Relocation Table这些是已经初始化的,但是由于加载so库时并不一定会把这些数据加载到哪一块内存,所以需要重定向重新定位到正确的地址。所以不仅外部函数需要重定向,内部函数同时也需要重定向,因为需要确定当前的实际地址,而不是so库的内部相对地址。

ELF RELA Relocation Table指向的就是那些已经初始化的数据,ELF JMPREL Relocation Table指向的是got.plt。

HOOK

到这里,我们知道函数调用,首先会去转到plt然后转到got.plt表获取该函数的实现地址,那么我们可以做的是,修改got.plt表处指向的地址用来实现HOOK。

第一步找到对应so库加载后在内存中的地址

对于一个进程,我们要找到该so库在该进程中的虚拟地址

知识普及

gpt:

对于一个64位系统,寻址范围位 0 到2的64次方-1, 在 Android 系统上,64位系统的寻址范围也符合一般的 64位系统规范。具体来说,Android 上的 > > 64位系统使用的是 ARM64 或者 x86-64 架构,这两者都支持广泛的64位寻址。 在典型的 Android 64位系统上,用户空间和内核空间的地址范围可能如下:

  1. 用户空间(User Space):  Android 应用程序运行在用户空间。在一些系统上,用户空> 间可能占据高位地址,而其范围可能是从 0x0000000000000000 到 0x7FFFFFFFFFFFFFFF(共计 2^63 个地址)。这是一个极其庞大的地址范围,远远超过目前实际可用的内存。
  2. 内核空间(Kernel Space):  内核空间包含操作系统内核和核心数据结构。在一些系统上,内核空间可能占据低位地址,其范围可能是从 0xFFFFFFFF80000000 到 0xFFFFFFFFFFFFFFFF(共计 2^64 - 2^31 个地址)。这同样是一个非常大的地址范围。 需要注意的是,这里提到的地址范围是理论上的极限,实际可用的地址空间可能受到硬件和操作系统的限制。例如,系统可能会为其他目的(比如 I/O 映射、保留内存等)留出一部分地址空间。

读取虚拟文件proc/self/maps获取基址

从输出内容来看,你会发现有好几行相同so文件的信息

78c9e80000-78c9e90000 r--p 00000000 fe:33 98 /apex/com.android.art/lib64/libjavacore.so  
78c9e90000-78c9ebc000 r-xp 00010000 fe:33 98 /apex/com.android.art/lib64/libjavacore.so  
78c9ebc000-78c9ebd000 r--p 0003c000 fe:33 98 /apex/com.android.art/lib64/libjavacore.so  
78c9ebd000-78c9ec0000 rw-p 0003c000 fe:33 98 /apex/com.android.art/lib64/libjavacore.so

这是因为不同的段有不同的权限,我们可以看到他们的权限都是不一样的。修改权限之前可以通过这个先来判断是否有写权限,有的话就不用修改权限,直接写就可以了。

这个是获取其他so文件的基址,获取当前so文件基址使用dladdr获取Dlinfo,Dlinfo.dli_fbase就是当前so文件的基址。

GNU HASH TABLE

GUN hash table

gun hash table用于快速定位要查找的函数所对应的Symbol Table表项的索引

我们可以首先使用GUN hash table查找该函数是否存在,而不用先去遍历Rela表

DT_RALA表和DT_JMPREL表

struct elf64_rela {
  Elf64_Addr r_offset;
  Elf64_Xword r_info;
  Elf64_Sxword r_addend;
} Elf64_Rela;

DT_RALA表和DT_JMPREL表的表项都是Elf64_Rela;

DT_RALA表主要存储一些object变量等信息。

DT_JMPREL表用来存储函数,所以对于替换函数来说我们使用DT_JMPREL这个表。

r_info的低32位表示rela表项的类型:ELF64_R_TYPE(r_info)可以获取rela表项的类型:

  1. x86_64 架构:

    • R_X86_64_NONE
    • R_X86_64_64:64 位绝对地址
    • R_X86_64_PC32:PC 相对 32 位地址
    • R_X86_64_GOT32:32 位全局偏移表地址
    • R_X86_64_PLT32:32 位过程链接表地址
    • 等等...
  2. ARM 架构:

    • R_ARM_NONE
    • R_ARM_ABS32:32 位绝对地址
    • R_ARM_REL32:PC 相对 32 位地址
    • R_ARM_GLOB_DAT:全局数据地址
    • R_ARM_JUMP_SLOT:跳转槽
    • 等等...

32位对应着SYMBOL TABLE中表项的索引: DT_RALA的info属性的占用8个字节,其中高4字节也就是高32位对应着SYMBOL TABLE中表项的索引

DT_RALA表项表示要跳转到函数的地址,rela表项会把这个地方的要调用的函数重定向到该函数的地址,这个地址可能是在got.plt表、got表、函数的实际地址中。

  • RALA表项
    • IDA中JUMP_SLOT就是表示r_offset指向了got.plt表,存储在DT_JMPREL表 image.png

    • GLOB_DAT表示r_offset指向了got表,指向got基本都是一些变量的地址,函数一般都是got.plt表中的 image.png

Symbol Table也会指向.data.rel.ro表: .data.rel.ro会保存一些我们声明的一些类class,包括类中的函数

ObjectA是一个class

  • Symbol Table表项

    • 指向ObjectA,类型是Object image.png

      • 指向处:data.rel.ro表中的class声明: image.png
    • 指向ObjectA类中的函数:虽然这个函数属于ObjectA,但实际上指向的是函数的实现地址 image.png

      • 指向处: image.png
    • 指向对象 image.png

got.plt表项只有函数地址,但我们无法知道该地址是哪个函数,有了DT_RALA表就能知道got.plt表项对应的是哪个函数了。

DT_RALA表中所表示的函数地址:r_info高32位表示的symbol table处的索引,通过索引就可以去symbol table处找到该函数的名字了。

如果我们通过DT_RALA表找到我们要修改的函数的plt.got表,我们修改这个地方要跳转的地址就可以了。

修改got.plt表会遇到权限问题

由于我们要修改的地方是只读的,所以要强制设置成可写,否则会由于权限问题崩溃。

mprotect 函数是用于修改内存区域保护属性的系统调用,通常用于改变一块内存的访问权限。其函数原型如下:

int mprotect(void *addr, size_t len, int prot);
  • addr:指向要更改保护属性的内存区域的起始地址。

  • len:要更改保护属性的内存区域的长度。

  • prot:新的保护属性,可以使用以下标志的按位组合:

    • PROT_NONE:无权限,不能访问。
    • PROT_READ:可读。
    • PROT_WRITE:可写。
    • PROT_EXEC:可执行。

mprotect 函数的返回值是 0 表示成功,-1 表示失败,失败时可以通过查看全局变量 errno 获取错误代码。

但是mprotect修改权限是按页修改的,但可以同时设置多个页的访问权限,只要len这个参数设置为 要修改的页数*PAGE_SIZE即可,所以要使用mprotect就要先获取该页的起始地址。

获取页面的方法:pageIndex = addr & PAGE_MASK

len参数:len = ((addr+bytes-1) & PAGE_MASK)+PAGE_SIZE - addr & PAGE_MASK

bytes表示从addr开始要修改的字节数,因为有可能横跨多个页面,那么我们要同时设置这些页面的权限。

修改地址后还要调用__builtin___clear_cache ,这个函数的目的就是告诉编译器和操作系统更新缓存,以确保最新的代码和数据被加载。因为这些数据可能会临时存储在cpu的寄存器中,

同名函数

如果存在重载函数就不能单纯的使用函数名字,应该使用符号函数名,具体的话还是用IDA加载so文件查看我们需要替换的函数在字符串表中名字。

_Z8getValuei

int getValue(int a);

_Z8getValuev

int getValue();

这样可以保证一个函数对应一个symtable,对应一个函数地址

函数不存在

虽然在头文件中引入了此函数,但是我们没有在so库中调用过此函数,那么编译器就会忽略掉这个函数,在hook时就会找不到这个函数,即使你从头文件中引入进来了。

例子

记录java层打开过的文件

  • 使用PLT-HOOK掉libjavaore.so的open函数,记录java层打开过的文件
  • 记录帧率: image.png image.png