深入理解GOT表覆写技术
0x00:前言
玩pwn的时候,有时要用到got表覆写技术,本文在于分享对GOT表覆写技术的理解,铺垫性的基础知识较多,目的在于让初学者知其然,还要知其所以然!
0x01:ELF文件生成过程
//hello.c
#include <stdio.h>
int main(){
printf("Hello World!n");
return 0;
}
执行指令:$gcc hello.c -o hello
注:gcc命令实际上是具体程序(如ccp、cc1、as等)的包装命令,用户通过gcc命令来使用具体的预处理程序ccp、编译程序cc1和汇编程序as等。
预处理过程
主要处理源文件中以“#”开头的预编译指令,经过预编译处理后,得到的是预处理文件(如,hello.i) ,它还是一个可读的文本文件 。
$gcc –E hello.c –o hello.i
编译过程
将预处理后得到的预处理文件(如 hello.i)进行词法分析、语法分析、语义分析、优化后,生成汇编代码文件。经过编译后,得到的汇编代码文件(如 hello.s)还是可读的文本文件,CPU无法理解和执行它。
$gcc –S hello.i –o hello.s(或者$gcc –S hello.c –o hello.s)
汇编过程
汇编程序(汇编器)用来将汇编语言源程序转换为机器指令序列(机器语言程序)。汇编结果是一个可重定位目标文件(如 hello.o),其中包含的是不可读的二进制代码,必须用相应的工具软件来查看其内容。
$gcc –c hello.s –o hello.o (或者$gcc –c hello.c –o hello.o)
预处理、编译和汇编三个过程针对一个模块(一个.c文件)进行处理,得到对应的一个可重定位目标文件(一个.o文件)。
链接过程
将多个可重定位目标文件合并以生成可执行目标文件
链接过程指令较复杂,此处不详细说明,具体可以参考《程序员的自我修养》第二章
0x02:目标文件格式概述
三类目标文件
1、可重定位目标文件 (Relocatable File; 后缀名为“.o”)
Linux下的.o(Windows下的.obj)
包含代码和数据,可被用来链接成可执行文件或共享目标文件,静态链接库也可以归为这一类
每个.o 文件由对应的.c文件生成
每个.o文件代码和数据地址都从0开始
2、可执行目标文件(Executable File;一般没有后缀名)
包含的代码和数据可以被直接复制到内存并被执行
代码和数据地址为虚拟地址空间中的地址
3、共享的目标文件 (Shared Object File;后缀名为“.so”)
链接器可使用.so文件跟其他.o文件和.so文件链接以生成新的.o文件
动态链接器将几个.so文件与可执行文件结合,作为进程映像的一部分来运行
特殊的可重定位目标文件,能在装入或运行时被装入到内存并自动被链接,称为共享库文件
Windows 中称其为 Dynamic Link Libraries (DLLs)
标准的几种目标文件格式
DOS操作系统(最简单) :COM格式,文件中仅包含代码和数据,且被加载到固定位置
System V UNIX早期版本:COFF格式,文件中不仅包含代码和数据,还包含重定位信息、调试信息、符号表等其他信息,由一组严格定义的数据结构序列组成
Windows: PE格式(COFF的变种),称为可移植可执行(Portable Executable,简称PE)
Linux等类UNIX:ELF格式(COFF的变种),称为可执行可链接(Executable and Linkable Format,简称ELF)
两种视图
链接视图(被链接):可重定位目标文件 (Relocatable object files)
• 可被链接(合并)生成可执行文件或共享目标文件
• 包含代码、数据(已初始化.data和未初始化.bss)
• 包含重定位信息(指出哪些符号引用处需要重定位)
• 文件扩展名为.o(相当于Windows中的 .obj文件)
执行视图(被执行):可执行目标文件(Executable object files)
• 定义的所有变量和函数已有确定地址(虚拟地址空间中的地址)
• 符号引用处已被重定位,以指向所引用的定义符号
• 没有文件扩展名或默认为a.out(相当于Windows中的 .exe文件)
• 可被CPU直接执行,指令地址和指令给出的操作数地址都是虚拟地址
0x03:ELF可重定位目标文件
.bss 节
C语言规定: 未初始化的全局变量和局部静态变量的默认初始值为0
将未初始化变量(.bss节)与已初始化变量(.data节)分开的好处
(1).data节中存放具体的初始值,需要占磁盘空间
(2).bss节中无需存放初始值,只要说明.bss中的每个变量将来在执行时占用几个字节即可,因此,.bss节实际上不占用磁盘空间,提高了磁盘空间利用率
所有未初始化的全局变量和局部静态变量都被汇总到.bss节中,通过专门的“节头表(Section header table)”来说明应该为.bss节预留多大的空间
0x04:ELF可执行目标文件
0x05:符号及符号表
链接操作的步骤
Step 1. 符号解析(Symbol resolution)
(1)确定程序中有定义和引用的符号 (包括变量和函数等)
(2)将定义的符号存放在一个符号表( symbol table)中.
符号表是一个结构数组
每个表项包含符号名、长度和位置等信息
(3)将每个符号的引用都与一个确定的符号定义建立关联
Step 2. 重定位
(1)合并相同的节:将集合E中所有目标模块中相同的节分别合并为一个新节;
(2)对定义符号进行重定位:确定新节中所有定义符号在虚拟地址空间中的绝对地址,完成这一步后,每条指令和每个全局或局部变量都可确定地址;
(3)对引用符号进行重定位:将可执行文件中符号引用处的地址修改为重定位后的地址信息.需要用到在.rel_data和.rel_text节中保存的重定位信息。
//main.c int buf[2] = {1, 2}; void swap(); int main() { swap(); return 0; } //swap.c extern int buf[]; int *bufp0 = &buf[0]; static int *bufp1; void swap() { int temp; bufp1 = &buf[1]; temp = *bufp0; *bufp0 = *bufp1; *bufp1 = temp; }
链接符号的类型
每个可重定位目标模块m都有一个符号表,它包含了在m中定义和引用的符号。有三种链接器符号:
(1) Global symbols(模块内部定义的全局符号)
由模块m定义并能被其他模块引用的符号。例如,非static C函数和非static的C全局变量(指不带static的全局变量)。如,main.c 中的全局变量名buf
(2)External symbols(外部定义的全局符号)
由其他模块定义并被模块m引用的全局符号.如,main.c 中的函数名swap
(3)Local symbols(本模块的局部符号)
仅由模块m定义和引用的本地符号。例如,在模块m中定义的带static的C函数和全局变量.如,swap.c 中的static变量名bufp1
注:局部符号不是指程序中的局部变量(分配在栈中的临时性变量)
目标文件中的符号表
.symtab 节记录符号表信息,是一个结构数组,函数名在text节中,变量名在data节或bss节中
0x06:静态链接和符号解析
静态链接对象
多个可重定位目标模块 + 静态库(标准库、自定义库)
(.o文件) (.a文件,其中包含多个.o模块)
静态库 (.a archive files)
将所有相关的目标模块(.o)打包为一个单独的库文件(.a),称为静态库文件 ,也称存档文件(archive)
使用静态库,可增强链接器功能,使其能通过查找一个或多个库文件中定义的符号来解析符号
在构建可执行文件时,只需指定库文件名,链接器会自动到库中寻找那些应用程序用到的目标模块,并且只把用到的模块从库中拷贝出来
在gcc命令行中无需明显指定C标准库libc.a(默认库)
自定义一个静态库文件
//program1.c
#include <stdio.h>
void Function(){
printf("This is the first test program!n");
}
//program2.c
#include <stdio.h>
void Function2(){
printf("This is the second test program!n");
}
//main.c
void Function( );
int main()
{
Function();
return 0;
}
$ gcc -m32 –c program1.c program2.c
$ ar rcs -m32 mylib.a program1.o program2.o //将program1.o和program2.o打包生成mylib.a
$ gcc -m32 –c main.c
$ gcc -m32 –static –o program main.o ./mylib.a
$ ./program
This is the first test program!
链接器中符号解析的全过程
三个重要集合:
E 将要被合并以组成可执行文件的所有目标文件集合
U 当前所有未解析的引用符号的集合
D 当前所有定义符号的集合
开始E、U、D为空,首先扫描main.o,把它加入E,同时把Function加入U,main加入D。接着扫描到mylib.a,将U中所有符号(本例中为Function)与mylib.a中所有目标模块(program1.o和program2.o)依次匹配,发现在program1.o中定义了Function,故program1.o加入E,Function从U转移到D。在program1.o中发现还有未解析符号printf,将其加到U。不断在mylib.a的各模块上进行迭代以匹配U中的符号,直到U、D都不再变化。此时U中只有一个未解析符号printf,而D中有main和Function。因为模块program2.o没有被加入E中,因而它被丢弃。接着,扫描默认的库文件libc.a,发现其目标模块printf.o定义了printf,于是printf也从U移到D,并将printf.o加入E,同时把它定义的所有符号加入D,而所有未解析符号加入U。处理完libc.a时,U一定是空的。
注:被链接模块应按调用顺序指定!
若命令为:$ gcc -m32–static –o myproc ./mylib.a main.o
首先,扫描mylib,因是静态库,应根据其中是否存在U中未解析符号对应的定义符号来确定哪个.o被加入E。因为开始U为空,故其中两个.o模块都不被加入E中而被丢弃。然后,扫描main.o,将Function加入U,直到最后它都不能被解析,因此,出现链接错误,因为它只能用mylib.a中符号来解析,而mylib中两个.o模块都已被丢弃!
0x07:可执行文件的加载
通过调用execve系统调用函数来调用加载器:
加载器(loader)根据可执行文件的程序(段)头表中的信息,将可执行文件的代码和数据从磁盘“拷贝”到存储器中
加载后,将PC( EIP)设定指向Entry point ,最终执行main函数,以启动程序执行
execve()函数的功能是在当前进程上下文中加载并运行一个新程序。
execve()函数的用法如下:
int execve(char *filename, char *argv[], *envp[]);
filename是加载并运行的可执行文件名(如./hello),可带参数列表argv和环境变量列表envp。若错误(如找不到指定文件filename),则返回-1,并将控制权交给调用程序; 若函数执行成功,则不返回,最终将控制权传递到可执行目标中的主函数main。
主函数main()的原型形式如下:
int main(int argc, char **argv, char **envp); 或者:
int main(int argc, char *argv[], char *envp[]);
argc指定参数个数,参数列表中第一个总是命令名(可执行文件名)
hello程序的加载和运行过程
Step1:在shell命令行提示符后输入命令:$./hello
Step2:shell命令行解释器构造argv和envp
Step3:调用fork()函数,创建一个子进程,与父进程shell完全相同(只读/共享),包括只读代码段、可读写数据段、堆以及用户栈等。
Step4:调用execve()函数,在当前进程(新创建的子进程)的上下文中加载并运行hello程序。将hello中的.text节、.data节、.bss节等内容加载到当前进程的虚拟地址空间(仅修改当前进程上下文中关于存储映像的一些数据结构,不从磁盘拷贝代码、数据等内容)
Step5:调用hello程序的main()函数,hello程序开始在一个进程的上下文中运行。
0x08:共享库和动态链接【划重点】
静态库有一些缺点
库函数(如printf)被包含在每个运行进程的代码段中,对于并发运行上百个进程的系统,造成极大的主存资源浪费;
库函数(如printf)被合并在可执行目标中,磁盘上存放着数千个可执行文件,造成磁盘空间的极大浪费;
程序员需关注是否有函数库的新版本出现,并须定期下载、重新编译和链接,更新困难、使用不便;
解决方案: Shared Libraries (共享库)
是一个目标文件,包含有代码和数据
从程序中分离出来,磁盘和内存中都只有一个备份
可以动态地在装入时或运行时被加载并链接
Window称其为动态链接库(Dynamic Link Libraries,.dll文件)
Linux称其为动态共享对象( Dynamic Shared Objects, .so文件)
自定义一个动态共享库文件
//program3.c
#include <stdio.h>
void Function3(){
printf("This is the third test program!n");
}
//program4.c
#include <stdio.h>
void Function4(){
printf("This is the fourth test program!n");
}
//main.c
void Function3();
int main(){
Function3();
return 0;
}
$ gcc -m32–c program3.c program4.c
$ gcc -m32 -shared -fPIC -o mylib.so program3.o program4.o
$ gcc -m32 -c main.c
$ gcc -m32 -o program main.o ./mylib.so
$ ./program
This is the third test program!
地址无关代码【划重点】
• 动态链接用到一个重要概念:
我们希望程序模块中共享的指令部分在装载时不需要因为装载地址的改变而改变,所以实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分可以在每个进程中拥有一个副本。这种方案就叫做地址无关代码(Position-Independent Code,PIC)。
GCC选项-fPIC指示生成PIC代码
• 共享库代码是一种PIC
共享库代码的位置可以是不确定的
即使共享库代码的长度发生变化,也不影响调用它的程序
• 所有引用情况
模块内的数据访问。如模块内的全局变量和静态变量
模块内的过程调用、跳转。采用PC相对偏移寻址
模块间的数据访问。如外部变量的访问
模块间的过程调用、跳转。
要实现动态链接,必须生成PIC代码,要生成PIC代码,主要解决第3和第4这两个问题
源代码:
static int a;
static int b;
extern void ext();
void bar()
{
a=1;
b=2;
}
void foo()
{
bar();
ext();
}
(1)模块内的函数调用或跳转
调用或跳转源与目的地都在同一个模块,相对位置固定,只要用相对偏移寻址即可。
call的目标地址为:0x8048369 + 0xffffffdb(-0x25) = 0x8048344
注:
8048364: e8 db ff ff ff call 8048344 <bar>
该指令是一条近址相对位移调用指令
(2)模块内的数据引用
注:任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只要相对于当前指令加上固定的偏移量就可以访问模块内部的数据了。
变量a与引用a的指令之间的距离为常数,调用__get_pc后,call指令的返回地址被置ECX。若模块被加载到0x9000000,则a的访问地址为:
0x9000000+0x34c+0x118c(指令与.data间距离)+0x28(a在.data节中偏移)
(3)模块间的数据访问
ELF解决模块间的数据访问目标地址的做法是在数据段里面建立一个指向这些变量的指针数组,也称为全局偏移表(Global Offset Table,Got),当代码需要引用该全局变量时,可通过GOT中相对应的项间接引用。
模块在编译时可以确定GOT相对于当前指令的偏移,然后根据变量地址在GOT中的偏移就可得到变量的地址。比如,当指令要访问变量b时,程序会先找到GOT,然后根据GOT中变量所对应的项找到变量的目标地址。
(4)模块间的调用、跳转
同理,我们可以使用类似于模块间的数据访问的方式,在GOT中加一个项(指针),用
于指向目标函数的首地址(如&ext),但是也要多用三条指令并额外多用一个寄存器(如EBX)。因此,可用“延迟绑定(lazy binding)”技术来优化动态链接性能:当函数第一次被用到时才进行绑定(符号查找、重定位等),这样可以大大加快程序启动速度。
ELF使用PLT(Procedure linkage Table, 过程链接表)的方法来实现。通常我们调用某个外部模块的函数时,应该是通过GOT中相应的项进行间接跳转。而PLT为了实现
延迟绑定,在这个过程中有增加了一层间接跳转。调用函数并不直接通过GOT跳转,而是通过一个叫作PLT项的结构来进行跳转。每个外部函数在PLT中都有一个相应的项,比如bar()在PLT中的项的地址我们称为bar@plt
。其中bar@plt
的实现如下:
bar@plt:
jmp *(bar@GOT)
push n
push moduleID
jump _dl_runtime_resolve
第一条指令是通过一条GOT间接跳转的指令。bar@GOT
表示GOT中保存的bar()这个函数相应的项。链接器在初始化阶段没有将bar()的地址填入到该项中,而是将上面代码中第二条指令“push n”的地址填入到bar@GOT
中。显然,第一条指令的效果是跳转到第二条指令,第二条指令将一个数字n压入堆栈中,该数字为bar这个符号引用在重定位表“.rel.plt”中的下标。接着将模块的ID压入堆栈中,然后调用_dl_runtime_resolve函数来完成符号解析和重定位工作。_dl_runtime_resolve在进行一系列工作以后将bar()的真正地址填入到bar@GOT
中。再次调用bar@plt
时,第一条jump指令能跳转到真正的bar()函数中,bar()函数返回的时候会根据堆栈里保存的EIP直接返回到调用者,而不会在继续执行bar@plt
中第二条指令开始的那段代码。
ELF将GOT拆分为两个表叫做“.got”和“.got.plt”:
.got 用来保存全局变量引用的地址;
.got.plt 用来保存函数引用的地址,即对于外部函数的引用全部被分离出来放到了“.got.plt”中
注:Linux下,ELF可执行文件虚拟地址空间默认从地址0x08048000
开始分配
0x09 实践部分
理解了何为GOT表和PLT之后,我们再通过pwnable.kr中的题目passcode来介绍一下GOT表覆盖技术:
Mommy told me to make a passcode based login system.
My initial C code was compiled without any error!
Well, there was some compiler warning, but who cares about that?
ssh passcode@pwnable.kr -p2222 (pw:guest)
//源代码passcode.c #include <stdio.h> #include <stdlib.h> void login(){ int passcode1; int passcode2; printf("enter passcode1 : "); scanf("%d", passcode1); fflush(stdin); // ha! mommy told me that 32bit is vulnerable to bruteforcing :) printf("enter passcode2 : "); scanf("%d", passcode2); printf("checking...n"); if(passcode1==338150 && passcode2==13371337){ printf("Login OK!n"); system("/bin/cat flag"); } else{ printf("Login Failed!n"); exit(0); } } void welcome(){ char name[100]; printf("enter you name : "); scanf("%100s", name); printf("Welcome %s!n", name); } int main(){ printf("Toddler's Secure Login System 1.0 beta.n"); welcome(); login(); // something after login... printf("Now I can safely trust you that you have credential :)n"); return 0; }
解题思路:
由于welcome()和login()函数调用栈的EBP相同,通过gdb调试后可以发现
输入的变量没有用取地址符号&,导致读入数据的时候,scanf会把这个变量中的值当成存储地址来存放数据,name值的最后4个字节是passcode1值,所以可以通过将passcode1的值改为fflush()的地址,scanf()之后会调用fflush()函数,覆盖fflush()在GOT表中的内容,把system(“/bin/cat flag”)对应汇编代码地址写入fflush()中,当这个函数被调用时,就会直接执行system(“/bin/cat flag”)。
通过objdump -R passcode
命令查看GOT表可以发现fflush()位于0x0804a004
处,即将0x80485e3
(调用system的地址)覆写位于0x0804a004的fflush()函数的GOT表。
看看got表:
//exp.py
#!/usr/bin/python
from pwn import *
p= process('./passcode')
fflush_got = 0x0804a004
system_addr = 0x80485e3
payload = "A" * 96 + p32(fflush_got) + str(system_addr) #有点小问题,见后
p.send(payload)
p.interactive()
GOT表覆写原理以及自己的理解
scanf函数:把某个输入的值输入到某个内存里。如果我们可以控制这个内存,或者可以控制这个输入,那么我们可能可以劫持进程流。
现在passcode1没有加&号。scanf是默认从栈中读取4个字节当作scanf的地址。
那么,如果我们可以控制栈呢?栈中读取的4个字节是我们控制的。输入的值是我们控制的。那么,scanf函数帮助我们控制了函数流程。
如果我们在scanf函数里,将原本是fflush函数地址的地方,写入了system函数地址。
那么当程序执行fflush函数时,相当于执行了system函数。也就实现了我们所说的:GOT表的覆写。
过程如下:
将passcode1的地址填充为fflush地址-->scanf("%d", fflush地址)-->send(system_addr
),该scanf使用system_addr覆盖了fflush地址。下次执行printf
时其实执行的是system("/etc/cat flag")(printf隐含了fflush的调用!)
0x0A:参考资料
《程序员的自我修养》
《计算机系统基础(一):程序的表示、转换与链接》https://www.icourse163.org/course/NJU-1001625001
pwn题目分析
题目已经很清楚了,那么直观的想法就是通过某种方式在scanf("%d", passcode1);
之前就为passcode1和passcode2精心准备好一个垃圾“随机”值。
可是怎么办呢?一旦进入login()就没戏了,所以我盯上了welcome()。首先,welcome和login之间没有多余的栈操作,因此二者的ebp应该是一致的。其次,在welcome有一个scanf("%100s", name);
,这是个很大的buffer,所以理论上来说,通过对name进行精心的布局,应该可以覆盖到login()栈帧中passcode1和passcode2的值。这也正是我对“随机”二字加了引号的原因。
那么passcode1和passcode2应该是什么值呢?从逻辑上来看,passcode1为338150(0x000582E6),passcode2为13371337(0x00cc07c9)即可。
然而且不论这两个地址是否是可写的,至少00字节的存在就因为截断而打消念想了。另一方面,浏览一下反汇编代码就会发现,实际上name的地址在ebp-0x70
的位置,而passcode1在ebp-0x10
的位置(两个栈帧中ebp值相等),经过计算,name也覆盖不到passcode2。
welcome:
login:
那么接下来怎么办?
实际上,既然scanf是一个具有写功能的函数,我们完全可以利用scanf来修改此后使用到的某个函数的got表项。例如,程序在scanf("%d", passcode1);
后立即使用了fflush函数,所以我们完全可以先找到fflush的got表项地址(程序没有开PIE,无需leak),把passcode1布局为该地址,并在调用到scanf(“%d”, passcode1)时输入程序代码中调用system("/bin/cat flag");
处的地址即可。
前面说的passcode1和name是在一个栈空间的,计算一下,name和passcode1相差96个字节,所以name后4个字节正好可以覆盖到passcode1。因此可以把passcode1的地址覆盖成fflush的地址,然后利用scanf函数把system的地址覆写过去。这样等调用fflush的就调用成了system。(因为scanf函数会调用fflush。可以调用system函数的原因是,linux没有对code段进行随机化)
思路明确了,接下来就是找地址了。
objdump -R passcode找fflush地址:
system(“cat flag”)地址:
Pwn
程序NO PIE,所以地址就无需leak了,直接通过gdb找到的就是固定的。
关于got和plt表的关系以及linux的动态延迟加载,我就不详细展开了,给一张图吧: