לדלג לתוכן

0.3 חזרה ממוקדת על הבסיס הרצאה

הפרק הזה הוא צומת. עד עכשיו, בקורס הליבה, למדנו איך המחשב עובד מבפנים. מכאן והלאה נתחיל לשבור אותו. לפני שנצלול לחולשה הראשונה, בואו נעצור לרגע ונרענן את אבני היסוד שילוו אותנו לאורך כל הקורס: מפת הזיכרון של תהליך, המחסנית ומוסכמות הקריאה, מה בדיוק call ו-ret עושים, מבנה קובץ ELF, המנגנון של GOT ו-PLT, וקידוד little-endian. את רוב הדברים האלה אתם כבר מכירים, אבל הפעם נסתכל עליהם דרך העיניים של תוקף: לא רק "איך זה עובד" אלא "איפה זה נשבר, ומה זה נותן לי".

אני ממליץ בחום לפתוח טרמינל ולהריץ כל פקודה שנראה תוך כדי קריאה. הבסיס הזה חייב להיות בקצות האצבעות שלכם - נחזור אליו בכל אתגר.


מפת הזיכרון של תהליך - process memory map

כשמריצים תוכנית, מערכת ההפעלה יוצרת עבורה מרחב כתובות וירטואלי משלה. כל כתובת שהתוכנית רואה היא וירטואלית, וה-MMU מתרגם אותה לכתובת פיזית מאחורי הקלעים. שני תהליכים יכולים להשתמש באותה כתובת וירטואלית והיא תצביע על זיכרון פיזי שונה לגמרי.

המרחב הזה מחולק לאזורים (mappings), ולכל אזור יש הרשאות: קריאה (r), כתיבה (w), הרצה (x). הדרך הכי מהירה לראות את המפה של תהליך חי היא הקובץ הווירטואלי /proc/<pid>/maps:

# run some binary and leave it stuck waiting on input
./vuln &
cat /proc/$!/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):

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 עושה שני דברים בפעולה אחת:

  1. דוחף למחסנית את כתובת ההוראה הבאה (הכתובת שאליה נרצה לחזור). זה מקטין את rsp ב-8.
  2. קופץ ל-target (מעדכן את rip).

מבחינה קונספטואלית:

call target   ==   push (address of the next instruction)
                   jmp  target

ההוראה ret עושה בדיוק את ההפך:

  1. שולף (pop) מראש המחסנית ערך אחד לתוך rip. זה מגדיל את rsp ב-8.
  2. הביצוע ממשיך מהכתובת ששלף.
ret   ==   pop rip

וכאן הטריק ששווה זהב: 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. את הקטעים רואים עם:
readelf -S ./vuln        # list all sections
  • מקטעים - segments (נקראים גם program headers): החלוקה שהטוען (loader) משתמש בה בזמן ריצה כדי למפות את הקובץ לזיכרון. כל מקטע PT_LOAD הופך למיפוי אחד או יותר ב-/proc/pid/maps. את המקטעים רואים עם:
readelf -l ./vuln        # program headers + section->segment mapping

ההבחנה בקצרה: 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:

checksec --file=./vuln

פלט לדוגמה:

RELRO           STACK CANARY      NX            PIE
Full RELRO      Canary found      NX enabled    PIE enabled

או ידנית: אם 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 זזים.