前言
经过一场CTF比赛,感觉以前学的知识都是没有得到升华。上网找了很多关于栈溢出攻击的实现方法,大家都说得很专业,我基本连入门都没看明白。经过一番尝试,这里做个小结,作为一个简单的入门。
工具就不多说了,大家都可以网上很容易找到各种安装教程,毕竟都只是一些常用的工具:
- checksec
- IDA Pro
- readelf
- gdb
- ROPgadget
- python with pwntools
第一步,用checksec进行文件安全信息分析。
checksec pwn
可以看到文件的一些基本信息:
参数 | 参数含义 | 当前值 | 说明 |
---|---|---|---|
Arch | 程序位数 | amd64-64-little | 这是一个64-bit的程序,子函数调用要用64-bit的方法。 |
RELRO | ”Partial RELRO”,说明我们可以对GOT表具有写权限。 ”Full RELRO”,简单理解为GOT表不可写。 “Canary RELRO”,不但保护了,而且还随机化了。 | Full RELRO | 不能通过改写GOT表破解。 |
Stack | 是否有栈溢出保护 | Canary found | 有栈溢出保护(下面我们会先破解这个保护) |
NX | non-executable, 堆栈是否禁止执行 | enable | 堆栈禁止执行权限, 不考虑写shellcode到堆栈然后执行。 |
PIE | ELF的地址是否进行随机化加载 | No PIE (0x400000) | ELF没有随机化加载(松了一口气) |
第二步,用IDA Pro打开文件进行反编译分析
打开pwn文件,找到main函数,按键盘F5进行反编译成C++语言:
int __cdecl main(int argc, const char **argv, const char **envp)
{
...
char buf[40]; // [rsp+10h] [rbp-30h] BYREF
unsigned __int64 v6; // [rsp+38h] [rbp-8h]
...
while...{
...
v4 = read(0, buf, 0x200uLL);
...
write(1, buf, (unsigned int)(v4 + 8));
...
}
return 0;
}
可以看出read函数读buf的时候是0x200个字节,但是buf定义只有40(0x28)个字节。这里很明显可以通过read读入实现栈溢出攻击,然后write把需要的内容打印出来。
从上面图中可以看出,栈溢出保护是检查var_8的值和之前的值有没有变化而判断有没有栈溢出。从下面的图中可以看到var_8其实就是我们buf后面的unsigned __int64 v6。
所以我们可以先尝试让buf的40个字节填满,然后当write去输出buf的时候就会把var_8/v6,也就是我们常说的canary打印出来,从而我们获取当前的canary,然后在第二次输入(read)的时候,我们就可以通过输入<buf[40]> + <canary>去到leave;retn;这个分支了。
第三步,用ROPgadget尝试找到ROP链
运行下面的命令:
ROPgadget --binary ./pwn --only "pop|ret"
我们可以找到两个挺有用的片段的地址:
- 片段一:pop rdi; ret;
- 片段二:pop rsi; pop r15; ret
这就涉及了调用子函数的方法:
位数 | 调用子函数方法 |
---|---|
32位 | 函数参数在函数返回地址的上方 32位程序函数调用时,依次将子函数的参数从右到左入栈,然后再压栈eip和ebp。 |
64位 | 64位程序如果子函数的参数数量<=6个,则会将参数从左到右依次存入rdi,rsi,rdx,rcx,r8,r9这6个寄存器中,如果还有参数,则像32位一样压栈。 |
ssize_t write(int fd, const void *buf, size_t n)
{
...
}
__int64 __fastcall system(__int64 a1, __int64 a2, __int64 a3, __int64 a4, u32 *a5, u32 a6)
{
...
}
可以看到,write和system的函数如上所示。
如果我们要执行system("/bin/sh"),就要先将"/bin/sh"的地址放到rdi(第一个参数)然后再call system。这就可以用到片段一。构建让程序read的ROP链如下:
<buf[40]> + <canary> + <rbp> + <片段一地址> + <binsh字符串的真实地址,会被pop去rdi> + <system函数的真实地址>
如果我们要write(xx, buf, xx),就要先将要输出的buf的地址放到rsi(第二个参数)然后再call write。这就要用到片段二。片段二其中,pop r15我们不需要用到,所以在构建ROP链的时候,要多加一个无用数让它pop出来。构建让程序read的ROP链如下:
<buf[40]> + <canary> + <rbp> + <片段二地址> + <buf地址,会被pop去rsi> + <没用的数据,会被pop去r15> + <write函数的真实地址>
<rbp> 我这里一般取0,因为leave的时候会pop rbp;所以如果是leave;retn;的程序段,要放多一个栈数据。
- 汇编命令leave等价于mov rsp rbp;pop rbp;
- 汇编命令retn等价于 pop rip;
第四步,尝试找到函数的真实地址
在第二步可以看得出,system函数是在动态链接库里面的函数,而且"/bin/sh"字符串,也只有在动态库里面有。为了构建我们的ROP链去运行system("/bin/sh"),我们需要找到这个函数的真实地址。因为每次加载动态链接库后的地址都会不一样,我们需要在运行的过程中找到动态链接库的基址。
PLT地址 -> GOT地址 -> 真实内存地址
选择一个函数,在pwn里面有plt地址链接到动态链接库。我们这里可以选择puts函数,然后把puts函数的got地址所指向的数据(真实内存地址)打印出来,就可以得到puts函数的真实地址。
<buf[40]> + <canary> + <rbp> + <片段二地址> + <puts函数的got地址> + <pop去r15的没用的数据> + <write函数的plt地址>
最后把puts函数的真实地址减去puts函数在动态链接库里面的相对地址,就可以计算出动态链接库的基址。
libc_base = puts_real_address - puts_address_in_libc
有了动态链接库的基址,那么我们就可以求出动态链接库里面system函数和"/bin/sh"字符串的真实地址:
system_real_address = libc_base + system_address_in_libc
第五步,用python写出对应的程序
如果上面都理解了,这里就不多做阐述了。
附件:一些名字的解释
缩写 | 含义 |
---|---|
RELRO | Relocation Read-Only, 重定向只读 |
PLT | Procedure Link Table,程序链接表 |
GOT | Global Offset Table,全局偏移表 |