栈溢出

image-20201014145220293

技术清单:

  • shellcode:修改返回地址,指向溢出数据中的一段指令

  • return2libc:修改返回地址,指向内存中已有的某个函数

  • ROP:修改返回地址,指向内存中已有的一段指令

  • hijack GOT:修改某个被调用函数的地址,指向另一个函数

Shellcode

image-20201014145914679

​ shellcode的存放位置可以是caller的栈帧(严格意义上不全是caller的栈帧,可以覆盖或超出),也可以是callee的栈帧,一般我们选择caller的栈帧,因为有时callee的栈帧太小了。

Payload构造

  • padding1
  • address of shellcode
  • padding2
  • shellcode

Q&A

  • padding1里面数据是什么?

    padding1的数据可以任意填充,但最好不要包含’\x00’,可能会被检测为字符串末尾的’\0’最后造成截断。

  • padding1多长?

    • 可以使用反汇编工具查看汇编代码来确定距离(静态)
    • 可以使用调试工具(例如gdb)运行程序时不断增加输入长度的方法来试探(如果返回地址被无效地址例如“AAAA”覆盖,程序会终止并报错)
  • shellcode的起始地址应该是多少?

    我们可以在调试工具里查看返回地址的位置(可以查看 ebp 的内容然后再加4(32位机),参见前面关于函数状态的解释),**可是在调试工具里的这个地址和正常运行时并不一致,这是运行时环境变量等因素有所不同造成的。**https://www.mathyvanhoef.com/2012/11/common-pitfalls-when-writing-exploits.html所以这种情况下我们只能得到大致但不确切的 shellcode 起始地址,解决办法是在 padding2 里填充若干长度的 “\x90”。这个机器码对应的指令是 NOP (No Operation),也就是告诉 CPU 什么也不做,然后跳到下一条指令。有了这一段 NOP 的填充,只要返回地址能够命中这一段中的任意位置,都可以无副作用地跳转到 shellcode 的起始处,所以这种方法被称为 NOP Sled(中文含义是“滑雪橇”)。这样我们就可以通过增加 NOP 填充来配合试验 shellcode 起始地址。

shellcode内存格局

image-20201014151907237

Return2Libc

需要完成的任务为:

  • 确定内存中某个函数的地址,用其覆盖掉返回地址

    由于libc动态链接库中的函数被广泛使用,所以有很大概率可以在内存中找到该动态函数

  • 鉴于要执行的函数可能需要参数,比如调用system函数打开shell的完成形式为system("/bin/sh"),所以溢出数据也要包括必要的参数。

image-20201014154410247

Payload构造

  • padding1

  • address of libc

  • padding2

    四字节(32位模式),其实是libc(这里是system函数)调用完的返回地址,但我们这里只需要打开shell就可以,并不关心从shell退出之后的行为,所以padding2的内容可以随意填充

  • address of “/bin/sh”

    传给system函数的参数。

Q&A

  • system函数地址如何获得?

    • 调试工具在运行程序过程中直接查看system()的地址
    • 查看动态库在内存的起始地址,再在动态库内查看函数的相对偏移位置,通过计算得到函数的绝对地址
  • “/bin/sh"的地址在哪?

    可以在动态库里搜索这个字符串,如果存在,就可以按照动态库起始地址+相对偏移来确定其绝对地址。如果在动态库里找不到,可以将这个字符串加到环境变量里,再通过 getenv() 等函数来确定地址。

ROP(Return Oriented Programming)

需要完成的任务为:

  • 在内存中确定某段指令的地址,并用其覆盖返回地址。

    return2lib是令返回地址指向内存中的一个函数,但是又是目标函数在内存中无法找到,或没有特定的函数可以完美适配。这是就需要在内存中寻找多个指令片段,拼凑出一些列操作来达成目的。

image-20201014160124064

(要是RET命令前一定带有LEAVE怎么办😂)

现在任务可以分解为:针对程序栈溢出所要实现的效果,找到若干段以ret作为结束的指令片段,按照上述的构造将他们的地址填充到溢出数据。

Q&A

  • 首先,栈溢出之后要实现什么效果?

    ROP 常见的拼凑效果是实现一次系统调用,Linux系统下对应的汇编指令是 int 0x80。执行这条指令时,被调用函数的编号应存入 eax,调用参数应按顺序存入 ebx,ecx,edx,esi,edi 中。例如,编号125对应函数

mprotect (void *addr, size_t len, int prot)

​ 可用该函数将栈的属性改为可执行,这样就可以使用 shellcode 了。

​ 假如我们想利用系统调用执行这个函数,eax、ebx、ecx、edx 应该分别为“125”、内存栈的分段地址(可以通过调试工具确定)、“0x10000”(需要修改的空间长度,也许需要更长)、“7”(RWX 权限)。

  • 其次,如何寻找对应的指令片段?

    有若干开源工具可以实现搜索以 ret 结尾的指令片段,著名的包括 ROPgadgetrp++ropeme 等,甚至也可以用 grep 等文本匹配工具在汇编指令中搜索 ret 再进一步筛选。搜索的详细过程在这里就不再赘述,有兴趣的同学可以参考上述工具的说明文档。

  • 最后,如何传入系统调用的参数?

    对于上面提到的 mprotect 函数,我们需要将参数传输至寄存器,所以可以用 pop 指令将栈顶数据弹入寄存器。如果在内存中能找到直接可用的数据,也可以用 mov 指令来进行传输,不过写入数据再 pop 要比先搜索再 mov 来的简单,对吧?如果要用 pop 指令来传输调用参数,就需要在溢出数据内包含这些参数,所以上面的溢出数据格式需要一点修改。对于单个 gadget,pop 所传输的数据应该在 gadget 地址之后,如下图所示。

img

Fig 4. gadget “pop eax; ret;”

​ 在调用 mprotect() 为栈开启可执行权限之后,我们希望执行一段 shellcode,所以要将 shellcode 也加入溢出数据,并将 shellcode 的开始地址加到 int 0x80 的 gadget之后。但确定 shellcode 在内存的确切地址是很困难的事(想起上篇里面艰难试探的过程了吗?),我们可以使用 push esp 这个 gadget(假如可以找到的话)。先将shellcode起始地址压入栈中,然后ret指令返回时将esp执行的shellcode起始地址加载到eip中,开始执行shellcode代码。

img

Fig 5. gadget “push esp; ret;”

​ 我们假设现在内存中可以找到如下几条指令:

pop eax; ret;    # pop stack top into eax
pop ebx; ret;    # pop stack top into ebx
pop ecx; ret;    # pop stack top into ecx
pop edx; ret;    # pop stack top into edx
int 0x80; ret;   # system call
push esp; ret;   # push address of shellcode

​ 对于所有包含 pop 指令的 gadget,在其地址之后都要添加 pop 的传输数据,同时在所有 gadget 最后包含一段 shellcode,最终溢出数据结构应该变为如下格式。

payload : padding + address of gadget 1 + param for gadget 1 + address of gadget 2 + param for gadget 2 + …… + address of gadget n + shellcode

img

Fig 6. 包含多个 gadget 的溢出数据(修改后)

​ 此处为了简单,先假定输入溢出数据不受“\x00"字符的影响,所以 payload 可以直接包含 “\x7d\x00\x00\x00”(传给 eax 的参数125)。**如果希望实现更为真实的操作,可以用多个 gadget 通过运算得到上述参数。**比如可以通过下面三条 gadget 来给 eax 传递参数。

pop eax; ret;         # pop stack top 0x1111118e into eax
pop ebx; ret;         # pop stack top 0x11111111 into ebx
sub eax, ebx; ret;    # eax -= ebx

​ 解决完上述问题,我们就可以拼接出溢出数据,输入至程序来为程序调用栈开启可执行权限并执行 shellcode。同时,由于 ROP 方法带来的灵活性,现在不再需要痛苦地试探 shellcode 起始地址了。回顾整个输入数据,**只有栈的分段地址需要获取确定地址(调用mprotect传入的参数)。**如果利用 gadget 读取 ebp 的值再加上某个合适的数值,就可以保证溢出数据都具有可执行权限,这样就不再需要获取确切地址,也就具有了绕过内存随机化的可能。

​ 出于演示的目的,我们假设(简直是钦点)了所有需要的 gadget 的存在。在实际搜索及拼接 gadget 时,并不会像上面一样顺利,有两个方面需要注意。

第一,很多时候并不能一次凑齐全部的理想指令片段,这时就要通过数据地址的偏移、寄存器之间的数据传输等方法来“曲线救国”。举个例子,假设找不到下面这条 gadget

pop ebx; ret; 

但假如可以找到下面的 gadget

mov ebx, eax; ret;

我们就可以将它和

pop eax; ret; 

组合起来实现将数据传输给 ebx 的功能。上面提到的用多个 gadget 避免输入“\x00”也是一个实例应用。

第二,要小心 gadget 是否会破坏前面各个 gadget 已经实现的部分,比如可能修改某个已经写入数值的寄存器。另外,要特别小心 gadget 对 ebp 和 esp 的操作,因为它们的变化会改变返回地址的位置,进而使后续的 gadget 无法执行。

Hijack GOT

需要完成的任务为:

  • 在内存中修改某个函数的地址,使其指向另一个函数

GOT和PLT基础知识

程序对外部函数的调用需要在生成可执行文件时将外部函数连接到程序中,链接的方式分为

  • 静态链接

    得到的可执行文件包含外部函数的全部代码

  • 动态链接

    得到的可执行文件并不包含外部函数的代码,而是在运行时将动态链接库(若干外部函数的集合)加载到内存的某个位置,再在发生调用时连接库定位所需要的函数。

可程序如何在链接库内定为所需要的函数呢?这一过程用到了两张表——GOT和PLT

  • GOT(Global Offset Table,全局偏移量表),用来存储外部函数在内存中的确切地址。GOT存储在数据段中,可在程序运行中被修改。

  • PLT(Procedure Linkage Table,程序链接表),用于存储外部函数的入口点。PLT存储在代码段,运行之前已经确定并不会被修改,所以PLT并不会知道程序运行时动态链接库被加载的确切位置,因此PLT存储的入口点时GOT表中对应条目的地址。

    image-20201014164537595

为什么不只是用PLT表,非得使用PLT+GOT?

​ 我们好像发现了一个不合理的地方,外部函数的内存地址存储在 GOT 而非 PLT 表内,PLT 存储的入口点又指向 GOT 的对应条目,那么程序为什么选择 PLT 而非 GOT 作为调用的入口点呢?在程序启动时确定所有外部函数的内存地址并写入 GOT 表,之后只使用 GOT 表不是更方便吗?这样的设计是为了程序的运行效率。GOT 表的初始值都指向 PLT 表对应条目中的某个片段,这个片段的作用是调用一个函数地址解析函数。

​ 当程序需要调用某个外部函数时,

​ 首先到 PLT 表内寻找对应的入口点,跳转到 GOT 表中。如果这是第一次调用这个函数,程序会通过 GOT 表再次跳转回 PLT 表,运行地址解析程序来确定函数的确切地址,并用其覆盖掉 GOT 表的初始值,之后再执行函数调用。

​ 当再次调用这个函数时,程序仍然首先通过 PLT 表跳转到 GOT 表,此时 GOT 表已经存有获取函数的内存地址,所以会直接跳转到函数所在地址执行函数。整个过程如下面两张图所示。

img

Fig 8. 第一次调用函数时解析函数地址并存入 GOT 表

img

Fig 9. 再次调用函数时直接读取 GOT 内的地址

上述实现遵循的是一种被称为 LAZY的设计思想,它将需要完成的操作(解析外部函数的内存地址)留到调用实际发生时才进行,而非在程序一开始运行时就解析出全部函数地址。这个过程也启示了我们如何实现函数的伪装,那就是到 GOT 表中将函数 A 的地址修改为函数 B 的地址。这样在后面所有对函数 A 的调用都会执行函数 B。

那我们的目标分为以下几步:

  • 找到函数A在GOT表中的条目位置

    程序调用函数时是通过 PLT 表跳转到 GOT 表的对应条目,所以可以在函数调用的汇编指令中找到 PLT 表中该函数的入口点位置,从而定位到该函数在 GOT 中的条目。

    例如

    call 0x08048430 <printf@plt>
    

    就说明 printf 在 PLT 表中的入口点是在 0x08048430,所以 0x08048430 处存储的就是 GOT 表中 printf 的条目地址。

  • 确定函数B在内存中的地址

    如果系统开启了内存布局随机化,程序每次运行动态链接库的加载位置都是随机的,就很难通过调试工具直接确定函数的地址。假如函数 B 在栈溢出之前已经被调用过,我们当然可以通过前一个问题的答案来获得地址。但我们心仪的攻击函数往往并不满足被调用过的要求,也就是 GOT 表中并没有其真实的内存地址。幸运的是,函数在动态链接库内的相对位置是固定的,在动态库打包生成时就已经确定。所以假如我们知道了函数 A 的运行时地址(读取 GOT 表内容),也知道函数 A 和函数 B 在动态链接库内的相对位置,就可以推算出函数 B 的运行时地址。

  • 将函数B的地址写入函数A在GOT表中的条目

    很难找到合适的函数来完成这一任务,不过我们还有强大的 ROP(DIY大法好)。假设我们可以找到以下若干条 gadget(继续钦点),就不难改写 GOT 表中数据,从而实现函数的伪装。ROP 的具体实现请回看上一章,这里就不再赘述了。

    pop eax; ret; 		# printf@plt -> eax
    mov ebx [eax]; ret;	# printf@got -> ebx
    pop ecx; ret; 		# addr_diff = system - printf -> ecx
    add [ebx] ecx; ret; 	# printf@got += addr_diff
    

    从修改 GOT 表的过程可以看出,这种方法也可以在一定程度上绕过内存随机化。

防范措施

  • ASLR(Address Space Layout Randomization,内存布局随机化)

    Every time the program is run, components (including the stack, heap, and libraries) are moved to a different address in virtual memory.

    这样可以增大确定堆栈内数据和动态库内函数的内存地址的难度

  • NX

  • Canary

  • PIE

QianLong Sang
QianLong Sang
Second Year CS Phd

My research interests include operating system, computer architecture and computer security.