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 משתנה בקריאה הראשונה בלבד.
- הריצו
checksec --file=./traceוּודאו שאתם רואיםPartial RELRO. - מצאו את כתובת תא ה-GOT של
puts. שתי דרכים:readelf -r ./trace | grep puts, וגם ב-pwndbg עם הפקודהgot. - פתחו את
traceב-GDB. לפניrun, הדפיסו את תוכן התא:x/gx <puts@got>. לאיזו כתובת הוא מצביע? רמז: השוו לפלט שלobjdump -d -j .plt ./trace- הכתובת אמורה ליפול בתוך ה-PLT. - שימו breakpoint על הקריאה הראשונה ל-
puts(break *main+<offset>), הריצו, ובדקו שוב את התא. אותו דבר. - עברו את הקריאה הראשונה (
finishאוnext), ובדקו את התא בפעם השלישית. עכשיו הוא אמור להצביע לכתובת אחרת לגמרי.
רמז: אמתו שהכתובת החדשה היא באמת puts של libc עם p puts או info symbol <value>. רשמו לעצמכם את שתי הכתובות (לפני ואחרי) - זה כל הסיפור של lazy binding בשתי שורות.
תרגיל 2 - למפות את ה-GOT¶
המטרה: להכיר את מבנה הטבלה, לא רק את תא puts.
- הריצו
readelf -S ./trace | grep -E '\.got|\.plt'וזהו את הכתובות של.plt,.got, ו-.got.plt. - ב-GDB, הדפיסו את ששת התאים הראשונים של
.got.plt:x/6gx <got.plt base>. זהו אתGOT[0](מצביע ל-_DYNAMIC), ואתGOT[1]ו-GOT[2]שממולאים בזמן טעינה. - השתמשו ב-
info symbolעל הערך שלGOT[2]. איזו פונקציה זו? רמז: זה הפותר. - הסבירו במילים שלכם: למה
GOT[3](שלputs) מצביע בהתחלה בחזרה לתוך ה-PLT ולא ל-libc?
רמז: לפני run, GOT[1] ו-GOT[2] עשויים להיות אפס - הם ממולאים על ידי הטוען. הריצו עד main ואז הסתכלו.
תרגיל 3 - Partial מול Full RELRO¶
המטרה: לראות בעיניים אילו תאים ניתנים לכתיבה בכל מצב.
- הריצו
checksecעלgotdemoועלgotdemo_full. ודאו שאתם רואיםPartial RELROמולFull RELRO. - הריצו
readelf -d ./gotdemo_full | grep -i -E 'bind|now'וּראו את הדגלBIND_NOW. הריצו את אותו הדבר עלgotdemo(Partial) - הדגל חסר. הסבירו למה BIND_NOW פירושו שאין lazy binding. - פתחו כל בינארי ב-GDB, עצרו ב-
main, והריצוvmmap. מצאו את הדף שמכיל את כתובתputs@got. מה ההרשאות שלו ב-Partial (רמז:rw-) ומה ב-Full (רמז:r--)? - ב-
gotdemo_full, נסו בכוונה: ב-GDB,set {long}<puts@got> = 0x41414141. מה קורה? רמז: הכתיבה אמורה להיכשל כי הדף קריאה-בלבד.
רמז: את puts@got תמצאו כמו בתרגיל 1. אותה כתובת נומרית בשני הבינארים (שניהם No PIE), אבל ההרשאות שונות.
תרגיל 4 - דריסת GOT ראשונה (Partial RELRO)¶
המטרה: לחטוף את puts("bye") כך שיריץ win() ויפתח shell.
- מצאו את שתי הכתובות שתצטרכו:
puts@got(כתובת התא) ו-win(כתובת הפונקציה). השתמשו ב-pwntools:elf.got['puts']ו-elf.symbols['win']. - חשבו: איזה ערך תזין התוכנית לתוך
addr, ואיזה לתוךval, כדי לדרוס אתputs@gotבכתובת שלwin? - כתבו exploit ב-pwntools. הזרימה: שלחו
addr = puts@got, אחר כךval = win, ואז עברו ל-interactive. - הריצו וּוַדאו שאתם מקבלים 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¶
המטרה: להבין למה אותה מתקפה בדיוק נכשלת.
- קחו את ה-exploit מתרגיל 4 והחליפו את היעד ל-
gotdemo_full(ELF('./gotdemo_full'),process('./gotdemo_full')). - הריצו. מה קורה? רמז: התוכנית אמורה לקרוס. באיזה שלב בדיוק?
- הריצו את זה תחת GDB (
gdb ./gotdemo_full, ואזrun < payload) ותראו את ה-SIGSEGV. על איזו הוראה הוא נופל? רמז: על הכתיבה*(unsigned long *)addr = valבתוךmain. - כתבו במשפט אחד למה הכתיבה נכשלה כאן ולא ב-
gotdemoהרגיל.
רמז: זה לא באג ב-exploit שלכם. ההגנה עשתה בדיוק את מה שהיא נועדה לעשות - הפכה את הדף של ה-GOT לקריאה-בלבד. אין תיקון מצד ה-exploit, זו הנקודה של Full RELRO.
תרגיל 6 (בונוס) - לחשוב כמו תוקף אמיתי¶
- בבינארי אמיתי אין פונקציית
win. במקום לדרוס עם כתובת פנימית, נדרוס עם כתובת שלsystemב-libc. איזה מידע נוסף תצטרכו כדי לחשב את הכתובת שלsystem? רמז: בסיס libc, כלומר leak - בדיוק מה שנלמד ב-4.4. - אם היינו דורסים את
puts@gotבכתובת שלsystem, איזו קריאה בתוכנית הייתה מריצהsystemעם ארגומנט שאנחנו שולטים בו? חשבו על קריאהputs(user_input). - חשבו: מדוע
printf,freeו-atoiהם יעדי דריסת GOT פופולריים אפילו יותר מ-puts? רמז: מי שולט בארגומנט שלהם, וכמה פעמים הם נקראים.