0.3 חזרה ממוקדת על הבסיס הרצאה
הפרק הזה הוא צומת. עד עכשיו, בקורס הליבה, למדנו איך המחשב עובד מבפנים. מכאן והלאה נתחיל לשבור אותו. לפני שנצלול לחולשה הראשונה, בואו נעצור לרגע ונרענן את אבני היסוד שילוו אותנו לאורך כל הקורס: מפת הזיכרון של תהליך, המחסנית ומוסכמות הקריאה, מה בדיוק call ו-ret עושים, מבנה קובץ ELF, המנגנון של GOT ו-PLT, וקידוד little-endian. את רוב הדברים האלה אתם כבר מכירים, אבל הפעם נסתכל עליהם דרך העיניים של תוקף: לא רק "איך זה עובד" אלא "איפה זה נשבר, ומה זה נותן לי".
אני ממליץ בחום לפתוח טרמינל ולהריץ כל פקודה שנראה תוך כדי קריאה. הבסיס הזה חייב להיות בקצות האצבעות שלכם - נחזור אליו בכל אתגר.
מפת הזיכרון של תהליך - process memory map¶
כשמריצים תוכנית, מערכת ההפעלה יוצרת עבורה מרחב כתובות וירטואלי משלה. כל כתובת שהתוכנית רואה היא וירטואלית, וה-MMU מתרגם אותה לכתובת פיזית מאחורי הקלעים. שני תהליכים יכולים להשתמש באותה כתובת וירטואלית והיא תצביע על זיכרון פיזי שונה לגמרי.
המרחב הזה מחולק לאזורים (mappings), ולכל אזור יש הרשאות: קריאה (r), כתיבה (w), הרצה (x). הדרך הכי מהירה לראות את המפה של תהליך חי היא הקובץ הווירטואלי /proc/<pid>/maps:
הנה פלט לדוגמה עבור בינארי PIE שמקושר דינמית מול glibc:
5636a1b3d000-5636a1b3e000 r--p 00000000 08:01 131074 /home/amit/vuln <- ELF header (read-only)
5636a1b3e000-5636a1b3f000 r-xp 00001000 08:01 131074 /home/amit/vuln <- .text (code, r-x)
5636a1b3f000-5636a1b40000 r--p 00002000 08:01 131074 /home/amit/vuln <- .rodata (constants)
5636a1b40000-5636a1b41000 r--p 00002000 08:01 131074 /home/amit/vuln <- RELRO (turned read-only)
5636a1b41000-5636a1b42000 rw-p 00003000 08:01 131074 /home/amit/vuln <- .data + .bss (rw)
5636a3c5f000-5636a3c80000 rw-p 00000000 00:00 0 [heap] <- the heap
7f8e4a000000-7f8e4a028000 r--p 00000000 08:01 786512 /usr/lib/libc.so.6 <- libc: header
7f8e4a028000-7f8e4a1a0000 r-xp 00028000 08:01 786512 /usr/lib/libc.so.6 <- libc: code
7f8e4a1a0000-... r--p ... ... libc.so.6 <- libc: rodata
7f8e4a1f0000-... rw-p ... ... libc.so.6 <- libc: data
7ffe1c2d0000-7ffe1c2f1000 rw-p 00000000 00:00 0 [stack] <- the stack
7ffe1c3aa000-7ffe1c3ae000 r--p 00000000 00:00 0 [vvar]
7ffe1c3ae000-7ffe1c3b0000 r-xp 00000000 00:00 0 [vdso]
שימו לב לכמה דברים שיהיו קריטיים בהמשך:
- הקוד (
.text) הואr-x- קריאה והרצה, בלי כתיבה. לכן אי אפשר סתם לכתוב על הקוד ולשנות אותו. - המחסנית והערמה הן
rw-- קריאה וכתיבה בלי הרצה. זה בדיוק ה-NX: קוד שנזריק למחסנית לא ירוץ. - הספרייה libc ממופה לתוך התהליך. כל הפונקציות שלה (
system,execve,printf) יושבות איפשהו בטווח הזה. אם נצליח לחשב את הכתובת שלהן, נוכל לקפוץ אליהן. זה בדיוק ret2libc. - הכתובות כאן נראות "אקראיות" (
5636...,7f8e...) כי ASLR פעיל. בהרצה הבאה הן יהיו שונות.
הנה תמונה מנטלית של המפה, מכתובות נמוכות למעלה לגבוהות למטה:
low addresses
+-----------------------------+
| .text (code) r-x | <- our binary
| .rodata (constants) r-- |
| .data (globals) rw- |
| .bss (zeros) rw- |
+-----------------------------+
| [heap] -> grows upward | rw-
+-----------------------------+
| ... |
| shared libraries (libc, ld) | r-x / rw-
+-----------------------------+
| ... |
| [stack] <- grows downward | rw-
+-----------------------------+
| [vdso] / [vvar] |
high addresses
הבחנה חשובה: ה-.bss הוא אזור למשתנים גלובליים שאותחלו לאפס (או לא אותחלו כלל). הוא לא תופס מקום בקובץ עצמו - הטוען פשוט ממפה עמודים מאופסים. ה-.data, לעומתו, מכיל ערכים התחלתיים שכן שמורים בקובץ.
המחסנית ומסגרת הפונקציה - stack frame¶
המחסנית גדלה כלפי מטה - כלומר, push מקטין את rsp ו-pop מגדיל אותו. לכל קריאה לפונקציה נוצרת מסגרת (stack frame) שמכילה את הreturn address, את ה-rbp השמור, ואת המשתנים המקומיים.
high addresses
+-----------------------------+
| arguments (32-bit only, |
| or argument 7 onward in 64) |
+-----------------------------+
| return address | <- ret will return here
+-----------------------------+
| saved rbp | <- rbp points here
+-----------------------------+
| local variables |
| char buf[64] | <- our buffer
+-----------------------------+ <- rsp points here (top of stack)
low addresses
הנקודה שתלווה אותנו בכל הקורס: הbuffer יושב בכתובות נמוכות יותר מreturn address. כתיבה לbuffer מתקדמת מכתובות נמוכות לגבוהות, כלומר לכיוון הreturn address. אם נכתוב יותר בתים ממה שהbuffer מחזיק, נגלוש קדימה ונדרוס את ה-rbp השמור, ואז את הreturn address. זה הלב של buffer overflow.
מוסכמות קריאה - calling conventions¶
מוסכמת קריאה היא ההסכם על איפה יושבים הארגומנטים ואיפה חוזר הערך כשקוראים לפונקציה. אנחנו חייבים לשלוט בזה בעל פה, כי כשנבנה ROP chain או קריאה ל-system נצטרך להעמיס ארגומנטים במקום הנכון.
מוסכמת ה-64 ביט - System V AMD64¶
בלינוקס 64 ביט, ששת הארגומנטים הראשונים (מספרים שלמים או מצביעים) עוברים באוגרים, לפי הסדר הזה:
argument 1 -> rdi
argument 2 -> rsi
argument 3 -> rdx
argument 4 -> rcx
argument 5 -> r8
argument 6 -> r9
argument 7 onward -> on the stack
return value -> rax
טריק זיכרון שעובד לי: "Diane's silk dress cost $89" (rDi, rSi, rDx, rCx, r8, r9).
בואו נראה את זה בקוד. הקריאה execve("/bin/sh", NULL, NULL):
מתקמפלת בערך כך:
lea rdi, [rip+bin_sh] ; argument 1: pointer to "/bin/sh"
xor esi, esi ; argument 2: NULL
xor edx, edx ; argument 3: NULL
mov eax, 59 ; syscall number of execve
syscall
שימו לב: מספר הקריאה של execve ב-64 ביט הוא 59, ונכנס ל-rax. זה מספר שתשננו.
מוסכמת ה-32 ביט - cdecl¶
ב-32 ביט הכל שונה. אין אוגרים לארגומנטים - כולם נדחפים למחסנית, בסדר הפוך (הארגומנט האחרון נדחף ראשון). זה הופך את ה-32 ביט לנוח יותר לexploit, כי הבקרה על הארגומנטים היא פשוט בקרה על המחסנית, ואת זה buffer overflow נותנת לנו בחינם.
push arg3
push arg2
push arg1
call func ; the arguments are already on the stack
; ...
; the caller cleans the stack after the return <- that's what cdecl means
return value -> eax
וקריאת מערכת ב-32 ביט: מעמיסים את מספר הקריאה ל-eax, את הארגומנטים ל-ebx, ecx, edx, esi, edi, ומפעילים int 0x80. מספר הקריאה של execve ב-32 ביט הוא 11 (לא 59!). ההבדל הזה תופס הרבה אנשים בהתחלה, אז שימו לב.
מה call ו-ret עושים¶
זו אולי הנקודה הכי חשובה בכל ההרצאה, כי כל הexploit שלנו נשען על המנגנון הזה.
ההוראה call target עושה שני דברים בפעולה אחת:
- דוחף למחסנית את כתובת ההוראה הבאה (הכתובת שאליה נרצה לחזור). זה מקטין את
rspב-8. - קופץ ל-
target(מעדכן אתrip).
מבחינה קונספטואלית:
ההוראה ret עושה בדיוק את ההפך:
- שולף (pop) מראש המחסנית ערך אחד לתוך
rip. זה מגדיל אתrspב-8. - הביצוע ממשיך מהכתובת ששלף.
וכאן הטריק ששווה זהב: ret סומך בעיוורון על הערך שנמצא בראש המחסנית. הוא לא בודק כלום. אם דרסנו את הערך הזה בbuffer overflow, אז ה-ret יקפוץ בדיוק לאן שאנחנו נכתוב. שליטה בהreturn address = שליטה ב-rip = שליטה בזרימת התוכנית. וזה גם בדיוק הרעיון מאחורי ROP: אנחנו משרשרים המון "return addresses" בזו אחר זו, וכל ret קופץ לגאדג'ט הבא.
בואו נראה מה קורה למחסנית סביב call:
before call: after call (return addr pushed):
+-------------+ +-------------------------+
| ... | <- rsp | return addr | <- rsp
+-------------+ +-------------------------+
| ... |
ואחרי ה-ret בתוך הפונקציה, rsp חוזר למעלה והערך נשלף ל-rip. בתרגול נצעד על שתי ההוראות האלה שלב-שלב ב-gdb ונראה את rsp ו-rip זזים בזמן אמת.
חזרה על ELF¶
קובץ הרצה בלינוקס הוא בפורמט ELF (Executable and Linkable Format). חשוב להבין את המבנה שלו כי ממנו נשלוף כתובות, סמלים, ומידע על ההגנות.
כותרת, מקטעים וקטעים - headers, segments, sections¶
בקובץ ELF יש שתי "נקודות מבט" על אותו קובץ:
- קטעים - sections: החלוקה הלוגית של הקובץ עבור המקשר (linker). למשל
.text(קוד),.data(נתונים),.rodata(קבועים לקריאה בלבד),.bss(אפסים),.symtab(טבלת סמלים),.plt,.got. את הקטעים רואים עם:
- מקטעים - segments (נקראים גם program headers): החלוקה שהטוען (loader) משתמש בה בזמן ריצה כדי למפות את הקובץ לזיכרון. כל מקטע
PT_LOADהופך למיפוי אחד או יותר ב-/proc/pid/maps. את המקטעים רואים עם:
ההבחנה בקצרה: sections הן לזמן קישור, segments הן לזמן טעינה. כמה sections מתקבצות יחד למקטע אחד. הפקודה readelf -l אפילו מדפיסה בתחתית איזה sections נכנסות לאיזה segment.
טבלת הסמלים - symbol table¶
טבלת הסמלים ממפה שמות (של פונקציות ומשתנים) לכתובות. יש שתי טבלאות:
- הקטע
.symtabהוא הטבלה המלאה, כולל סמלים מקומיים, והוא נמחק כשמריציםstripעל הבינארי. - הקטע
.dynsymהוא טבלת הסמלים הדינמיים, והוא נשאר תמיד, כי צריך אותו בזמן ריצה כדי לקשר פונקציות מספריות.
readelf -s ./vuln # prints symtab + dynsym
nm ./vuln # only if the binary hasn't been stripped
objdump -d ./vuln | grep '<main>:'
כשבינארי עבר strip ואין main בטבלה, נצטרך לזהות את הפונקציות בעצמנו עם דיסאסמבלר. נעשה את זה בפרק הreverse engineering.
קוד בלתי תלוי מיקום - PIE¶
בעבר, בינארי רגיל נטען תמיד לאותה כתובת בסיס קבועה (0x400000 ב-64 ביט). אם התוקף רצה לקפוץ ל-main, הכתובת הייתה ידועה מראש וקבועה.
בינארי PIE (Position Independent Executable) הוא מסוג ET_DYN - הוא נטען לכתובת בסיס אקראית בכל הרצה, בדיוק כמו ספרייה משותפת. לכן כל הכתובות הפנימיות שלו (של main, של גאדג'טים, של הטבלאות) לא ידועות מראש. כדי לתקוף בינארי PIE, קודם צריך לדלוף כתובת אחת ולחשב ממנה את בסיס הקוד.
איך יודעים אם בינארי הוא PIE? הכלי הכי נוח הוא checksec:
פלט לדוגמה:
או ידנית: אם readelf -h ./vuln מראה Type: DYN, זה PIE. אם Type: EXEC, זה בינארי עם כתובות קבועות.
טבלאות הקישור הדינמי - GOT ו-PLT¶
כשהתוכנית קוראת ל-printf, שנמצאת בתוך libc, הקוד שלנו לא יודע בזמן קומפילציה איפה printf תשב בזיכרון (libc נטענת לכתובת אקראית). כדי לפתור את זה, המקשר הדינמי משתמש בשתי טבלאות: PLT ו-GOT.
- טבלת הקישור - PLT (Procedure Linkage Table): קטע קוד קטן, קטע (stub) אחד לכל פונקציה חיצונית. הקוד שלנו קורא ל-
printf@plt, לא ישירות ל-printf. - טבלת ההיסטים - GOT (Global Offset Table): טבלה של כתובות בזיכרון הכתיב (
rw). כל כניסה מחזיקה את הכתובת האמיתית של פונקציה בספרייה.
קישור עצל - lazy binding¶
כברירת מחדל, glibc לא פותרת את כל הפונקציות בזמן הטעינה - היא פותרת כל פונקציה בפעם הראשונה שקוראים לה. הנה המנגנון:
first time calling printf:
call printf@plt
-> jmp [printf@got] ; the GOT still points back to the PLT
-> push index ; pushes the index of printf
-> jmp PLT[0] ; jumps to the resolver
-> _dl_runtime_resolve ; finds the real address of printf,
; writes it to [printf@got], then jumps to it
second time onward:
call printf@plt
-> jmp [printf@got] ; now the GOT already points directly to the real printf
בפעם הראשונה עוברים דרך ה-resolver, ומאותו רגע ה-printf@got מצביע ישירות לכתובת האמיתית. זה חוסך זמן טעינה.
למה זה מעניין אותנו כתוקפים? כי ה-GOT ניתן לכתיבה. אם יש לנו חולשת כתיבה שרירותית (למשל דרך %n בחולשת format string), נוכל לדרוס כניסה ב-GOT - למשל להחליף את הכתובת של printf בכתובת של system. מאותו רגע, כל קריאה ל-printf תקפוץ בעצם ל-system. זו טכניקת GOT overwrite, ונשתמש בה בפרק חולשות מחרוזת הformat.
הmitigation RELRO¶
ההגנה שנועדה בדיוק נגד זה נקראת RELRO (Relocation Read-Only):
- במצב Partial RELRO המקשר מסדר את ה-GOT כך שחלקים ממנו יהיו לקריאה בלבד, אבל
.got.plt(החלק של הקישור העצל) עדיין כתיב. - במצב Full RELRO הקישור העצל מכובה לגמרי (פותר הכל בזמן טעינה, מה שנקרא BIND_NOW), ואז כל ה-GOT הופך ל-קריאה בלבד. במצב הזה אי אפשר לדרוס את ה-GOT.
לכן, כשנתקל בבינארי, אחד הדברים הראשונים שנבדוק ב-checksec הוא אם ה-RELRO מלא או חלקי. זה קובע אם התקפת GOT overwrite בכלל אפשרית.
בואו נראה איפה הטבלאות יושבות בבינארי אמיתי:
objdump -d -j .plt ./vuln # disassembly of the PLT
readelf -r ./vuln # the relocations table: which GOT entry belongs to which function
הפלט של readelf -r יראה משהו כמו:
Offset Info Type Sym. Name
0000000404018 ... R_X86_64_JUMP_SLOT printf@GLIBC_2.2.5
0000000404020 ... R_X86_64_JUMP_SLOT puts@GLIBC_2.2.5
ה-Offset הוא הכתובת של כניסת ה-GOT של הפונקציה. את הכתובת הזו נצטרך כשנרצה לדלוף או לדרוס אותה.
סדר בתים - endianness¶
הארכיטקטורות x86 ו-x86-64 הן little-endian: הבית הכי פחות משמעותי נשמר בכתובת הנמוכה ביותר. זה נשמע כמו פרט טכני קטן, אבל הוא מקור נפוץ לבאגים בכתיבת exploit-ים.
נניח שאנחנו רוצים לכתוב את הכתובת 0x00000000004011a6 למחסנית. בזיכרון היא תיושב כך:
address: +0 +1 +2 +3 +4 +5 +6 +7
byte: a6 11 40 00 00 00 00 00
^^ the low byte first (little-endian)
כלומר, סדרת הבתים שנשלח היא \xa6\x11\x40\x00\x00\x00\x00\x00. אם נכתוב אותה הפוך, נקפוץ לכתובת שגויה לגמרי והתוכנית תקרוס.
לשמחתנו, אנחנו כמעט אף פעם לא נעשה את זה ידנית. הספרייה pwntools עושה את זה בשבילנו:
from pwn import *
addr = 0x4011a6
packed = p64(addr) # b'\xa6\x11@\x00\x00\x00\x00\x00' - 8 bytes, little-endian
print(packed)
# and in the reverse direction, from a leak:
leaked = b'\xa6\x11@\x00\x00\x00\x00\x00'
addr = u64(leaked) # 0x4011a6
# for 32-bit:
p32(0x8048456) # b'\x56\x84\x04\x08'
u32(b'\x56\x84\x04\x08') # 0x8048456
הכלל: p64/p32 להמרת מספר לבתים לפני שליחה, u64/u32 להמרת בתים למספר אחרי leak. הרבה מהזמן שנבזבז על תיקון exploit-ים שלא עובדים ייעלם ברגע שנטמיע את זה. וטיפ קטן: אם דלפתם 6 בתים בלבד (כתובות לינוקס בפועל הן 48 ביט), רפדו לפני u64, למשל u64(leaked.ljust(8, b'\x00')).
סיכום¶
- מפת הזיכרון של תהליך מחולקת לאזורים עם הרשאות (
r/w/x). רואים אותה חיה ב-/proc/pid/maps. הקוד הואr-x, המחסנית והערמה הןrw-(זה ה-NX), ו-libc ממופה פנימה - שם יושבות הפונקציות שנרצה לקרוא להן. - המחסנית גדלה כלפי מטה, והbuffer יושב מתחת להreturn address. הoverflow קדימה דורסת את הreturn address.
- מוסכמות קריאה: ב-64 ביט הארגומנטים ב-
rdi, rsi, rdx, rcx, r8, r9והחזרה ב-rax; ב-32 ביט הארגומנטים על המחסנית והחזרה ב-eax.execveהוא syscall 59 ב-64 ביט ו-11 ב-32 ביט. - ההוראה
callדוחפת return address וקופצת; ההוראהretשולפת כתובת מראש המחסנית ל-ripבלי לבדוק כלום. שליטה בהreturn address = שליטה בזרימת התוכנית, וזה הבסיס לכל ROP. - בפורמט ELF: ה-sections הן לזמן קישור, וה-segments הן לזמן טעינה. ה-
.symtabנמחק ב-strip, וה-.dynsymנשאר. בינארי PIE נטען לכתובת בסיס אקראית, ולכן דורש leak של כתובת לפני תקיפה. - המנגנון של PLT/GOT וקישור עצל: קריאות לספריות עוברות דרך PLT, שקורא כתובת מ-GOT הכתיב. דריסת GOT מאפשרת חטיפת זרימה - ו-Full RELRO סוגר את זה.
- הקידוד little-endian: כתובות נארזות עם הבית הנמוך ראשון. תמיד השתמשו ב-
p64/u64של pwntools ותחסכו לעצמכם באגים.
בתרגול נלכלך את הידיים: נסתכל על המפה של תהליך חי, נמצא את ה-GOT וה-PLT עם readelf ו-objdump, ונצעד ב-gdb על call ו-ret בזמן שאנחנו צופים ב-rsp וב-rip זזים.