לדלג לתוכן

7.4 תקיפת unlink הרצאה

בפרקים הקודמים ראינו איך ה-tcache וה-fastbins מחזיקים chunks משוחררים ברשימות מקושרות חד-כיווניות, ואיך שליטה בשדה fd נותנת לנו הקצאה שרירותית. עכשיו נעלה מדרגה ונדבר על ה-bins הגדולים יותר, שבהם ה-chunks חיים ברשימה דו-כיוונית עם שני מצביעים, fd ו-bk. כשמנהל הזיכרון מוציא chunk מרשימה כזו הוא קורא לפעולה בשם unlink, ואם נזייף את שני המצביעים נקבל פרימיטיב כתיבה חזק במיוחד. זו אחת ההתקפות הקלאסיות והמלמדות ביותר על ה-heap, והיא גם הסיבה ההיסטורית שבגללה glibc הוסיפה את מנגנוני ההגנה שנלמד עליהם כאן.

רשימה דו-כיוונית של chunks - doubly linked list

ב-unsorted bin, ב-small bins וב-large bins, כל chunk משוחרר מחזיק שני מצביעים בתחילת אזור הנתונים שלו:

  • השדה fd (forward) מצביע ל-chunk הבא ברשימה.
  • השדה bk (back) מצביע ל-chunk הקודם ברשימה.

זכרו את מבנה ה-chunk מפרק 7.1. בבנייה של 64 ביט השדות יושבים כך:

address of the chunk (mchunkptr)
+0x00 | prev_size |
+0x08 | size      |
+0x10 | fd        |  <-- pointer to the next chunk in the list
+0x18 | bk        |  <-- pointer to the previous chunk in the list
+0x20 | ...data    |

הכתובת שהמשתמש מקבל מ-malloc (ה-mem) היא chunk + 0x10, כלומר בדיוק המקום שבו יושב fd. כשה-chunk בשימוש, הבתים האלה הם הנתונים של המשתמש. כשהוא משוחרר ונכנס ל-bin, אותם בתים הופכים למצביעי הרשימה. זו בדיוק ההבלעה (overlap) בין נתונים למטא-דאטה שאנחנו הולכים לexploit.

רשימה של שלושה chunks נראית כך:

   +--------+       +--------+       +--------+
   |   A    |<--bk--|   B    |<--bk--|   C    |
   |     fd |--fd-->|     fd |--fd-->|        |
   +--------+       +--------+       +--------+

כלומר B->fd == C, B->bk == A, וכן הלאה.

כשמנהל הזיכרון רוצה להוציא chunk (נסמן אותו P) מאמצע הרשימה, הוא צריך "לתפור" מחדש את השכנים שלו כך שיצביעו זה אל זה ויקפצו מעל P. זה בדיוק מה שהמאקרו ההיסטורי unlink עשה:

#define unlink(P, BK, FD) {            \
    FD = P->fd;                        \
    BK = P->bk;                        \
    FD->bk = BK;   /* P->fd->bk = P->bk */ \
    BK->fd = FD;   /* P->bk->fd = P->fd */ \
}

במילים פשוטות: השכן הבא (FD) יקבל את השכן הקודם (BK) בתור ה-bk שלו, והשכן הקודם יקבל את השכן הבא בתור ה-fd שלו. אחרי הפעולה P כבר לא ברשימה:

before unlink(B):               after unlink(B):
  A <-> B <-> C                  A <-------> C
                                   (B bypassed)

עד כאן הכל תמים. אבל שימו לב מה קורה ברמת הזיכרון. הפעולה FD->bk = BK היא בעצם כתיבה לכתובת שנגזרת מ-P->fd. אם אנחנו שולטים בתוכן של P (למשל דרך overflow heap שדרסה את ה-chunk הזה), אנחנו שולטים ב-fd וב-bk שלו, ולכן שולטים גם לאן נכתב וגם מה נכתב.

פרימיטיב הכתיבה - the write primitive

בואו נפרק את שתי הכתיבות לפי היסטים. נסמן ב-OFF_FD את ההיסט של fd בתוך ה-chunk וב-OFF_BK את ההיסט של bk. שתי הכתיבות הן:

write 1:  *(FD + OFF_BK) = BK
write 2:  *(BK + OFF_FD) = FD

כאשר FD ו-BK הם הערכים שאנחנו הזרקנו ל-P->fd ו-P->bk. נניח שאנחנו רוצים לכתוב ערך V לכתובת יעד T. נבחר:

FD = T - OFF_BK      ->  write 1 becomes:  *(T) = V
BK = V               ->

וקיבלנו כתיבה שרירותית: *(T) = V. יש רק תופעת לוואי אחת, כתיבה 2, שמבצעת *(V + OFF_FD) = FD = T - OFF_BK. כלומר גם הכתובת V עצמה נדרסת.

הנה הטריק והמלכוד באותה נשימה: כדי שההתקפה לא תקרוס, כתובת הלוואי V חייבת להצביע לזיכרון בר-כתיבה. אם ננסה לכתוב ל-T ערך V שהוא כתובת קוד (למשל כתובת של פונקציה בקטע .text), תופעת הלוואי תנסה לכתוב ל-.text, שהוא לקריאה בלבד, ונקבל SIGSEGV. זו נקודה קריטית שנחזור אליה כשנבנה את ה-exploit: עם unlink לא כותבים כתובת קוד ישירות אל מצביע הרצה. כותבים מצביע לזיכרון בר-כתיבה שאנחנו שולטים בו, ומשם מנתבים את הזרימה.

ההתקפה ההיסטורית (מימי dlmalloc, סוף שנות ה-90) עבדה כך: תוכנית מחזיקה מצביע גלובלי g שמצביע ל-chunk, ובאיזשהו שלב היא משחררת אותו וקוראת ל-unlink. אם התוקף דרס את המטא-דאטה של אותו chunk (למשל דרך heap overflow), הוא זייף את fd ו-bk, וקיבל את הכתיבה *(T) = V שראינו למעלה. מכאן דרסו כתובת ב-GOT, מצביע פונקציה, או return address על המחסנית, וחטפו את זרימת הבקרה.

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

כדי לחסום את ההתקפה, glibc עטפה את המאקרו בבדיקות שפירותן שלמות (consistency) של הרשימה הדו-כיוונית. הרעיון פשוט ויפה: אם B באמת נמצא ברשימה תקינה בין A ל-C, אז חייב להתקיים ש-B->fd->bk == B וגם B->bk->fd == B. כלומר, השכן הבא צריך להצביע חזרה אל B דרך ה-bk שלו, והשכן הקודם צריך להצביע חזרה אל B דרך ה-fd שלו. הנה המאקרו המחוזק:

#define unlink(AV, P, BK, FD) {                                       \
    FD = P->fd;                                                       \
    BK = P->bk;                                                       \
    if (__builtin_expect (FD->bk != P || BK->fd != P, 0))            \
        malloc_printerr ("corrupted double-linked list");            \
    else {                                                            \
        FD->bk = BK;                                                  \
        BK->fd = FD;                                                  \
        /* ... in newer versions also a size check against prev_size ... */       \
    }                                                                 \
}

הבדיקה FD->bk != P || BK->fd != P היא לב העניין. אם התוקף מזייף את fd ו-bk כדי לכתוב *(T) = V, אזי FD ו-BK מצביעים למקומות שרירותיים, ואין שום סיבה ש-FD->bk יהיה שווה ל-P. הבדיקה נכשלת, וההקצאה מפילה את התוכנית עם corrupted double-linked list. בגרסאות מודרניות נוספה גם בדיקה ש-chunksize(P) תואם ל-prev_size של ה-chunk העוקב, שסותמת עוד וקטורים.

השורה התחתונה: unlink קלאסי מת מול glibc מודרנית. וזו בדיוק הסיבה שנולדו טכניקות ה-House of (House of Force, House of Spirit, House of Einherjar ועוד) שנלמד בפרק הבא - הן דרכים לתקוף את ה-allocator בלי להיכשל בבדיקות האלה.

הבדיקה נראית הרמטית, אבל יש לה חור מפורסם. מה אם נצליח לגרום ל-FD->bk ול-BK->fd באמת להצביע חזרה אל P? זה אפשרי כשקיים מצביע גלובלי g שמחזיק את הכתובת של P (מצב נפוץ מאוד: תוכניות שומרות טבלת מצביעים ל-chunks שהקצו). נשתמש ב-g בתור ה"עוגן".

בבנייה של 64 ביט, fd יושב בהיסט 0x10 ו-bk בהיסט 0x18 בתוך ה-chunk. נזייף בתוך P:

P->fd = &g - 0x18      (denote FD = &g - 0x18)
P->bk = &g - 0x10      (denote BK = &g - 0x10)

עכשיו נבדוק את התנאים של safe unlink:

FD->bk = *(FD + 0x18) = *(&g - 0x18 + 0x18) = *(&g) = g = P   ->  passes!
BK->fd = *(BK + 0x10) = *(&g - 0x10 + 0x10) = *(&g) = g = P   ->  passes!

שתי הבדיקות עוברות, כי שתיהן קוראות בסוף את g, ו-g באמת שווה ל-P. עכשיו unlink מבצע את הכתיבות:

FD->bk = BK   ->  *(&g) = &g - 0x10
BK->fd = FD   ->  *(&g) = &g - 0x18

התוצאה הסופית: g = &g - 0x18. במקום להצביע ל-chunk, המצביע הגלובלי מצביע עכשיו 0x18 בתים לפני עצמו. זה נראה תמים, אבל זה זהב: ברגע שהתוכנית תכתוב דרך g (למשל g->buf בפעולת "עריכה" רגילה), היא תכתוב לכתובת שאנחנו שולטים בה, וכוללת את g עצמו ואת השכנים שלו. מכאן אפשר להפוך את g למצביע לכל כתובת שנרצה, וקיבלנו כתיבה שרירותית מלאה. זה נקרא לפעמים unsafe unlink, וזו אבן הבניין של exploit heap מודרני רבים.

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

אחרי כל התיאוריה, בואו נראה את הפרימיטיב במצב הכי נקי שלו. האתגר unlink ב-pwnable.kr (בקטגוריית Rookiss) הוא בינארי של 32 ביט שממש כתוב כדי ללמד את ההתקפה הזו. היופי בו: הוא לא משתמש ב-unlink של glibc, אלא בפונקציה משלו בלי שום בדיקת בטיחות. אז אין לנו safe unlink להילחם בו, רק הפרימיטיב במלוא עוצמתו.

הנה המקור (unlink.c):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct tagOBJ {
    struct tagOBJ* fd;      // offset 0
    struct tagOBJ* bk;      // offset 4
    char buf[8];            // offset 8
} OBJ;

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

void unlink(OBJ* P) {
    OBJ* BK;
    OBJ* FD;
    BK = P->bk;
    FD = P->fd;
    FD->bk = BK;   // *(FD + 4) = BK
    BK->fd = FD;   // *(BK + 0) = FD
}

int main(int argc, char* argv[]) {
    malloc(1024);
    OBJ* A = (OBJ*)malloc(sizeof(OBJ));
    OBJ* B = (OBJ*)malloc(sizeof(OBJ));
    OBJ* C = (OBJ*)malloc(sizeof(OBJ));

    // doubly linked list: A <-> B <-> C
    A->fd = B;
    B->bk = A;
    B->fd = C;
    C->bk = B;

    printf("here is stack address leak: %p\n", &A);
    printf("here is heap address leak: %p\n", A);
    printf("now that you have leaks, get shell!\n");

    // heap overflow!
    gets(A->buf);

    // exploit the unlink!
    unlink(B);
    return 0;
}

בואו נקרא אותו כמו חוקרים. יש כאן ארבעה דברים חשובים:

  • הפונקציה unlink היא בדיוק המאקרו הקלאסי, בלי בדיקות: *(FD+4) = BK ואז *(BK) = FD. במבנה הזה fd בהיסט 0 ו-bk בהיסט 4.
  • התוכנית נותנת לנו שתי leaks בחינם: &A (כתובת על המחסנית, כי A הוא משתנה מקומי) ו-A (כתובת ב-heap, ה-chunk הראשון). כלומר גם ASLR של המחסנית וגם של ה-heap כבר לא בעיה.
  • הקריאה gets(A->buf) היא overflow heap קלאסית. gets לא מגבילה אורך, אז מהכתובת A->buf והלאה אנחנו כותבים כמה שנרצה, ובפרט אפשר לדרוס את fd ו-bk של B.
  • קיימת פונקציה shell() שקוראת ל-system("/bin/sh"). זה היעד שלנו: לגרום לתוכנית לקפוץ אליה.

ההגנות - protections

הבינארי הזה הוא 32 ביט, בלי PIE, בלי canary, עם NX דלוק. נאמת את זה תמיד עם checksec (נעשה את זה בתרגול), אבל המשמעות המעשית:

  • אין canary, אז דריסת מצביעים על המחסנית לא נתפסת.
  • אין PIE, אז הכתובת של shell() קבועה וידועה מראש (objdump -d ./unlink | grep shell).
  • מנגנון ה-NX דלוק, אבל לא אכפת לנו: אנחנו לא מריצים shellcode, אלא קופצים לפונקציה shell() שכבר קיימת.

התוכנית - the plan

אנחנו רוצים בסוף להגיע ל-shell(). ראינו שאסור לכתוב את הכתובת של shell ישירות למצביע הרצה, כי תופעת הלוואי של unlink תקרוס על .text. אז נשתמש בטריק ה-leave;ret הקלאסי:

  1. נזייף את B->fd ואת B->bk כך שה-unlink יכתוב מצביע ל-heap (זיכרון בר-כתיבה) אל תוך ה-saved ebp של הפריים על המחסנית. אין קריסה כי גם היעד וגם ערך הלוואי הם בזיכרון בר-כתיבה.
  2. באזור ה-heap שאליו הצבענו, נכין "פריים מזויף" שמכיל את הכתובת של shell() במקום הנכון.
  3. כשהפונקציה תסיים ותבצע leave; ret, ה-esp יקפוץ לפריים המזויף שלנו וה-ret יקרא ממנו את הכתובת של shell(). שתי פעולות leave; ret רצופות (של unlink ואז של main) הן שמאפשרות את ה-pivot מ-EBP מזויף ל-EIP מבוקר.

את החישוב המדויק של ההיסטים - המרחק מ-A->buf ל-B->fd, ואיזה saved ebp לדרוס - נעשה צעד-צעד בתרגול ובפתרון, כולל הפקודות ב-gdb שמודדות אותם. כאן העיקר להבין את הרעיון: מזייפים מטא-דאטה, מפעילים unlink, מקבלים כתיבה, וממנפים אותה ל-pivot אל shell.

אפשר לחשוב שאם glibc חסמה את unlink, הנושא היסטורי בלבד. זה לא נכון משתי סיבות. ראשית, מלא תוכנות ממשות ("אמיתיות") מממשות רשימות דו-כיווניות משלהן (allocators פנימיים, מבני נתונים בkernel, מנועי דפדפן), ושם אין את הבדיקות של glibc - הפרימיטיב חי וקיים בדיוק כמו באתגר. שנית, גם מול glibc, עקיפת safe unlink עם מצביע עוגן היא טכניקה נפוצה עד היום. אז הבנה עמוקה של unlink היא כלי עבודה, לא שיעור בהיסטוריה.

סיכום

  • ב-bins הגדולים של glibc chunks חיים ברשימה דו-כיוונית עם fd ו-bk, ופעולת unlink תופרת מחדש את השכנים כשמוציאים chunk.
  • שליטה ב-fd וב-bk של chunk (דרך גלישת heap או UAF) הופכת את unlink לפרימיטיב כתיבה: *(T) = V עבור T ו-V שנבחר, עם תופעת לוואי שדורסת את V - ולכן V חייב להצביע לזיכרון בר-כתיבה.
  • אסור לכתוב כתובת קוד ישירות למצביע הרצה עם unlink - כותבים מצביע לזיכרון בר-כתיבה ומנתבים משם (טריק ה-leave;ret).
  • חיזוק ה-safe unlink מוסיף את הבדיקות FD->bk == P ו-BK->fd == P (וגם בדיקת גודל), וזה מה שהרג את ההתקפה הקלאסית ונתן דחיפה לטכניקות ה-House of.
  • אפשר לעקוף safe unlink כשקיים מצביע גלובלי g אל ה-chunk: מזייפים fd = &g - 0x18 ו-bk = &g - 0x10 כך שהבדיקות עוברות, והתוצאה היא g = &g - 0x18 - מצביע שאנחנו שולטים בו.
  • אתגר unlink ב-pwnable.kr מדגים את הפרימיטיב במצב טהור: unlink ידני בלי בדיקות, שתי leaks בחינם, וoverflow heap דרך gets, שאותם נexploit כדי לקפוץ ל-shell().