לדלג לתוכן

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

פתרון - פנימיות המקצה של glibc

נעבור תרגיל אחר תרגיל. הכתובות שתראו אצלכם יהיו שונות (בגלל ASLR), אבל המבנה והיחסים בין הכתובות זהים. כל הפלטים כאן נלקחו מ-glibc 2.35 על x86-64 עם ASLR. אם ה-glibc שלכם 2.32 ומעלה, המצביעים ב-tcache יראו "מוזרים" בגלל safe-linking - נסביר את זה בתרגיל 2.

תזכורת להידור ולהרצה:

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

פתרון תרגיל 1 - צילום הערמה אחרי הקצאה

שמנו breakpoint על puts והרצנו עד ה"allocated":

pwndbg> break puts
pwndbg> run
pwndbg> heap

הפלט (מקוצר) נראה כך:

Allocated chunk | PREV_INUSE
Addr: 0x555555559290
Size: 0x21

Allocated chunk | PREV_INUSE
Addr: 0x5555555592b0
Size: 0x21
... (7 more chunks of size 0x21) ...

Allocated chunk | PREV_INUSE
Addr: 0x555555559470
Size: 0x511          <- this is big

Allocated chunk | PREV_INUSE
Addr: 0x555555559980
Size: 0x21          <- this is guard

Top chunk | PREV_INUSE
Addr: 0x5555555599a0
Size: 0x20661

מה אנחנו רואים:

  • תשעה נתחים רצופים בגודל 0x21, אחר כך big בגודל 0x511, אחר כך guard, ואז ה-top chunk הענק.
  • הגודל של big הוא 0x511 ולא 0x510 - כי הביט התחתון הוא PREV_INUSE שדלוק. הגודל האמיתי הוא 0x511 & ~0x7 = 0x510, כמו שחישבנו: malloc(0x500) -> (0x500 + 8 + 15) & ~15 = 0x510.

התשובה לשאלה בכתב: malloc(0x18) תפס 0x20 בתים (32), לא 0x18 (24). הסיבה כפולה: המקצה מוסיף 8 בתים לשדה ה-size, ומיישר את התוצאה ל-16 בתים. (0x18 + 8 + 15) & ~15 = 0x20. שימו לב שדווקא כאן 0x18 בתי משתמש כן נכנסים ב-0x20, כי אפשר "לשאול" את 8 הבתים של prev_size מהנתח הבא כל עוד הנתח הנוכחי בשימוש.


פתרון תרגיל 2 - מילוי ה-tcache וזליגה ל-fastbin

המשכנו עד אחרי כל ה-free-ים:

pwndbg> continue        # stops at puts("freed")
pwndbg> bins

הפלט:

tcachebins
0x20 [  7]: 0x555555559410 -> 0x5555555593f0 -> ... -> 0x555555559290
fastbins
0x20: 0x555555559430 -> 0x555555559450 -> 0x0

זה בדיוק מה שציפינו. שחררנו 9 נתחים בגודל 0x20:

  • שבעת הראשונים (chunks[0] עד chunks[6]) מילאו את tcache[0x20] עד הגבול.
  • שני האחרונים (chunks[7], chunks[8]) לא נכנסו ל-tcache שהתמלא, ולכן נפלו ל-fastbin של גודל 0x20.

עכשיו לסדר. ה-tcache הוא LIFO, אז הראש שלו הוא הנתח האחרון שנכנס אליו. שחררנו לפי הסדר chunks[0] ... chunks[8], אבל רק 0..6 נכנסו ל-tcache, ואחרון מביניהם הוא chunks[6] בכתובת 0x555555559410. ואכן, זה ראש הרשימה. הזנב הוא chunks[0] בכתובת 0x555555559290.

ה-fastbin קיבל את chunks[7] ואז chunks[8], וגם הוא LIFO, אז הראש שלו הוא chunks[8] (0x555555559450), ואחריו chunks[7].

אימות עם malloc_chunk:

pwndbg> malloc_chunk 0x555555559410
0x555555559410 FD: 0x5555555593f0  (the next chunk in the tcache list)

הערה על safe-linking (glibc 2.32+): אם אתם על גרסה 2.32 ומעלה, ה-fd שתראו לא יהיה כתובת "נקייה" אלא ערך מעורבל: fd_real ^ (chunk_addr >> 12). pwndbg כבר יודע לפענח את זה ב-bins, ולכן שם תראו כתובות רגילות. אם תסתכלו על הבתים הגולמיים בזיכרון (x/2gx), תראו את הערך המעורבל. אל תיבהלו - זו הגנה, ונלמד לעקוף אותה בשיעור על tcache poisoning.

התשובה לשאלה בכתב: בראש ה-tcache יושב chunks[6] (האחרון שנכנס ל-tcache), ובראש ה-fastbin יושב chunks[8] (האחרון ששוחרר בכלל). הסיבה: שתי הרשימות הן LIFO, וה-tcache התמלא אחרי 7 שחרורים, כך שהשחרורים ה-8 וה-9 גלשו ל-fastbin.


פתרון תרגיל 3 - ה-unsorted bin וleak libc

באותה נקודה, בפלט של bins, נמצא גם את השורה של ה-unsorted bin:

unsortedbin
all: 0x555555559470 -> 0x7ffff7e19ce0 (main_arena+96) <- 0x555555559470

הנתח big (בכתובת 0x555555559470) נחת ב-unsorted bin, כי הוא גדול מ-0x410 (לא tcache) וגם גדול מ-0x80 (לא fastbin). נסתכל על המצביעים שלו:

pwndbg> malloc_chunk 0x555555559470
0x555555559470 PREV_INUSE
    Size: 0x511
    fd:   0x7ffff7e19ce0
    bk:   0x7ffff7e19ce0

הנה ההבדל המהותי מ-tcache/fastbin: ה-fd וה-bk כאן מצביעים ל-0x7ffff7e19ce0, שזו כתובת בטווח הגבוה (0x7f...) - בתוך libc, לא בערמה (0x55...). pwndbg אפילו מסמן לנו main_arena+96. כשיש נתח יחיד ב-unsorted bin, הרשימה המעגלית משרשרת אותו בחזרה אל ה-arena, וה-arena חיה בתוך glibc.

נאמת עם vmmap:

pwndbg> vmmap 0x7ffff7e19ce0
    0x7ffff7e19000  0x7ffff7e1b000  rw-p   libc.so.6

אכן, הכתובת יושבת בתוך המיפוי של libc.so.6.

התשובה לשאלה בכתב: לו יכולנו לקרוא את 8 הבתים של ה-fd (למשל דרך use-after-free שמדפיס נתח משוחרר), היינו מקבלים כתובת קבועה בתוך libc - main_arena+96. ה-offset של main_arena מתחילת libc קבוע לכל גרסת glibc נתונה, ולכן: libc_base = leaked - offset_of(main_arena+96). ברגע שיש לנו libc_base, כל פונקציה בו (למשל system) בכתובת ידועה, וה-ASLR של libc נשבר לגמרי. זו טכניקת leak קלאסית ב-heap.


פתרון תרגיל 4 (אתגר) - איחוד לתוך ה-top chunk

הסרנו את שורת ה-guard, הידרנו מחדש, והרצנו עד אחרי free(big):

pwndbg> bins
tcachebins
0x20 [  7]: ...
fastbins
0x20: ...
unsortedbin
all: 0x0            <- empty!

ה-unsorted bin ריק. איפה big? הוא נבלע ל-top chunk. נסתכל על גודל ה-top:

pwndbg> top_chunk
0x555555559470 Size: 0x20b91    <- grew!

לפני השחרור ה-top התחיל אחרי big. עכשיו, בלי ה-guard, big היה הנתח האחרון לפני ה-top - כלומר גבל בו ישירות. כשמשחררים נתח שגובל ב-top chunk, glibc לא שמה אותו בפח אלא מאחדת אותו לתוך ה-top. הכתובת של ה-top ירדה אחורה עד תחילת big, והגודל שלו גדל ב-0x510.

התשובה לשאלה בכתב: השומר guard היה נחוץ כדי למנוע מ-big לגבול ישירות ב-top chunk. בלעדיו, free(big) פשוט מרחיב את ה-top במקום להכניס את big ל-unsorted bin, וכך לא היינו יכולים לצפות בו שם.


איך להכליל

  • חוק אצבע ראשון: כדי לדחוף נתחים ל-fastbin או ל-unsorted bin, קודם צריך "לעקוף" את ה-tcache. או שממלאים אותו (7 נתחים), או שמשחררים נתח גדול ממנו (מעל 0x410), או שמכבים אותו לגמרי עם export GLIBC_TUNABLES=glibc.malloc.tcache_count=0.
  • חוק אצבע שני: כמעט בכל תרגיל heap צריך נתח שומר לפני ה-top chunk, אחרת נתחים שאתם משחררים נבלעים ל-top ונעלמים לכם מהעיניים.
  • חוק אצבע שלישי: ה-unsorted bin הוא מקור הleak הקלאסי. אם אתם צריכים כתובת libc, החזקה של נתח משוחרר גדול ב-unsorted bin וקריאה של ה-fd שלו זו הדרך הראשונה שכדאי לנסות.

שלושת הכלים heap, bins, vis_heap_chunks יהיו החברים הכי טובים שלכם בכל הפרק. כדאי להתרגל להריץ אותם אחרי כל malloc וכל free עד שהתמונה הופכת לאינטואיטיבית.