0.3 חזרה ממוקדת על הבסיס פתרון
פתרון - חזרה ממוקדת על הבסיס¶
הפתרון עובר תרגיל-תרגיל עם הפקודות המדויקות והפלט הצפוי. הכתובות אצלכם יהיו שונות (בגלל ASLR), אבל המבנה יהיה זהה. שווה להשוות שורה-שורה מול מה שיצא לכם.
תזכורת לקימפול:
הוספתי -fno-stack-protector לגרסת ה-nopie כדי שתרגיל 5 יעבוד בלי שהcanary יעצור אותנו.
פתרון תרגיל 1 - מפת הזיכרון של תהליך חי¶
מריצים ולוקחים את ה-pid:
פלט מקוצר וטיפוסי:
55d3c8a2a000-55d3c8a2b000 r--p 00000000 08:01 ... /home/amit/basics_pie <- ELF header
55d3c8a2b000-55d3c8a2c000 r-xp 00001000 08:01 ... /home/amit/basics_pie <- .text (r-x)
55d3c8a2c000-55d3c8a2d000 r--p 00002000 08:01 ... /home/amit/basics_pie <- .rodata
55d3c8a2d000-55d3c8a2e000 r--p 00002000 08:01 ... /home/amit/basics_pie <- RELRO
55d3c8a2e000-55d3c8a2f000 rw-p 00003000 08:01 ... /home/amit/basics_pie <- .data/.bss (rw)
55d3ca1f7000-55d3ca218000 rw-p 00000000 00:00 0 [heap] <- heap (rw)
7f2b1c400000-... r-xp ... /usr/lib/libc.so.6 <- libc (r-x)
...
7ffd9e3c0000-7ffd9e3e1000 rw-p 00000000 00:00 0 [stack] <- stack (rw)
תשובות:
- הקוד (
.text) הואr-x: קריאה והרצה, בלי כתיבה. - המחסנית, הערמה ואזור ה-
.dataהםrw-: קריאה וכתיבה, בלי הרצה. - אין אזור שהוא גם
wוגםxבו-זמנית. זה בדיוק ה-NX: אי אפשר לכתוב shellcode למחסנית ולהריץ אותו, כי המחסנית לא ניתנת להרצה. - הרצה שנייה תיתן כתובות בסיס שונות לגמרי (
55d3...יהפוך למשהו אחר), כי ASLR פעיל. בדיקה:
למה זה עבד / איך להכליל: /proc/pid/maps הוא החבר הכי טוב שלנו כשאנחנו רוצים להבין תהליך חי. בכל אתגר, אחת הפעולות הראשונות תהיה להסתכל אילו אזורים כתיבים, אילו ניתנים להרצה, ואיפה libc יושבת. ההרשאות של האזורים הן שמכתיבות אילו טכניקות בכלל אפשריות.
פתרון תרגיל 2 - PIE לעומת לא-PIE¶
checksec --file=./basics_pie
# PIE: PIE enabled
checksec --file=./basics_nopie
# PIE: No PIE (0x400000)
בדיקת ה-Type בכותרת ה-ELF:
readelf -h ./basics_pie | grep Type
# Type: DYN (Position-Independent Executable file)
readelf -h ./basics_nopie | grep Type
# Type: EXEC (Executable file)
מפת הזיכרון של הגרסה הלא-PIE:
./basics_nopie &
grep basics_nopie /proc/$!/maps
# 00400000-00401000 r--p ... basics_nopie
# 00401000-00402000 r-xp ... basics_nopie <- the code is always at 0x40....
# ...
הקוד יושב תמיד סביב 0x400000, וזה לא משתנה בין הרצות (הבינארי עצמו לא מושפע מ-ASLR; רק המחסנית, הערמה ו-libc כן).
למה צריך leak ב-PIE ולא בלא-PIE: בבינארי לא-PIE הכתובת של main, של גאדג'טים ושל הטבלאות ידועה מראש וקבועה - אפשר לכתוב אותה ישירות ב-exploit. בבינארי PIE כל הכתובות הפנימיות מוזזות בכמות אקראית בכל הרצה, אז אנחנו לא יודעים אותן מראש. הפתרון: לדלוף כתובת אחת שאנחנו כן מזהים, לחשב ממנה את בסיס הטעינה (base = כתובת שדלפנו פחות ה-offset הקבוע שלה בקובץ), ואז לחשב כל כתובת אחרת יחסית לבסיס.
למה זה עבד / איך להכליל: checksec ו-readelf -h הם שתי הבדיקות הראשונות על כל בינארי חדש. Type: DYN = PIE = צריך leak. Type: EXEC = כתובות קבועות = החיים קלים יותר.
פתרון תרגיל 3 - איתור GOT ו-PLT¶
טבלת ה-relocations:
הקטע הרלוונטי (בערך):
Relocation section '.rela.plt' at offset 0x5c0 contains 4 entries:
Offset Info Type Sym. Name
0000000404018 ... R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
0000000404020 ... R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5
0000000404028 ... R_X86_64_JUMP_SLOT strcpy@GLIBC_2.2.5
0000000404030 ... R_X86_64_JUMP_SLOT getchar@GLIBC_2.2.5
אז כניסת ה-GOT של puts היא בכתובת 0x404018, וזו של strcpy היא 0x404028.
עכשיו ה-PLT:
הקטע של puts:
0000000000401030 <puts@plt>:
401030: ff 25 e2 2f 00 00 jmp *0x2fe2(%rip) # 404018 <puts@GLIBC_2.2.5>
401036: 68 00 00 00 00 push $0x0
40103b: e9 e0 ff ff ff jmp 401020 <_init+0x20>
שימו לב לתגובית שהדיסאסמבלר הוסיף: # 404018. זו בדיוק כתובת ה-GOT של puts מטבלת ה-relocations. הן חייבות להתאים, כי ה-PLT קופץ (jmp) דרך כניסת ה-GOT: הקוד קורא ל-puts@plt, שקופץ לכתובת שכתובה ב-0x404018. בהתחלה שם כתובת שמחזירה ל-resolver, ואחרי הפתרון הראשון שם כבר הכתובת האמיתית של puts ב-libc.
ובתוך main:
הפונקציה main קוראת ל-puts@plt, לא ל-puts עצמו, כי בזמן הקומפילציה הכתובת האמיתית של puts בתוך libc עדיין לא ידועה (libc נטענת דינמית לכתובת אקראית). ה-PLT הוא רמת הכוונה (indirection) שמאפשרת לדחות את קביעת הכתובת האמיתית לזמן ריצה.
למה זה עבד / איך להכליל: readelf -r נותן לכם את מפת כתובות ה-GOT לפי שם פונקציה, ו-objdump -d -j .plt מראה איך ה-PLT קופץ דרכן. הצמד הזה הוא בדיוק מה שנצטרך כשנרצה לדלוף כתובת libc (קוראים את ה-GOT של פונקציה שכבר נפתרה) או לדרוס GOT (כותבים ל-Offset שמצאנו).
פתרון תרגיל 4 - לצעוד על call ו-ret ב-gdb¶
עכשיו אנחנו בתוך greet. מסתכלים על ראש המחסנית:
מצליבים עם הדיסאסמבלי של main:
pwndbg> disassemble main
...
0x000000000040122f <+...>: call 0x4011a9 <greet>
0x0000000000401234 <+...>: lea rax, [rip+...] <- the instruction after the call
...
הכתובת 0x401234 שראינו בראש המחסנית היא בדיוק ההוראה שמיד אחרי ה-call greet. זו return address שה-call דחף.
רושמים את הערכים (דוגמה): $rsp = 0x7fffffffe288, $rip = 0x4011a9+... (איפשהו בתוך greet).
עכשיו שמים עצירה על ה-ret של greet:
pwndbg> disassemble greet
...
0x00000000004011d8 <+47>: leave
0x00000000004011d9 <+48>: ret
pwndbg> break *0x4011d9
pwndbg> continue
הגענו ל-ret. לפני שנצעד, נסתכל מה עומד להישלף:
pwndbg> print $rsp
$1 = (void *) 0x7fffffffe288
pwndbg> x/gx $rsp
0x7fffffffe288: 0x0000000000401234 <- this is what ret is about to load into rip
צועדים הוראה אחת:
pwndbg> stepi
pwndbg> print $rip
$2 = (void (*)()) 0x401234 <main+...> <- rip got exactly the value that was at the top of the stack
pwndbg> print $rsp
$3 = (void *) 0x7fffffffe290 <- rsp grew by 8 (0xe288 -> 0xe290)
בשורה אחת: ret שלף 8 בתים מראש המחסנית לתוך rip (כלומר pop rip), והגדיל את rsp ב-8. שום בדיקה, שום ולידציה. הוא פשוט קפץ למה שהיה שם.
למה זה עבד / איך להכליל: זו התובנה שכל הקורס נשען עליה. ה-ret בוטח בעיוורון בערך שבראש המחסנית. אם נשלוט בערך הזה (וbuffer overflow נותנת לנו בדיוק את זה), נשלוט ב-rip. וכשנשרשר כמה ערכים כאלה בזה אחר זה - כל אחד גאדג'ט שמסתיים ב-ret - קיבלנו ROP.
פתרון תרגיל 5 (אתגר) - מה קורה כשדורסים את הreturn address¶
התוכנית קורסת. במצב הקריסה:
Program received signal SIGSEGV, Segmentation fault.
pwndbg> print $rip
$1 = (void (*)()) 0x4141414141414141
הערך 0x4141414141414141 הוא שמונה בתים של A (הקוד ההקסה של A הוא 0x41). מאיפה הוא הגיע? ה-strcpy בתוך greet העתיק את 200 ה-A-ים לתוך local[32], גלש הרבה מעבר לbuffer, ודרס את ה-rbp השמור ואת הreturn address. כשה-greet ביצע ret, הוא שלף את ה-A-ים לתוך rip - וקפץ לכתובת 0x4141414141414141, שאינה ממופה, ומכאן ה-SIGSEGV.
כמה בתים היה צריך? local הוא בגודל 32, ומעליו יושבים 8 בתים של rbp שמור ואז 8 בתים של הreturn address. כלומר, בערך 40 בתים ממלאים עד הreturn address, והבתים ב-offset 40..47 הם אלה שנוחתים ב-rip. את ה-offset המדויק נמצא בפרק 2 בצורה מסודרת עם cyclic, במקום לנחש.
למה זה עבד / איך להכליל: ראינו שליטה מלאה ב-rip דרך buffer overflow - בדיוק כפי שה-ret "בוטח" בראש המחסנית. זו התמונה השלמה של תרגיל 4 בפעולה: מי ששולט בראש המחסנית ברגע ה-ret שולט בזרימת התוכנית. בפרק הבא נפסיק לגרום לקריסות ונתחיל להפנות את השליטה הזו לכתובות שאנחנו בוחרים.