לדלג לתוכן

7.1 פנימיות המקצה של glibc הרצאה

עד עכשיו בקורס עסקנו כמעט תמיד במחסנית - buffer overflows, דריסת return address, ROP. אבל חלק אדיר מהחולשות בתוכנות אמיתיות (דפדפנים, מנועי JavaScript, שרתים, kernels) חי דווקא בערמה - the heap. כדי לנצל חולשות heap אי אפשר להסתפק ב"malloc מחזיר לי זיכרון ו-free משחרר אותו". צריך להבין לעומק מה בדיוק קורה מאחורי הקלעים: איך זיכרון מסודר, איזה metadata שומרים לידו, ולאן נתחים משוחררים נודדים. מנהל הזיכרון של glibc נקרא ptmalloc2, וההרצאה הזו מפרקת אותו לגורמים. זה הבסיס לכל טכניקה שנלמד בהמשך הפרק - use-after-free, double free, tcache poisoning, וכל משפחת ה-House of.

כל מה שנכתוב כאן מתייחס למקצה של glibc על x86-64. אם אתם רוצים לעקוב, בדקו את הגרסה שלכם עם ldd --version. ההסברים מדויקים לגרסאות 2.31 עד 2.35, ונציין במפורש איפה גרסאות שונות משנות את ההתנהגות.


למה בכלל מקצה - the allocator

כשאתם קוראים ל-malloc(100), אתם לא מדברים ישירות עם הkernel. בקשה של 100 בתים לkernel בכל פעם הייתה איטית להחריד. במקום זה, glibc מבקשת מהkernel בלוק גדול של זיכרון בבת אחת (דרך brk או mmap), וקוראת לו arena. אחר כך המקצה מחלק את הבלוק הזה לחתיכות קטנות לפי הבקשות שלכם, וממחזר חתיכות ששוחררו.

התהליך המרכזי מקבל arena ראשי שנקרא main_arena. כל thread נוסף מקבל arena משלו (עד גבול מסוים), כדי שלא יצטרכו לנעול מנעול משותף על כל הקצאה. בהרצאה הזו נתמקד ב-main_arena של thread יחיד, אבל זכרו שהמבנה malloc_state (ה-arena) מחזיק בתוכו את כל רשימות הפחים שנדבר עליהן.

היחידה הבסיסית שהמקצה מחלק נקראת נתח - chunk. כל דבר בערמה הוא chunk: מה ש-malloc נתן לכם, מה ששחררתם, ואפילו השטח הפנוי בקצה הערמה. בואו נראה איך chunk בנוי.


מבנה הנתח - malloc_chunk

הנה ההגדרה, בצורה מפושטת, מתוך הקוד של glibc:

struct malloc_chunk {
    size_t prev_size;   /* size of the previous chunk, only if it is free */
    size_t size;        /* size of this chunk, including 3 flags in the low bits */

    struct malloc_chunk *fd;   /* forward pointer  - only when the chunk is free */
    struct malloc_chunk *bk;   /* backward pointer - only when the chunk is free */

    /* only for large chunks (large bins): */
    struct malloc_chunk *fd_nextsize;
    struct malloc_chunk *bk_nextsize;
};

הטריק המרכזי שחייבים להפנים: אותו זיכרון משמש לשני דברים שונים, תלוי אם הנתח מוקצה או פנוי.

כשהנתח מוקצה (בשימוש), רק שני השדות הראשונים הם metadata, והשאר הוא הזיכרון שלכם:

        +--------------------------------+
 chunk  | prev_size (8 bytes)            |  <- belongs to the previous chunk if it is in use
        +--------------------------------+
        | size            | A | M | P |  |  <- size + 3 flags
   mem  +--------------------------------+  <- the address malloc returns to you
        | user data...                   |
        | ...                            |
        +--------------------------------+

שימו לב לנקודה שמבלבלת את כולם בהתחלה: הכתובת ש-malloc מחזיר (נסמן mem) היא לא תחילת הנתח. היא 16 בתים אחרי תחילת הנתח (chunk), כלומר אחרי prev_size ו-size. כשנעבוד ב-pwndbg נראה כתובות מה-mem שהתוכנית שלנו מחזיקה, ונצטרך להחסיר 0x10 כדי להגיע לתחילת ה-chunk.

כשהנתח פנוי (שוחרר), אין נתוני משתמש - המקצה משתמש בשטח שהיה שלכם כדי לשרשר את הנתח לרשימות פנויים:

        +--------------------------------+
 chunk  | prev_size                      |
        +--------------------------------+
        | size            | A | M | P |  |
        +--------------------------------+
        | fd  (pointer to the next chunk in the list)     |  <- used to be the start of your data
        +--------------------------------+
        | bk  (pointer to the previous chunk in the list) |
        +--------------------------------+
        | ...old garbage...              |
        +--------------------------------+

זו נקודת המפתח לכל exploit heap: ברגע שנתח משתחרר, ה-16 בתים הראשונים של הזיכרון שלכם הופכים למצביעים fd/bk. אם נצליח לכתוב לנתח משוחרר (זו בדיוק החולשה use-after-free), אנחנו כותבים ישירות על מצביעים שהמקצה יבטח בהם. זה מה שהופך את ה-heap לשדה משחקים לתוקף.


שדה הגודל והדגלים - size flags

ה-size תמיד מיושר ל-16 בתים, ולכן 3 הביטים התחתונים שלו תמיד יהיו אפס. glibc "גונבת" את שלושת הביטים האלה לשלושה דגלים:

size = [ ..... real size ..... ][ A ][ M ][ P ]
                                  |    |    +--- PREV_INUSE   (0x1)
                                  |    +-------- IS_MMAPPED   (0x2)
                                  +------------- NON_MAIN_ARENA (0x4)
  • הדגל PREV_INUSE (0x1) - הכי חשוב לexploit. הוא לא מדבר על הנתח הנוכחי אלא על הנתח שלפניו: אם הביט דלוק, הנתח הקודם בשימוש. אם הוא כבוי, הנתח הקודם פנוי - ואז השדה prev_size של הנתח הנוכחי מכיל את הגודל של אותו נתח קודם. ככה המקצה יודע לאחד נתחים סמוכים.
  • הדגל IS_MMAPPED (0x2) - מסמן שהנתח הוקצה ישירות דרך mmap ולא מתוך ה-arena. זה קורה לבקשות ענק (כברירת מחדל מעל 128KB). נתח כזה משוחרר עם munmap ולא נכנס לאף פח.
  • הדגל NON_MAIN_ARENA (0x4) - מסמן שהנתח שייך ל-arena של thread ולא ל-main_arena.

כדי לקרוא את הגודל האמיתי פשוט מסננים את שלושת הביטים: real_size = size & ~0x7. בפועל, כשאתם רואים ב-pwndbg נתח בגודל 0x91, הגודל האמיתי הוא 0x90 וה-0x1 הוא PREV_INUSE דלוק.


חישוב גודל הנתח - request2size

הבקשה שלכם כמעט אף פעם לא הופכת לנתח באותו גודל. המקצה מוסיף 8 בתים לשדה ה-size, מיישר ל-16 בתים, וכופה מינימום של 0x20. הנוסחה (על 64 ביט):

chunk_size = max( 0x20 , (request + 8 + 15) rounded down to 16 )

יש כאן טריק חסכני: אפשר "לגנוב" את 8 הבתים של prev_size מהנתח הבא, כי כשהנתח הנוכחי בשימוש, אף אחד לא צריך את ה-prev_size ההוא. לכן:

malloc(0)    -> chunk 0x20   (minimum)
malloc(24)   -> chunk 0x20   (24 bytes fit in 0x20)
malloc(25)   -> chunk 0x30
malloc(0x18) -> chunk 0x20
malloc(0x88) -> chunk 0x90

כשתבנו exploit תעשו את החישוב הזה בראש כל הזמן, כי הגודל הוא מה שקובע לאיזה פח הנתח ילך כשישוחרר.


הפחים - bins

זה הלב של העניין. כשאתם קוראים ל-free, glibc לא מחזירה את הזיכרון לkernel. היא שומרת את הנתח ברשימה מקושרת פנימית כדי למחזר אותו מהר בהקצאה הבאה. הרשימות האלה נקראות פחים - bins, ויש כמה סוגים, כל אחד עם התנהגות משלו. הבנה של ההבדלים ביניהם היא בדיוק ההבדל בין מי שexploits heap למי שלא.

מטמון לכל thread - tcache

זו התוספת הכי חשובה מבחינת exploit, ונוספה בגרסה glibc 2.26. ה-tcache הוא מטמון פרטי לכל thread, בלי מנעולים, ולכן הוא הראשון שנבדק גם ב-malloc וגם ב-free.

  • יש 64 פחי tcache, אחד לכל גודל נתח בטווח 0x20 עד 0x410.
  • כל פח מחזיק עד 7 נתחים (הקבוע TCACHE_FILL_COUNT).
  • הרשימה חד-כיוונית (singly linked) דרך fd בלבד, ומתנהגת כמו מחסנית - LIFO. האחרון ששוחרר הוא הראשון שיוקצה.
  • מ-glibc 2.29 נוסף שדה key בתוך הנתח (במקום שבו יושב bk), שמשמש לזיהוי double free.
  • מ-glibc 2.32 נוסף safe-linking: המצביע fd בתוך נתח ב-tcache/fastbin מעורבב עם הכתובת שלו (fd ^ (address >> 12)). זו הגנה, ונתייחס אליה בפירוט בשיעורים על tcache poisoning.
tcache[0x20] ->  chunk C ->  chunk B ->  chunk A ->  NULL
                (freed last)              (freed first)

פחים מהירים - fastbins

לפני שהיה tcache, אלה היו הפחים המהירים. הם קיימים גם היום ומשמשים כשה-tcache המתאים מתמלא.

  • הם מחזיקים נתחים קטנים בלבד: כברירת מחדל עד גודל 0x80 (הקבוע global_max_fast), כלומר הגדלים 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, 0x80.
  • גם הם רשימה חד-כיוונית (LIFO) דרך fd.
  • מאפיין קריטי: כשמשחררים נתח לתוך fastbin, הדגל PREV_INUSE של הנתח שאחריו לא מתאפס. כלומר, מבחינת השכנים הנתח עדיין "בשימוש". זה נעשה בכוונה כדי למנוע איחוד (consolidation) של נתחים מהירים, כי הרעיון הוא שהם ימוחזרו מהר. יש לזה השלכות חשובות על exploit.

הפח הלא ממוין - unsorted bin

כאן מתחיל להיות מעניין. כשמשחררים נתח שהוא לא קטן מספיק ל-tcache/fastbin (כלומר גדול מ-0x410, או שה-tcache שלו מלא), הוא לא הולך ישר לפח המדויק שלו. קודם הוא נכנס ל-unsorted bin - מין תור המתנה זמני.

  • זו רשימה דו-כיוונית מעגלית (fd ו-bk), אחת בלבד, שמחזיקה נתחים בכל גודל בעירבוביה.
  • הרעיון: לתת לנתח הזדמנות להיות ממוחזר מיד. אם ה-malloc הבא מבקש בדיוק את הגודל הזה, המקצה ייתן אותו ישר מה-unsorted bin.
  • אם לא, במהלך ה-malloc הבא המקצה ממיין את הנתחים מה-unsorted bin לפחים המדויקים שלהם (small/large).

נקודה שחשובה מאוד לleak כתובות: כשנתח יחיד נכנס ל-unsorted bin, ה-fd וה-bk שלו מצביעים בחזרה לתוך main_arena (למבנה של ה-arena עצמו). אם נצליח לקרוא את הבתים האלה, קיבלנו leak libc - כתובת בתוך glibc. זו אחת הדרכים הקלאסיות לעקוף ASLR ב-heap.

פחים קטנים וגדולים - small bins ו-large bins

אלה הפחים ה"קבועים" שאליהם ממיינים מה-unsorted bin.

  • פחים קטנים - small bins: 62 פחים, כל אחד מחזיק נתחים בגודל מדויק אחד (מ-0x20 עד מתחת ל-0x400). דו-כיווניים, ומתנהגים FIFO. כי כל הנתחים בפח זהים בגודל, ההקצאה מיידית.
  • פחים גדולים - large bins: מחזיקים נתחים בגודל 0x400 ומעלה. כל פח כאן מחזיק טווח של גדלים, ולכן הנתחים בתוכו ממוינים לפי גודל, מהגדול לקטן. בשביל המיון המהיר הזה משתמשים בשני המצביעים הנוספים - fd_nextsize ו-bk_nextsize.

נתח הפסגה - top chunk

בקצה הערמה, ה-chunk האחרון גובל בזיכרון שעדיין לא חולק - הוא נקרא top chunk (או wilderness). כשאף פח לא יכול לשרת בקשה, המקצה פשוט חותך את מה שצריך מתוך ה-top chunk ומקטין אותו. אם גם ה-top chunk לא מספיק גדול, המקצה מרחיב את הערמה מהkernel (עוד brk/mmap). כשמשחררים נתח שגובל ב-top chunk, הוא נבלע בחזרה לתוכו.


מה קורה ב-malloc

עכשיו נחבר הכל. כשקוראים ל-malloc(n), הנה בגדול סדר החיפוש:

  1. מחשבים את גודל הנתח.
  2. אם יש נתח מתאים ב-tcache של הגודל הזה - מחזירים אותו מיד. זה המסלול המהיר.
  3. אחרת, אם הגודל הוא גודל fastbin ויש שם נתח - לוקחים אותו (ולפעמים ממלאים את ה-tcache בשאר הנתחים מאותו fastbin).
  4. אחרת, אם הגודל הוא גודל small bin ויש שם נתח - לוקחים אותו.
  5. אחרת נכנסים ללולאה הגדולה: מאחדים fastbins, מעבדים את ה-unsorted bin (ממיינים אותו, ואם יש התאמה מדויקת מחזירים אותה), ואז מחפשים ב-small ו-large bins.
  6. אם שום דבר לא התאים - חותכים מה-top chunk.
  7. ואם גם הוא קטן מדי - מבקשים עוד זיכרון מהkernel.

הנקודה החשובה: ב-glibc מודרנית ה-tcache תמיד ראשון. לכן ברוב תרגילי ה-heap הצעד הראשון הוא "למלא את ה-tcache" (7 נתחים) כדי לדחוף נתחים לתוך ה-fastbin או ה-unsorted bin ולראות אותם שם.


מה קורה ב-free

הזרימה של free(p) היא המראה של הקודם:

  1. אם ה-tcache של הגודל הזה לא מלא (פחות מ-7) - שמים את הנתח שם וזהו. מהיר, בלי בדיקות שכנים.
  2. אחרת, אם זה גודל fastbin - שמים אותו בראש ה-fastbin המתאים (בלי לאפס PREV_INUSE של השכן).
  3. אחרת - זה המסלול ה"איטי": בודקים אם השכנים פנויים ומאחדים איתם (consolidation), ואז שמים את התוצאה ב-unsorted bin. אם הנתח גובל ב-top chunk, הוא פשוט נבלע לתוכו.
  4. נתח שהוקצה ב-mmap (הדגל IS_MMAPPED) משוחרר ישירות עם munmap.

פיצול ואיחוד - splitting ו-consolidation

שני התהליכים האלה הם מה שהופך את ה-heap לדינמי, ושניהם ניתנים לexploit.

פיצול - splitting: אם המקצה מוצא נתח פנוי גדול יותר מהנדרש (למשל ב-top chunk או ב-unsorted bin), הוא חותך ממנו את מה שצריך ומחזיר את השארית כנתח פנוי חדש (remainder). ככה בקשה קטנה יכולה "לנגוס" בנתח גדול.

איחוד - consolidation: כשמשחררים נתח לא-מהיר והשכן הפיזי שלו (לפני או אחרי, בזיכרון) פנוי גם הוא, glibc מוציאה את השכן מהרשימה שלו (unlink) ומאחדת את השניים לנתח אחד גדול. זה מונע פיצול-יתר של הערמה. הבדיקה מתבצעת דרך הדגל PREV_INUSE של השכנים ודרך שדה ה-prev_size. בדיוק בגלל שה-unlink מסתמך על fd/bk, יש בו בדיקות קשיחות (unlink hardening) שמוודאות ש-P->fd->bk == P וש-P->bk->fd == P. את הבדיקות האלה נלמד לעקוף בשיעור על תקיפת unlink.

הפחים המהירים (fastbins) במיוחד לא מאוחדים בזמן free רגיל. הם מאוחדים רק כשקוראים ל-malloc_consolidate - בדרך כלל כשמבקשים נתח גדול והמקצה צריך לפנות מקום. עד אז הם נשארים מפוצלים כשרשרת קטנה ומהירה.


הכל ביחד - דוגמה עם pwndbg

בואו נראה את זה על תוכנית קטנה. נהדיר בינארי, נריץ תחת pwndbg, ונצפה בנתחים נודדים בין הפחים.

/* heap_demo.c  -  gcc heap_demo.c -o heap_demo -no-pie */
#include <stdlib.h>
#include <stdio.h>

int main(void) {
    char *a = malloc(0x18);   /* chunk 0x20  - tcache/fast size */
    char *b = malloc(0x18);   /* chunk 0x20 */
    char *big = malloc(0x500);/* chunk 0x510 - too big for tcache */
    char *guard = malloc(0x18);/* guard that separates big from the top chunk */

    free(a);                  /* -> tcache[0x20] */
    free(b);                  /* -> tcache[0x20] (head of the list) */
    free(big);                /* -> unsorted bin */

    puts("done");
    return (int)(long)guard;
}

הרצה ובדיקה:

gcc heap_demo.c -o heap_demo -no-pie
gdb ./heap_demo

בתוך pwndbg, נשים breakpoint אחרי ה-free-ים ונסתכל:

pwndbg> break puts
pwndbg> run
pwndbg> heap            # shows all chunks in the heap in physical order
pwndbg> bins            # shows all the bins: tcache, fast, unsorted, small, large
pwndbg> vis_heap_chunks # visual display of the chunks and pointers

הפקודה bins תראה משהו כזה (הכתובות אצלכם יהיו שונות):

tcache
tcachebins
0x20 [  2]: 0x5555555592a0 -> 0x555555559280 -> 0x0
fastbins
empty
unsortedbin
all: 0x5555555592d0 -> 0x7ffff7fa2ce0 (main_arena+96) <- 0x555555...

שימו לב לשני דברים חשובים:

  • ב-tcachebins יש שני נתחים בגודל 0x20. הראשון ברשימה הוא b (ששוחרר אחרון) - זו התנהגות ה-LIFO.
  • ה-unsortedbin מכיל את big, וה-fd שלו מצביע ל-main_arena+96. זו כתובת בתוך libc. אם נדליף אותה, נחשב את בסיס ה-libc ונעקוף ASLR.

עכשיו אפשר לראות את מבנה הנתח לעומק:

pwndbg> malloc_chunk 0x5555555592a0   # shows prev_size, size, fd, bk and the flags

וזה מאשר בעיניים את כל מה שדיברנו עליו: שדה size עם דגל PREV_INUSE, מצביע fd שמשרשר לנתח הבא, וכן הלאה.


סיכום

  • הערמה מנוהלת על ידי ptmalloc2, שמחלק זיכרון ל-נתחים - chunks ומחזיר אותם דרך פחים - bins.
  • כל נתח מתחיל ב-prev_size וב-size. הכתובת ש-malloc מחזיר היא 0x10 בתים אחרי תחילת הנתח.
  • שלושת הביטים התחתונים של size הם דגלים: PREV_INUSE, IS_MMAPPED, NON_MAIN_ARENA. PREV_INUSE מתאר את השכן הקודם ולכן הוא קריטי לאיחוד.
  • כשנתח פנוי, 16 הבתים הראשונים של הזיכרון שלכם הופכים למצביעי fd/bk. זה מה שמאפשר את כל exploits ה-heap.
  • המטמון tcache (מ-2.26) נבדק ראשון, מחזיק 7 נתחים לפח, LIFO. fastbins קטנים ומהירים, גם LIFO. unsorted bin הוא תור זמני שמדליף כתובות libc. small/large bins הם היעד הממוין. top chunk מספק זיכרון טרי.
  • פיצול חותך מנתח גדול, איחוד ממזג שכנים פנויים דרך unlink (עם בדיקות קשיחות שנלמד לעקוף).
  • ב-pwndbg: heap, bins, vis_heap_chunks, malloc_chunk הם הכלים שנחיה איתם בכל הפרק.

בשיעור הבא נתחיל להשתמש בידע הזה בפועל - חולשת use-after-free ראשונה.