7.6 מבוא לניצול חולשות הרצאה
הנדסה הפוכה לא עוסקת רק בהבנת קוד - היא גם הבסיס לגילוי וניצול חולשות אבטחה. בהרצאה הזו נלמד על חולשות נפוצות בתוכנות, איך הן נוצרות, ואיך מנגנוני הגנה מודרניים מנסים למנוע ניצול שלהן.
חשוב להדגיש: הידע הזה הוא הגנתי. אנחנו צריכים להבין איך התקפות עובדות כדי לבנות הגנות טובות יותר. כדי להגן על מערכת, צריך להבין מה תוקף יכול לעשות.
המחסנית וגלישת חוצץ - stack buffer overflow¶
תזכורת: מבנה המחסנית - stack layout¶
כמו שלמדנו בפרקים הקודמים, כשפונקציה נקראת, נוצר stack frame חדש. בואו ניזכר במבנה:
כתובות גבוהות (תחתית המחסנית)
+---------------------------+
| פרמטרים לפונקציה | (במערכת 32 ביט, ב-64 ביט הם באוגרים)
+---------------------------+
| כתובת חזרה - return addr | <-- הכתובת שאליה נחזור אחרי ret
+---------------------------+
| rbp שמור - saved rbp | <-- ה-rbp של הפונקציה הקוראת
+---------------------------+
| משתנים מקומיים |
| ... |
| char buf[64] | <-- חוצץ על המחסנית
+---------------------------+
כתובות נמוכות (ראש המחסנית)
שימו לב לסדר: החוצץ (buffer) נמצא מתחת לכתובת החזרה. כשכותבים לחוצץ, כותבים מכתובות נמוכות כלפי מעלה - כלומר, לכיוון כתובת החזרה.
גלישת חוצץ קלאסית - classic buffer overflow¶
בואו נראה דוגמה פשוטה:
#include <stdio.h>
void vulnerable() {
char buf[64];
gets(buf); // לעולם אל תשתמשו ב-gets()!
}
int main() {
vulnerable();
printf("Returned safely\n");
return 0;
}
הפונקציה gets קוראת קלט מהמשתמש ושומרת אותו ב-buf. הבעיה: gets לא בודקת כמה בתים נכנסים. אם המשתמש מקליד יותר מ-64 תווים, הבתים הנוספים ידרסו את מה שנמצא מעל buf על המחסנית.
מה יקרה אם נקליד 80 תווים?
+---------------------------+
| כתובת חזרה - return addr | <-- נדרס!
+---------------------------+
| saved rbp | <-- נדרס!
+---------------------------+
| buf[64..79] | <-- גלישה! 16 בתים מעבר לחוצץ
+---------------------------+
| buf[0..63] | <-- 64 בתים ראשונים - בסדר
+---------------------------+
16 הבתים הנוספים גלשו מעבר ל-buf ודרסו את saved rbp ואת כתובת החזרה. כשהפונקציה מבצעת ret, היא קופצת לכתובת שנדרסה - לא לכתובת המקורית. התוצאה: קריסה (Segmentation fault), או גרוע מכך - אם התוקף שולט בערך שנכתב, הוא שולט לאן הביצוע קופץ.
שליטה בכתובת החזרה¶
אם התוקף יודע בדיוק כמה בתים צריך לכתוב עד כתובת החזרה (ה-offset), הוא יכול לכתוב כתובת ספציפית במקום כתובת החזרה. ברגע שהפונקציה חוזרת, הביצוע קופץ לכתובת שהתוקף בחר.
לדוגמה, אם offset הוא 72 בתים (64 בתים buf + 8 בתים saved rbp):
הפקודה הזו כותבת 72 תווים 'A' (כדי למלא את buf ולדרוס את saved rbp), ואז כותבת את הכתובת 0x401000 ככתובת החזרה. כשהפונקציה חוזרת, היא תקפוץ ל-0x401000.
הגנה: קנרי על המחסנית - Stack Canary¶
מנגנון ההגנה הראשון שניתקל בו הוא stack canary (נקרא גם stack protector או stack cookie).
איך זה עובד?¶
הקומפיילר מוסיף ערך אקראי (הקנרי) בין החוצץ לבין כתובת החזרה:
+---------------------------+
| כתובת חזרה - return addr |
+---------------------------+
| saved rbp |
+---------------------------+
| **CANARY** (ערך אקראי) | <-- ערך שנבדק לפני ret
+---------------------------+
| buf[64] |
+---------------------------+
לפני שהפונקציה מבצעת ret, היא בודקת אם הקנרי השתנה. אם כן - התוכנית מפסיקה מיד (abort). גלישת חוצץ שדורסת את כתובת החזרה חייבת לעבור דרך הקנרי, ולכן תתפס.
הפעלה¶
הקומפיילר gcc מפעיל canary אוטומטית עם:
הדגל -fstack-protector-strong מפעיל הגנה על פונקציות שמשתמשות בחוצצים.
חולשה של הקנרי¶
הקנרי בדרך כלל מתחיל בבית null (\x00). למה? כי רוב פונקציות המחרוזות (כמו strcpy, gets) עוצרות בבית null. אז אם התוקף מנסה לקרוא את הקנרי על ידי גלישה, הוא ייתקע ב-null byte.
אבל הקנרי לא מגן מפני כל חולשה - למשל, הוא לא עוזר נגד חולשות שמאפשרות כתיבה מדויקת לכתובת ספציפית (arbitrary write) בלי לדרוס את הקנרי.
הגנה: NX / DEP - ללא הרצה¶
בעבר, התוקפים היו כותבים קוד מכונה (shellcode) ישירות על המחסנית, ואז גורמים לתוכנית לקפוץ לשם ולהריץ אותו.
מנגנון NX (No eXecute) או DEP (Data Execution Prevention) מסמן את אזורי המחסנית וה-heap כלא ניתנים להרצה. כלומר, גם אם התוקף כותב קוד על המחסנית, המעבד יסרב להריץ אותו.
מפת זיכרון עם NX:
+------------------+--------+
| אזור | הרשאות |
+------------------+--------+
| קוד (.text) | r-x | <-- קריאה + הרצה (בלי כתיבה)
| נתונים (.data) | rw- | <-- קריאה + כתיבה (בלי הרצה)
| מחסנית (stack) | rw- | <-- קריאה + כתיבה (בלי הרצה)
| ערמה (heap) | rw- | <-- קריאה + כתיבה (בלי הרצה)
+------------------+--------+
אפשר לראות את ההרשאות עם:
אם הפלט מראה RW (בלי E) - זה אומר שה-NX פעיל והמחסנית לא ניתנת להרצה.
הגנה: ASLR - הקצאת כתובות אקראית¶
ASLR (Address Space Layout Randomization) מערבבת את הכתובות של:
- המחסנית
- ה-heap
- ספריות משותפות (libc וכו')
- ולפעמים גם הקוד עצמו (כש-PIE מופעל)
כל פעם שמריצים את התוכנית, הכתובות שונות. התוקף לא יכול לדעת מראש באיזו כתובת נמצא הקוד שהוא רוצה לקפוץ אליו.
אפשר לבדוק אם ASLR פעיל:
-
0 = כבוי-
1 = חלקי-
2 = מלא
לצורך לימוד, אפשר לכבות את ASLR זמנית:
חולשה של ASLR¶
אם התוקף מצליח לדלוף כתובת מהזיכרון (information leak), הוא יכול לחשב את שאר הכתובות ולעקוף את ASLR. למשל, אם יש חולשת format string שמדליפה כתובת מה-stack, התוקף יכול לחשב את הבסיס של libc ולהשתמש בפונקציות שלה.
תכנות מונחה חזרה - ROP - Return-Oriented Programming¶
אם NX מונע הרצת קוד על המחסנית, ו-ASLR מקשה על מציאת כתובות - מה התוקפים עושים?
התשובה: ROP. במקום להזריק קוד חדש, התוקף משתמש בקטעי קוד שכבר קיימים בתוכנית או בספריות.
גאדג'טים - gadgets¶
גאדג'ט הוא רצף קצר של הוראות אסמבלי שמסתיים ב-ret. למשל:
או:
איך ROP עובד?¶
התוקף בונה מחסנית מזויפת - שרשרת של כתובות שכל אחת מצביעה על גאדג'ט. כשהפונקציה הפגיעה מבצעת ret:
- ה-
retשולף את הכתובת הראשונה מהמחסנית - זה גאדג'ט 1 - גאדג'ט 1 מבצע פעולה כלשהי ואז
ret - ה-
retשל גאדג'ט 1 שולף את הכתובת הבאה - זה גאדג'ט 2 - וכך הלאה...
מחסנית מזויפת:
+----------------------------+
| כתובת גאדג'ט 3 |
+----------------------------+
| ערך לגאדג'ט 2 |
+----------------------------+
| כתובת גאדג'ט 2 |
+----------------------------+
| ערך לגאדג'ט 1 (נכנס ל-rdi) |
+----------------------------+
| כתובת גאדג'ט 1 | <-- ret קופץ לכאן
+----------------------------+
על ידי שרשור גאדג'טים, התוקף יכול לבצע כל פעולה - למשל לקרוא ל-system("/bin/sh") כדי לפתוח shell.
כלים למציאת גאדג'טים¶
הכלים האלו סורקים את הבינארי ומוצאים את כל הגאדג'טים האפשריים.
ROP הוא נושא מתקדם ולא ניכנס ליישום מלא שלו כאן, אבל חשוב להבין את העיקרון - הוא מראה למה NX לבד לא מספיק.
חולשות format string¶
עוד חולשה קלאסית ומעניינת:
מה ההבדל? בשורה הראשונה, המשתמש שולט במחרוזת הפורמט. אם הוא מקליד %x %x %x, הוא יכול לקרוא ערכים מהמחסנית. אם הוא משתמש ב-%n, הוא יכול לכתוב לזיכרון!
קריאת הזיכרון¶
כל %x קורא את ה-"ארגומנט" הבא מהמחסנית. אבל אין ארגומנטים אמיתיים - אז printf קוראת מה שיש על המחסנית. התוקף רואה ערכים מהזיכרון.
כתיבה לזיכרון עם %n¶
הספציפייר %n כותב את מספר התווים שהודפסו עד כה לכתובת שנמצאת על המחסנית. עם מספיק שליטה, התוקף יכול לכתוב לכל כתובת בזיכרון.
קומפיילרים מודרניים מזהירים על זה:
חולשות ב-heap - ערמה¶
נושא מתקדם, אבל חשוב להכיר את העקרונות:
שימוש לאחר שחרור - Use-After-Free (UAF)¶
char *ptr = malloc(64);
// ... שימוש ב-ptr ...
free(ptr);
// ... קוד נוסף ...
ptr[0] = 'A'; // באג! ptr כבר שוחרר!
אחרי free, הזיכרון חוזר למנהל ה-heap ויכול להיות מוקצה מחדש למשהו אחר. כתיבה ל-ptr אחרי free דורסת נתונים שלא שייכים לנו.
שחרור כפול - Double Free¶
זה גורם לשחיתות במבני הנתונים הפנימיים של ה-heap allocator, ויכול לאפשר לתוקף לשלוט בהקצאות עתידיות.
גלישת חוצץ בערמה - Heap Overflow¶
דומה לstack overflow, אבל ב-heap. דורס את ה-metadata של ה-heap allocator או מידע שנמצא ב-chunk הבא.
חולשות heap הן מורכבות יותר לניצול מ-stack overflow, אבל הן נפוצות מאוד בתוכנות אמיתיות (דפדפנים, שרתים, קרנלים).
סיכום מנגנוני הגנה מודרניים¶
| מנגנון הגנה | מה הוא עושה | נגד מה הוא מגן |
|---|---|---|
| קנרי על המחסנית - Stack Canary | ערך אקראי בין חוצץ לכתובת חזרה | גלישת חוצץ במחסנית |
| ללא הרצה - NX/DEP | מחסנית וheap לא ניתנים להרצה | הזרקת shellcode |
| כתובות אקראיות - ASLR | כתובות זיכרון אקראיות בכל הרצה | ניצול עם כתובות קבועות |
| קוד בלתי תלוי מיקום - PIE | גם הקוד עצמו בכתובת אקראית | ניצול עם כתובות קוד קבועות |
| טבלת קישור מוגנת - RELRO | מגן על GOT/PLT מכתיבה | דריסת כתובות בGOT |
| חיזוק מקור - FORTIFY_SOURCE | בודק גבולות בפונקציות מסוכנות | גלישות בפונקציות כמו strcpy |
| שלמות זרימת בקרה - CFI | מוודא שקפיצות הולכות ליעדים חוקיים | ROP ו-JOP |
הכלי checksec¶
הכלי checksec בודק אילו מנגנוני הגנה פעילים בקובץ בינארי:
פלט לדוגמה:
הכלי fuzzing - מציאת קריסות אוטומטית¶
במקום לחפש חולשות ידנית, אפשר להשתמש בfuzzer - כלי שמייצר קלטים אקראיים ומריץ את התוכנית עם כל אחד מהם, ומחפש קריסות.
עקרון הפעולה¶
- ה-fuzzer מייצר קלט אקראי (או מוטציה של קלט קיים)
- מריץ את התוכנית עם הקלט
- בודק אם הייתה קריסה
- אם כן - שומר את הקלט שגרם לקריסה
- חוזר לשלב 1
כלי fuzzing נפוצים¶
- AFL (American Fuzzy Lop) - אחד ה-fuzzer-ים הנפוצים ביותר
- libFuzzer - fuzzer שמשולב בקומפיילר clang
- honggfuzz - fuzzer של Google
תהליך העבודה הוא:
1. הfuzzer מוצא קריסה
2. אנחנו מנתחים את הקריסה - למה היא קורה?
3. האם אפשר לנצל אותה? או שזה פשוט באג שצריך לתקן?
4. בכל מקרה - מתקנים את הבאג
סיכום¶
בהרצאה הזו למדנו על:
- גלישת חוצץ במחסנית - החולשה הקלאסית ביותר
- מנגנוני הגנה: Stack Canary, NX, ASLR, ועוד
- ROP - טכניקה לעקיפת NX
- חולשות format string - כשהמשתמש שולט בפורמט
- חולשות heap - UAF, double free, heap overflow
- checksec - כלי לבדיקת הגנות
- fuzzing - מציאת באגים אוטומטית
כל מנגנון הגנה נועד לעצור סוג מסוים של התקפה, אבל אין מנגנון אחד שעוצר הכל. לכן משתמשים בכולם יחד - הגנה בעומק - defense in depth.
הדבר הכי חשוב: כתיבת קוד בטוח מלכתחילה. אל תשתמשו ב-gets, תמיד בדקו גבולות, שחררו זיכרון נכון, ואל תתנו למשתמש לשלוט במחרוזת הפורמט.