לדלג לתוכן

4.1 GOT וPLT לעומק תרגול

תרגול - GOT וPLT לעומק

בתרגול הזה תראו את הפתרון הדינמי קורה במו עיניכם, תבינו מי כותב לתא ה-GOT ומתי, ותבצעו דריסת GOT ראשונה שפותחת shell. בסוף גם תבדקו על בשרכם למה Full RELRO הופך את כל המתקפה לבלתי אפשרית. עבדו לפי הסדר - כל תרגיל בונה על הקודם. אין כאן אתגר pwnable.kr ייעודי, אנחנו בונים בינארים משלנו כדי לבודד כל רעיון, אבל בדיוק היכולות האלה חוזרות באתגרים כמו fsb ובכל אתגר format string שנפגוש בהמשך.

הכנה - הבינארים

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

// trace.c
#include <stdio.h>
int main() {
    puts("first call");    // triggers the resolver
    puts("second call");   // direct
    return 0;
}

השני, gotdemo.c, נותן לנו כתיבה שרירותית (write-what-where) ופונקציה מנצחת:

// gotdemo.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win() {
    system("/bin/sh");   // deliberately does not call puts
}

int main() {
    setvbuf(stdout, NULL, _IONBF, 0);
    unsigned long addr, val;

    puts("addr (hex):");
    scanf("%lx", &addr);
    puts("val (hex):");
    scanf("%lx", &val);

    *(unsigned long *)addr = val;   // arbitrary write

    puts("bye");                     // the call we'll hijack
    return 0;
}

קמפלו את שתי הגרסאות של כל בינארי - אחת Partial RELRO ואחת Full RELRO:

# trace: clean classic version for tracing
gcc -no-pie -fcf-protection=none -z lazy -o trace trace.c

# gotdemo: Partial RELRO (default) vs Full RELRO
gcc -no-pie -fno-stack-protector -o gotdemo       gotdemo.c
gcc -no-pie -fno-stack-protector -z now -o gotdemo_full gotdemo.c

הנחות ההגנה שלנו לכל התרגול: NX פעיל, בלי canary, לא PIE (כדי שהכתובות יהיו קבועות ולא נצטרך עדיין leak). ה-RELRO הוא המשתנה שנשחק איתו.

תרגיל 1 - לראות את הפתרון קורה

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

  1. הריצו checksec --file=./trace וּודאו שאתם רואים Partial RELRO.
  2. מצאו את כתובת תא ה-GOT של puts. שתי דרכים: readelf -r ./trace | grep puts, וגם ב-pwndbg עם הפקודה got.
  3. פתחו את trace ב-GDB. לפני run, הדפיסו את תוכן התא: x/gx <puts@got>. לאיזו כתובת הוא מצביע? רמז: השוו לפלט של objdump -d -j .plt ./trace - הכתובת אמורה ליפול בתוך ה-PLT.
  4. שימו breakpoint על הקריאה הראשונה ל-puts (break *main+<offset>), הריצו, ובדקו שוב את התא. אותו דבר.
  5. עברו את הקריאה הראשונה (finish או next), ובדקו את התא בפעם השלישית. עכשיו הוא אמור להצביע לכתובת אחרת לגמרי.

רמז: אמתו שהכתובת החדשה היא באמת puts של libc עם p puts או info symbol <value>. רשמו לעצמכם את שתי הכתובות (לפני ואחרי) - זה כל הסיפור של lazy binding בשתי שורות.

תרגיל 2 - למפות את ה-GOT

המטרה: להכיר את מבנה הטבלה, לא רק את תא puts.

  1. הריצו readelf -S ./trace | grep -E '\.got|\.plt' וזהו את הכתובות של .plt, .got, ו-.got.plt.
  2. ב-GDB, הדפיסו את ששת התאים הראשונים של .got.plt: x/6gx <got.plt base>. זהו את GOT[0] (מצביע ל-_DYNAMIC), ואת GOT[1] ו-GOT[2] שממולאים בזמן טעינה.
  3. השתמשו ב-info symbol על הערך של GOT[2]. איזו פונקציה זו? רמז: זה הפותר.
  4. הסבירו במילים שלכם: למה GOT[3] (של puts) מצביע בהתחלה בחזרה לתוך ה-PLT ולא ל-libc?

רמז: לפני run, GOT[1] ו-GOT[2] עשויים להיות אפס - הם ממולאים על ידי הטוען. הריצו עד main ואז הסתכלו.

תרגיל 3 - Partial מול Full RELRO

המטרה: לראות בעיניים אילו תאים ניתנים לכתיבה בכל מצב.

  1. הריצו checksec על gotdemo ועל gotdemo_full. ודאו שאתם רואים Partial RELRO מול Full RELRO.
  2. הריצו readelf -d ./gotdemo_full | grep -i -E 'bind|now' וּראו את הדגל BIND_NOW. הריצו את אותו הדבר על gotdemo (Partial) - הדגל חסר. הסבירו למה BIND_NOW פירושו שאין lazy binding.
  3. פתחו כל בינארי ב-GDB, עצרו ב-main, והריצו vmmap. מצאו את הדף שמכיל את כתובת puts@got. מה ההרשאות שלו ב-Partial (רמז: rw-) ומה ב-Full (רמז: r--)?
  4. ב-gotdemo_full, נסו בכוונה: ב-GDB, set {long}<puts@got> = 0x41414141. מה קורה? רמז: הכתיבה אמורה להיכשל כי הדף קריאה-בלבד.

רמז: את puts@got תמצאו כמו בתרגיל 1. אותה כתובת נומרית בשני הבינארים (שניהם No PIE), אבל ההרשאות שונות.

תרגיל 4 - דריסת GOT ראשונה (Partial RELRO)

המטרה: לחטוף את puts("bye") כך שיריץ win() ויפתח shell.

  1. מצאו את שתי הכתובות שתצטרכו: puts@got (כתובת התא) ו-win (כתובת הפונקציה). השתמשו ב-pwntools: elf.got['puts'] ו-elf.symbols['win'].
  2. חשבו: איזה ערך תזין התוכנית לתוך addr, ואיזה לתוך val, כדי לדרוס את puts@got בכתובת של win?
  3. כתבו exploit ב-pwntools. הזרימה: שלחו addr = puts@got, אחר כך val = win, ואז עברו ל-interactive.
  4. הריצו וּוַדאו שאתם מקבלים shell. הריצו id ו-cat על משהו כדי לוודא שהוא חי.

רמז: scanf("%lx") מקבל תחילית 0x, אז אפשר לשלוח ישר hex(addr).encode(). ודאו שאתם מסנכרנים על ההודעות addr (hex): ו-val (hex): עם recvuntil לפני כל sendline, אחרת הקלט מתערבב.

רמז שני: אם אתם רואים "bye" נדפס במקום shell - כנראה כתבתם לכתובת הלא נכונה, או ש-win בטעות קוראת ל-puts. ודאו ש-win קוראת ישירות ל-system.

תרגיל 5 - אותו exploit על Full RELRO

המטרה: להבין למה אותה מתקפה בדיוק נכשלת.

  1. קחו את ה-exploit מתרגיל 4 והחליפו את היעד ל-gotdemo_full (ELF('./gotdemo_full'), process('./gotdemo_full')).
  2. הריצו. מה קורה? רמז: התוכנית אמורה לקרוס. באיזה שלב בדיוק?
  3. הריצו את זה תחת GDB (gdb ./gotdemo_full, ואז run < payload) ותראו את ה-SIGSEGV. על איזו הוראה הוא נופל? רמז: על הכתיבה *(unsigned long *)addr = val בתוך main.
  4. כתבו במשפט אחד למה הכתיבה נכשלה כאן ולא ב-gotdemo הרגיל.

רמז: זה לא באג ב-exploit שלכם. ההגנה עשתה בדיוק את מה שהיא נועדה לעשות - הפכה את הדף של ה-GOT לקריאה-בלבד. אין תיקון מצד ה-exploit, זו הנקודה של Full RELRO.

תרגיל 6 (בונוס) - לחשוב כמו תוקף אמיתי

  1. בבינארי אמיתי אין פונקציית win. במקום לדרוס עם כתובת פנימית, נדרוס עם כתובת של system ב-libc. איזה מידע נוסף תצטרכו כדי לחשב את הכתובת של system? רמז: בסיס libc, כלומר leak - בדיוק מה שנלמד ב-4.4.
  2. אם היינו דורסים את puts@got בכתובת של system, איזו קריאה בתוכנית הייתה מריצה system עם ארגומנט שאנחנו שולטים בו? חשבו על קריאה puts(user_input).
  3. חשבו: מדוע printf, free ו-atoi הם יעדי דריסת GOT פופולריים אפילו יותר מ-puts? רמז: מי שולט בארגומנט שלהם, וכמה פעמים הם נקראים.