לדלג לתוכן

7.3 שחרור כפול וtcache poisoning תרגול

תרגול - שחרור כפול וtcache poisoning

בתרגול הזה תבנו exploit מלא של הרעלת tcache: leaks libc, דרך הרעלת שדה ה-next, ועד shell - קודם על glibc 2.31 (המקרה הנקי), ואז שוב על glibc 2.35 שבו כבר יש safe-linking וה-hooks נעלמו. המטרה היא שתרגישו על העור את ההבדל בין הגרסאות: מתי אפשר לכתוב כתובת גולמית, מתי חייבים לערבל, ומה עושים כשאין __free_hook. אל תעתיקו מהפתרון. תריצו, תיפלו על בדיקת המפתח, תראו את double free detected in tcache 2 בעיניים, ותתקנו.

סביבה והנחות

כל התרגילים רצים מקומית על לינוקס. הכי נקי לעבוד בתוך Docker כדי לשלוט בגרסת ה-glibc:

# glibc 2.31
docker run -it --rm -v "$PWD":/w -w /w ubuntu:20.04 bash
# glibc 2.35
docker run -it --rm -v "$PWD":/w -w /w ubuntu:22.04 bash

הכלים שתצטרכו: python3 עם pwntools, gdb עם pwndbg (במיוחד הפקודות tcachebins, bins, vis_heap_chunks), one_gadget, ו-ROPgadget. חשוב: תעבדו מול עותק מקומי של ה-libc.so.6 שהבינארי טוען - תמצאו אותו עם ldd ./tc, והעתיקו אותו לתיקיית העבודה.

ההנחות: ASLR פעיל (הדרך הרגילה), NX פעיל, בלי canary, והבינארי no-pie (כדי לבודד את חולשת ה-heap). אנחנו נדליף libc בעצמנו בתוך ה-exploit.

הבינארי לדוגמה

שמרו בשם tc.c. זה ממשק תפריט קלאסי של heap: alloc/free/edit/view. שימו לב לחולשה - free לא מאפס את המצביע, אז נשארת לנו גישת UAF גם ל-edit וגם ל-view:

// tc.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define N 16
char  *slots[N];
size_t sizes[N];

long num(const char *prompt) {
    char buf[32];
    printf("%s", prompt);
    if (!fgets(buf, sizeof buf, stdin)) exit(0);
    return strtol(buf, 0, 10);
}

int main() {
    setvbuf(stdout, 0, _IONBF, 0);
    setvbuf(stdin,  0, _IONBF, 0);
    while (1) {
        long c = num("\n1)alloc 2)free 3)edit 4)view 5)exit\n> ");
        if (c == 1) {
            long i = num("idx: "), s = num("size: ");
            if (i < 0 || i >= N) continue;
            slots[i] = malloc(s);
            sizes[i] = s;
            printf("data: ");
            read(0, slots[i], s);
        } else if (c == 2) {
            long i = num("idx: ");
            if (i < 0 || i >= N) continue;
            free(slots[i]);              // UAF: the pointer is not reset
        } else if (c == 3) {
            long i = num("idx: ");
            if (i < 0 || i >= N) continue;
            printf("data: ");
            read(0, slots[i], sizes[i]); // UAF write
        } else if (c == 4) {
            long i = num("idx: ");
            if (i < 0 || i >= N) continue;
            write(1, slots[i], sizes[i]); // UAF read (leak)
        } else {
            break;
        }
    }
    return 0;
}

קמפול (אותו מקור, שתי גרסאות glibc לפי הקונטיינר):

gcc -fno-stack-protector -no-pie -o tc tc.c
checksec --file=./tc

ודאו: NX enabled, No canary, No PIE. העתיקו את ה-libc: cp "$(ldd ./tc | awk '/libc/{print $3}')" ./libc.so.6.


תרגיל 1 - מיפוי ה-tcache ב-gdb

לפני שתוקפים, תראו את המבנה בעיניים.

המטרה:

  1. הריצו את tc תחת gdb+pwndbg. הקצו שני צ'אנקים בגודל 0x50 (idx 0 ו-1), ושחררו את שניהם.
  2. הריצו tcachebins. כמה צ'אנקים ב-bin של 0x60? מי בראש - הראשון ששוחרר או האחרון? רשמו לעצמכם את הכתובות.
  3. הריצו vis_heap_chunks. אתרו את שדה ה-next ואת שדה ה-key בכל צ'אנק חופשי. ודאו ש-key מצביע לתחילת ה-tcache (tcache_perthread_struct).

רמז: בקשה של 0x50 בתים ממופה לצ'אנק 0x60 (ראש 0x10 + נתונים aligned). זה tc_idx שמתאים ל-tcachebins תחת 0x60.

תרגיל 2 - להיכשל בכוונה: בדיקת המפתח של 2.29

עכשיו תפגשו את ההגנה.

המטרה:

  1. הקצו צ'אנק בגודל 0x50 (idx 0), שחררו אותו, ואז שחררו אותו שוב בלי לגעת בכלום. מה קורה?
  2. תעדו את הודעת הקריסה המדויקת. איזה מחרוזת glibc מדפיס?
  3. עכשיו עקפו: שחררו את idx 0 פעם אחת, השתמשו ב-edit כדי לדרוס את שדה ה-key (offset 8) לאפס תוך שמירת ה-next, ואז שחררו שוב. הפעם זה עובר?

רמז: ההודעה היא free(): double free detected in tcache 2. היא באה מהשוואת e->key == tcache.

רמז: ב-edit אתם כותבים מ-offset 0. כדי לשמור את ה-next (8 בתים ראשונים) ולאפס רק את ה-key, שלחו 8 בתים כלשהם ואז p64(0). אחרי צ'אנק בודד ששוחרר, ה-next הוא ממילא NULL, אז b'\x00'*16 עושה את העבודה.

תרגיל 3 - leak libc דרך ה-unsorted bin

הרעלה דורשת כתובת יעד ב-libc, אז קודם צריך את הבסיס.

המטרה:

  1. הקצו צ'אנק גדול מדי בשביל tcache (בקשה של 0x500, idx 0), ואחריו צ'אנק שמירה קטן (idx 1, גודל 0x20) כדי שהצ'אנק הגדול לא יתמזג עם ה-top chunk כשמשחררים.
  2. שחררו את הצ'אנק הגדול (idx 0). הוא הולך ל-unsorted bin, ושדות ה-fd/bk שלו מצביעים לתוך main_arena שבתוך libc.
  3. עשו view ל-idx 0, קראו 8 בתים, וזה leak libc. חשבו את libc.address בעזרת ה-offset של הערך הleaked מבסיס libc.

רמז: למה 0x500? כי מעל בקשה של 0x408 הצ'אנק לא נכנס ל-tcache, ובלי צ'אנק שמירה הוא היה מתמזג ל-top. הצ'אנק הקטן חוסם את המיזוג.

רמז: את ה-offset של הערך הleaked מדדו בעצמכם ב-gdb: אחרי השחרור, bins יראה לכם את כתובת ה-fd הleaked; חשבו הפרש = leak - libc_base פעם אחת, וקבעו אותו קבוע. אל תעתיקו offset עיוור בין גרסאות.

תרגיל 4 - הרעלה אל __free_hook והשגת shell (glibc 2.31)

המטרה המרכזית. הרכיבו את הכל.

המטרה:

  1. אחרי שהדלפתם libc (תרגיל 3), חשבו את __free_hook ואת system.
  2. הקצו שני צ'אנקים בגודל 0x50, שחררו את שניהם (מונה 2 באותו bin), ובעזרת edit דרסו את ה-next של הראש לכתובת __free_hook.
  3. שתי הקצאות של 0x50: הראשונה מחזירה את הצ'אנק המקורי, השנייה נוחתת על __free_hook. כתבו לשם p64(system).
  4. הקצו צ'אנק שהתוכן שלו "/bin/sh\x00" ושחררו אותו. קבלו shell, הריצו id.

רמז: זכרו למה שני צ'אנקים ולא אחד - עניין המונה מההרצאה. אם תשחררו רק אחד, ההקצאה השנייה תדלג על ה-tcache.

רמז: על 2.31 אין safe-linking, אז כותבים את __free_hook גולמי ל-next, בלי ערבול. אם משהו קורס, פתחו tcachebins ב-gdb אחרי ה-edit - צריך לראות את __free_hook כ-next של הראש.

תרגיל 5 - אותה התקפה על glibc 2.35 (safe-linking, בלי hooks)

עכשיו קמפלו את אותו tc.c בתוך ubuntu:22.04 והתמודדו עם העולם המודרני.

המטרה:

  1. הריצו את ה-exploit מתרגיל 4 כמו שהוא. איפה הוא נשבר? (שני דברים ישתנו.)
  2. תקנו את החלק של ה-next: כדי לכתוב next שמפוענח ל-TARGET, חשבו (chunk_addr >> 12) ^ TARGET. את chunk_addr השיגו מleak heap - שחררו צ'אנק בודד וקראו את ה-next המעורבל שלו (זה pos >> 12), ואז heap << 12.
  3. תקנו את החלק של היעד: __free_hook לא קיים ב-2.34+. בחרו יעד אחר. הקל ביותר כאן: כיוון שהבינארי no-pie עם Partial RELRO, הקצו על כניסת GOT של פונקציה שהבינארי באמת קורא (למשל printf, free או strtol), דרסו אותה ל-one_gadget או ל-system, והפעילו את הפונקציה.
  4. קבלו shell גם על 2.35.

רמז: את leak ה-heap עשו לפני ההרעלה: free(0) על צ'אנק בודד, view(0), heap = u64(...) << 12. אחר כך chunk_addr שאתם מרעילים הוא כתובת ה-mem של הצ'אנק הרלוונטי - חשבו אותו מבסיס ה-heap ב-gdb.

רמז: יעד GOT דורש Partial RELRO. ודאו עם checksec. אם תבחרו לדרוס free@got, שימו לב שאותה הקצאה נוחתת על ה-GOT, ושהפונקציה שתפעילו (למשל free הבא) תזנק ליעד. one_gadget נוח כי אין צורך לבנות ארגומנטים.

רמז: זכרו את בדיקת הalignment של safe-linking - הצ'אנק המורעל חייב להיות aligned ל-16. כתובת GOT היא לרוב aligned ל-8 בלבד; אם תיתקלו ב-unaligned tcache chunk detected, כוונו לכתובת aligned ל-16 בתוך אותו אזור, או השתמשו בטריק ה-0x??f0.

תרגיל 6 (אתגר) - שחרור כפול טהור בלי UAF edit

בתרגילים הקודמים השתמשנו ב-edit כדי לדרוס את ה-next. עכשיו דמיינו בינארי שאין בו edit בכלל - רק alloc/free - אבל malloc נותן לכם לכתוב לצ'אנק חדש.

המטרה:

  1. חשבו: איך משיגים הרעלה כשהדרך היחידה לכתוב ל-next היא דרך malloc שמחזיר צ'אנק ששוחרר קודם? (רמז: זה בדיוק מה שהשחרור הכפול נותן - מצביע חי לצ'אנק שעדיין ברשימה.)
  2. ממשו: שחרור כפול (עם עקיפת המפתח לפי 2.29), ואז malloc שמחזיר את הצ'אנק ובו זמנית משאיר אותו ברשימה, וכתיבת ה-next דרך ה-data של אותה הקצאה.
  3. השוו: כמה פעולות תפס המסלול הזה לעומת המסלול עם edit? מתי כל אחד עדיף?

רמז: הרצף הוא free(a); (עקיפת key); free(a); a2 = alloc(...) - וה-data של ההקצאה a2 נכתב לתוך אותו זיכרון שעדיין ראש ה-bin. שם אתם שותלים את ה-next המורעל.

רמז: אם אתם על 2.32+, אל תשכחו לערבל את ה-next גם כאן. הleak ה-heap עדיין נחוץ.