好!我学!
了解PLT、GOT
在ELF文件中,PLT是用于管理外部函数调用的表,它在程序启动时并不知道每个动态链接函数的确切内存地址,而是在第一次调用时通过GOT表来进行解析。GOT表中存储了这些函数的实际内存地址。当函数被调用时,它会先跳转到PLT中的一个入口,该入口会进一步跳转到GOT中的对应项,然后才会跳转到实际的函数地址。
首先,通过IDA打开文件一个SO文件,查看他的段视图(视图->打开子视图->段)
首先我们需要了解他们都是干什么的:
在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,可以看到段的起始地址、段类型、段的大小等信息,具体如图:
在一些情况下,GOT 表可能不会被明确标记为一个独立的节,特别是在节头表被剥离的情况下。在这种情况下,你可能需要手动查看与动态链接相关的重定位条目来推断 GOT 表的位置。当然这种事情IDA会帮我们做。(视图->打开子视图->段->找到.got)
解析GOT表项
PLT:
PLT跳转到GOT:
调用av_find_input_format的地方:
函数调用大致流程:
函数调用->执行PLT对应代码取到GOT对应的条目的函数地址->跳转到该函数地址 对于库内函数GOT保存的是该函数的实现地址 对于库外函数GOT保存的是extern段的地址,这个段项会保存着目标函数的实际内存地址
对于外部函数地址需要等待该函数第一次执行时,才会初始化这个值
加载原理: 在DYNAMIC段中(这个段的信息在PHT中有),会有类型为DT_NEEDED的信息,这表示当前库需要依赖这些库。
共享库中的IMPORT值通常会在库加载到内存时就被动态链接器解析并赋值。这意味着在运行时之前,这些值已经被初始化。当操作系统加载一个共享库时,动态链接器会执行以下步骤:
- 加载共享库:将共享库加载到进程的地址空间中。
- 符号解析:对于共享库中的所有符号引用(如函数和全局变量),动态链接器会在所有已加> 载的共享库中查找这些符号的定义,并将它们的地址解析出来。
- 重定位:将解析出来的符号地址填充到共享库的全局偏移表(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
后面是符号表
每个符号项大小都是固定的,都表示一个结构体:Elf64_Sym
,具体如图:
我们使用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. */
举例子:
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))
声明的类型,只支持在不同源文件去使用。
STT_FUNC表示这是一个函数
0x12表示全局函数
重定位 Relocation
linker会对so库进行重定位,他会更新plt所指向的got中所指向的函数的实际地址。
对应的表:ELF JMPREL Relocation Table
这个表的每个表项都会对应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位系统上,用户空间和内核空间的地址范围可能如下:
- 用户空间(User Space): Android 应用程序运行在用户空间。在一些系统上,用户空> 间可能占据高位地址,而其范围可能是从 0x0000000000000000 到 0x7FFFFFFFFFFFFFFF(共计 2^63 个地址)。这是一个极其庞大的地址范围,远远超过目前实际可用的内存。
- 内核空间(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表项的类型:
-
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 位过程链接表地址- 等等...
-
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表
-
GLOB_DAT表示r_offset指向了got表,指向got基本都是一些变量的地址,函数一般都是got.plt表中的
-
Symbol Table也会指向.data.rel.ro表: .data.rel.ro会保存一些我们声明的一些类class,包括类中的函数
ObjectA是一个class
-
Symbol Table表项
-
指向ObjectA,类型是Object
- 指向处:data.rel.ro表中的class声明:
- 指向处:data.rel.ro表中的class声明:
-
指向ObjectA类中的函数:虽然这个函数属于ObjectA,但实际上指向的是函数的实现地址
- 指向处:
- 指向处:
-
指向对象
-
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层打开过的文件
- 记录帧率: