Zj_W1nd's BLOG

CHOP——强网杯2024expect_number wp

2024/11/10

参考自溢出漏洞在异常处理中的攻击利用手法-上溢出漏洞在异常处理中的攻击利用手法-下

简单了解下C++异常处理

C++有这样的关键字,支持执行异常的处理:

1
2
3
4
5
6
7
8
9
10
11
try{
do_sth();
throw exception;
nothing_will_happen_here();
}
catch(exception typeA){
my_handler();
}
catch(exception typeB){
my_handler2();
}

关于C++的异常处理:

https://www.cnblogs.com/sgawscd/p/13870406.html
https://blog.csdn.net/GrayOnDream/article/details/138469330 <-- 具体的细节参考这一篇即可

简单的来说,任何函数都可以通过throw关键字抛出异常。对于可能抛出异常的代码,要用try去包裹。而后续需要用catch去捕获异常。
catch可以在后面添加类型来声明catch内处理的异常类型。触发throw后,在开发者的视角下,会从抛出异常的try块向外匹配第一个类型合适的catch块,然后程序会将控制权移交到catch块中的异常处理代码。此时throw后面的代码全部都不会执行,因此这种控制流的强行跳转给了我们利用的机会。这种对于catch逐层向上的匹配机制,叫做栈展开(Stack Unwind)

libstdc++的异常处理实现

下面以linux的libstdc++的实现为例大概看一下throw之后发生了什么

首先,当使用throw关键字的时候,编译器会先用__cxa_allocate_exception分配一些处理所需要的对象空间。然后程序会调用__cxa_throw()抛出异常,最后核心函数会进入_Unwind_RaiseException()。再后面就是gdb跟进困难的内容了,具体细节不再赘述。不过会用Unwind以及程序中eh_frame节中相关的信息去对堆栈进行回溯。

既然会回溯找代码,那么一定会设计到堆栈中pc的恢复,也就是返回地址。如果我们能够覆盖try块的返回地址然后去throw,就可能能劫持到控制流到其他的catch块(不是其他catch块的话,回溯到main发现没有catch代码,会被terminate退出)

另外,由于throw后面的内容其实都根本不会执行,所以这个操作能够绕过canary(check根本不执行,覆盖了canary也没事),这是比较神奇的一点。

对于这道题目

流程分析

这一部分我们不多讲,程序是通过一个以1为种子的伪随机数序列,然后通过用户输入0 1 2进行加,乘,除操作。程序提供了一个
system gift(但是没用)。

指针初始化在initarray,程序有4个类,一个基类和3个派生(猜测这是为了让它们函数虚表的地址相邻),其中两个派生类一个是退出一个是存在溢出的漏洞函数。5010->5520->vtable->func

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
data.rel.ro:0000000000004C20 ; `vtable for'Derived3
.data.rel.ro:0000000000004C20 _ZTV8Derived3 dq 0 ; offset to this
.data.rel.ro:0000000000004C28 dq offset _ZTI8Derived3 ; `typeinfo for'Derived3
.data.rel.ro:0000000000004C30 off_4C30 dq offset display_d3 ; DATA XREF: set_d3_display+1C↑o
.data.rel.ro:0000000000004C38 ; `vtable for'Derived2
.data.rel.ro:0000000000004C38 _ZTV8Derived2 dq 0 ; offset to this
.data.rel.ro:0000000000004C40 dq offset _ZTI8Derived2 ; `typeinfo for'Derived2
.data.rel.ro:0000000000004C48 quit_ptr dq offset quit ; DATA XREF: set_quit+1C↑o
.data.rel.ro:0000000000004C50 ; `vtable for'Derived
.data.rel.ro:0000000000004C50 _ZTV7Derived dq 0 ; offset to this
.data.rel.ro:0000000000004C58 dq offset _ZTI7Derived ; `typeinfo for'Derived
.data.rel.ro:0000000000004C60 vuln_off dq offset vuln ; DATA XREF: init_game+1C↑o
.data.rel.ro:0000000000004C68 ; `vtable for'Base
.data.rel.ro:0000000000004C68 _ZTV4Base dq 0 ; offset to this
.data.rel.ro:0000000000004C70 dq offset _ZTI4Base ; `typeinfo for'Base
.data.rel.ro:0000000000004C78 base_display_ptr dq offset base_display ; DATA XREF: set_base_display_ptr+C↑o
.data.rel.ro:0000000000004C80 public _ZTISt13runtime_error ; weak

5520正好在输入结构的+288位置,重复288次输入没有考虑结构体头的12字节,可以溢出12字节。但是每次写入之后前一个就会被覆盖为上次操作我们实际上只能溢出1个字节。恰巧的是,1个就够了,50->60即可总之我们就利用程序的逻辑和一个offbyone的漏洞将正常退出的指针劫持到了虚表里另一个派生类的漏洞函数上。

我们重点关注这个有栈溢出的后门:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
unsigned __int64 vuln()
{
__int64 v0; // rax
std::runtime_error *exception; // rbx
_BYTE buf[8]; // [rsp+20h] [rbp-20h] BYREF
unsigned __int64 v4; // [rsp+28h] [rbp-18h]

v4 = __readfsqword(0x28u);
v0 = std::operator<<<std::char_traits<char>>();
std::ostream::operator<<(v0, &std::endl<char,std::char_traits<char>>);
if ( read(0, buf, 0x30uLL) > 8 )
{
exception = (std::runtime_error *)__cxa_allocate_exception(0x10uLL);
std::runtime_error::runtime_error(exception, "Input too long");
__cxa_throw(exception,
(struct type_info *)&`typeinfo forstd`::runtime_error,
(void (*)(void *))&std::runtime_error::~runtime_error);
}
return v4 - __readfsqword(0x28u);
}

同时程序里还有这样的catch块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
.text:00000000000024E1 ; __unwind { // __gxx_personality_v0
.text:00000000000024E1 endbr64
.text:00000000000024E5 push rbp
.text:00000000000024E6 mov rbp, rsp
.text:00000000000024E9 push rbx
.text:00000000000024EA sub rsp, 28h
.text:00000000000024EE mov rax, fs:28h
.text:00000000000024F7 mov [rbp+var_18], rax
.text:00000000000024FB xor eax, eax
.text:00000000000024FD mov [rbp+var_24], 0
.text:0000000000002504 lea rax, [rbp+var_24]
.text:0000000000002508 mov rsi, rax
.text:000000000000250B lea rax, _ZSt3cin ; std::cin
.text:0000000000002512 mov rdi, rax
.text:0000000000002515 ; try {
.text:0000000000002515 call __ZNSirsERi ; std::istream::operator>>(int &)
.text:0000000000002515 ; } // starts at 2515
.text:000000000000251A mov eax, [rbp+var_24]
.text:000000000000251D jmp short loc_256A
.text:000000000000251F ; ---------------------------------------------------------------------------
.text:000000000000251F ; catch(std::runtime_error) // owned by 2515
.text:000000000000251F endbr64
.text:0000000000002523 cmp rdx, 1
.text:0000000000002527 jz short loc_2531
.text:0000000000002529 mov rdi, rax ; struct _Unwind_Exception *
.text:000000000000252C call __Unwind_Resume
.text:0000000000002531 ; ---------------------------------------------------------------------------
.text:0000000000002531
.text:0000000000002531 loc_2531: ; CODE XREF: cin+46↑j
.text:0000000000002531 mov rdi, rax ; void *
.text:0000000000002534 call ___cxa_begin_catch
.text:0000000000002539 mov [rbp+var_20], rax
.text:000000000000253D lea rax, command ; "/bin/sh"
.text:0000000000002544 mov rdi, rax ; command
.text:0000000000002547 ; try {
.text:0000000000002547 call _system ; ------> system
.text:0000000000002547 ; } // starts at 2547
.text:000000000000254C call ___cxa_end_catch
.text:0000000000002551 jmp short loc_256A
.text:0000000000002553 ; ---------------------------------------------------------------------------
.text:0000000000002553 ; cleanup() // owned by 2547
.text:0000000000002553 endbr64
.text:0000000000002557 mov rbx, rax
.text:000000000000255A call ___cxa_end_catch
.text:000000000000255F mov rax, rbx
.text:0000000000002562 mov rdi, rax ; struct _Unwind_Exception *
.text:0000000000002565 call __Unwind_Resume
.text:000000000000256A ; ---------------------------------------------------------------------------
.text:000000000000256A
.text:000000000000256A loc_256A: ; CODE XREF: cin+3C↑j
.text:000000000000256A ; cin+70↑j
.text:000000000000256A mov rdx, [rbp+var_18]
.text:000000000000256E sub rdx, fs:28h
.text:0000000000002577 jz short loc_257E
.text:0000000000002579 call ___stack_chk_fail
.text:000000000000257E ; ---------------------------------------------------------------------------
.text:000000000000257E
.text:000000000000257E loc_257E: ; CODE XREF: cin+96↑j
.text:000000000000257E mov rbx, [rbp+var_8]
.text:0000000000002582 leave
.text:0000000000002583 retn
.text:0000000000002583 ; } // starts at 24E1
.text:0000000000002583 cin endp

想办法让程序返回到这就可以了。本着实践大于理论的原则比赛的时候试了好久(其实也就一小时可能)都没成,结果是要返回到0x251a,我就低了5个字节。

事实上是,将返回地址填成我们想要跳转到的catch块,rbp的值只要是一个可读写的地址就行了,防止中间汇编rbp寻址的时候sigsegv。

几个小问题

程序怎么识别的try-catch块?

编译器为程序生成了一个异常处理表.gcc_except_table,作为一个单独的节存放相关的信息。

rbp的作用?

如果在handler中有leave;ret的gadget,我们理论上在溢出后控制rbp就能劫持控制流。其他情况下一般要注意rbp地址有效。

注意:

这个ret地址的范围并不精确,尽量要写到带有catch的函数的try范围内,如果不成功一定要换附近地址多试几次,血泪教训。

什么是CHOP?

CHOP是Catch Handler Oriented Programming。出自一篇论文,更多的可以去看开篇提到的blog。总之这玩意的思想就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
void __cxa_call_unexpected (void *exc_obj_in) {
xh_terminate_handler = xh->terminateHandler;
try { /* ... */ }
catch (...) {
__terminate(xh_terminate_handler);
}
}

void __terminate (void (*handler)()) throw () {
/* ... */
handler();
std::abort();
}

有函数指针的地方就有pwn,libstdc++中有这样一个调用指针的地方。如果异常处理前存在栈溢出能劫持返回地址,我们控制好xh_terminate_handler这一局部变量后返回到__cxa_call_unexpected的catch块就能实现控制流劫持。通过这个Golden Gadget,我们可以将有canary的栈溢出变成无canary的栈溢出,只需要以这个catch做跳板。

版本更高的libc也只是调用路径更复杂了。

摸了

CATALOG
  1. 1. 简单了解下C++异常处理
    1. 1.1. libstdc++的异常处理实现
  2. 2. 对于这道题目
    1. 2.1. 流程分析
  3. 3. 几个小问题
    1. 3.1. 程序怎么识别的try-catch块?
    2. 3.2. rbp的作用?
    3. 3.3. 注意:
  4. 4. 什么是CHOP?