לדלג לתוכן

7.6 - מבוא לניצול חולשות - הרצאה

הנדסה הפוכה לא עוסקת רק בהבנת קוד - היא גם הבסיס לגילוי וניצול חולשות אבטחה. בהרצאה הזו נלמד על חולשות נפוצות בתוכנות, איך הן נוצרות, ואיך מנגנוני הגנה מודרניים מנסים למנוע ניצול שלהן.

חשוב להדגיש: הידע הזה הוא הגנתי. אנחנו צריכים להבין איך התקפות עובדות כדי לבנות הגנות טובות יותר. כדי להגן על מערכת, צריך להבין מה תוקף יכול לעשות.


המחסנית וגלישת buffer - stack buffer overflow

תזכורת: מבנה המחסנית - stack layout

כמו שלמדנו בפרקים הקודמים, כשפונקציה נקראת, נוצר stack frame חדש. בואו ניזכר במבנה:

High addresses (bottom of the stack)
+---------------------------+
| function parameters       |  (on 32-bit systems; on 64-bit they are in registers)
+---------------------------+
| return address             |  <-- the address we return to after ret
+---------------------------+
| saved rbp                  |  <-- the caller's rbp
+---------------------------+
| local variables            |
| ...                       |
| char buf[64]              |  <-- buffer on the stack
+---------------------------+
Low addresses (top of the stack)

שימו לב לסדר: הbuffer נמצא מתחת לכתובת החזרה. כשכותבים לbuffer, כותבים מכתובות נמוכות כלפי מעלה - כלומר, לכיוון כתובת החזרה.

גלישת buffer קלאסית - classic buffer overflow

בואו נראה דוגמה פשוטה:

#include <stdio.h>

void vulnerable() {
    char buf[64];
    gets(buf);  // never use gets()!
}

int main() {
    vulnerable();
    printf("Returned safely\n");
    return 0;
}

הפונקציה gets קוראת קלט מהמשתמש ושומרת אותו ב-buf. הבעיה: gets לא בודקת כמה בתים נכנסים. אם המשתמש מקליד יותר מ-64 תווים, הבתים הנוספים ידרסו את מה שנמצא מעל buf על המחסנית.

מה יקרה אם נקליד 80 תווים?

+---------------------------+
| return address             |  <-- overwritten!
+---------------------------+
| saved rbp                 |  <-- overwritten!
+---------------------------+
| buf[64..79]               |  <-- overflow! 16 bytes past the buffer
+---------------------------+
| buf[0..63]                |  <-- first 64 bytes - fine
+---------------------------+

16 הבתים הנוספים גלשו מעבר ל-buf ודרסו את saved rbp ואת כתובת החזרה. כשהפונקציה מבצעת ret, היא קופצת לכתובת שנדרסה - לא לכתובת המקורית. התוצאה: קריסה (Segmentation fault), או גרוע מכך - אם התוקף שולט בערך שנכתב, הוא שולט לאן הביצוע קופץ.

שליטה בכתובת החזרה

אם התוקף יודע בדיוק כמה בתים צריך לכתוב עד כתובת החזרה (ה-offset), הוא יכול לכתוב כתובת ספציפית במקום כתובת החזרה. ברגע שהפונקציה חוזרת, הביצוע קופץ לכתובת שהתוקף בחר.

לדוגמה, אם offset הוא 72 בתים (64 בתים buf + 8 בתים saved rbp):

python3 -c "print('A' * 72 + '\x00\x10\x40\x00\x00\x00\x00\x00')" | ./vulnerable

הפקודה הזו כותבת 72 תווים 'A' (כדי למלא את buf ולדרוס את saved rbp), ואז כותבת את הכתובת 0x401000 ככתובת החזרה. כשהפונקציה חוזרת, היא תקפוץ ל-0x401000.


הגנה: קנרי על המחסנית - Stack Canary

מנגנון ההגנה הראשון שניתקל בו הוא stack canary (נקרא גם stack protector או stack cookie).

איך זה עובד?

הקומפיילר מוסיף ערך אקראי (הקנרי) בין הbuffer לבין כתובת החזרה:

+---------------------------+
| return address             |
+---------------------------+
| saved rbp                 |
+---------------------------+
| **CANARY** (random value)  |  <-- value checked before ret
+---------------------------+
| buf[64]                   |
+---------------------------+

לפני שהפונקציה מבצעת ret, היא בודקת אם הקנרי השתנה. אם כן - התוכנית מפסיקה מיד (abort). גלישת buffer שדורסת את כתובת החזרה חייבת לעבור דרך הקנרי, ולכן תתפס.

הפעלה

הקומפיילר gcc מפעיל canary אוטומטית עם:

gcc -fstack-protector-strong -o program program.c

הדגל -fstack-protector-strong מפעיל הגנה על פונקציות שמשתמשות בbuffers.

חולשה של הקנרי

הקנרי בדרך כלל מתחיל בבית null (\x00). למה? כי רוב פונקציות המחרוזות (כמו strcpy, gets) עוצרות בבית null. אז אם התוקף מנסה לקרוא את הקנרי על ידי גלישה, הוא ייתקע ב-null byte.

אבל הקנרי לא מגן מפני כל חולשה - למשל, הוא לא עוזר נגד חולשות שמאפשרות כתיבה מדויקת לכתובת ספציפית (arbitrary write) בלי לדרוס את הקנרי.


הגנה: NX / DEP - ללא הרצה

בעבר, התוקפים היו כותבים קוד מכונה (shellcode) ישירות על המחסנית, ואז גורמים לתוכנית לקפוץ לשם ולהריץ אותו.

מנגנון NX (No eXecute) או DEP (Data Execution Prevention) מסמן את אזורי המחסנית וה-heap כלא ניתנים להרצה. כלומר, גם אם התוקף כותב קוד על המחסנית, המעבד יסרב להריץ אותו.

Memory map with NX:
+------------------+--------+
| Region           | Perms  |
+------------------+--------+
| Code (.text)     | r-x    |  <-- read + execute (no write)
| Data (.data)     | rw-    |  <-- read + write (no execute)
| Stack            | rw-    |  <-- read + write (no execute)
| Heap             | rw-    |  <-- read + write (no execute)
+------------------+--------+

אפשר לראות את ההרשאות עם:

readelf -l program | grep GNU_STACK

אם הפלט מראה RW (בלי E) - זה אומר שה-NX פעיל והמחסנית לא ניתנת להרצה.


הגנה: ASLR - הקצאת כתובות אקראית

ASLR (Address Space Layout Randomization) מערבבת את הכתובות של:
- המחסנית
- ה-heap
- ספריות משותפות (libc וכו')
- ולפעמים גם הקוד עצמו (כש-PIE מופעל)

כל פעם שמריצים את התוכנית, הכתובות שונות. התוקף לא יכול לדעת מראש באיזו כתובת נמצא הקוד שהוא רוצה לקפוץ אליו.

אפשר לבדוק אם ASLR פעיל:

cat /proc/sys/kernel/randomize_va_space

- 0 = כבוי
- 1 = חלקי
- 2 = מלא

לצורך לימוד, אפשר לכבות את ASLR זמנית:

echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

חולשה של ASLR

אם התוקף מצליח לדלוף כתובת מהזיכרון (information leak), הוא יכול לחשב את שאר הכתובות ולעקוף את ASLR. למשל, אם יש חולשת format string שמדליפה כתובת מה-stack, התוקף יכול לחשב את הבסיס של libc ולהשתמש בפונקציות שלה.


תכנות מונחה חזרה - ROP - Return-Oriented Programming

אם NX מונע הרצת קוד על המחסנית, ו-ASLR מקשה על מציאת כתובות - מה התוקפים עושים?

התשובה: ROP. במקום להזריק קוד חדש, התוקף משתמש בקטעי קוד שכבר קיימים בתוכנית או בספריות.

גאדג'טים - gadgets

גאדג'ט הוא רצף קצר של הוראות אסמבלי שמסתיים ב-ret. למשל:

pop rdi      ; pops a value from the stack into rdi
ret          ; jumps to the next address on the stack

או:

pop rax      ; pops a value into rax
ret

איך ROP עובד?

התוקף בונה מחסנית מזויפת - שרשרת של כתובות שכל אחת מצביעה על גאדג'ט. כשהפונקציה הפגיעה מבצעת ret:

  1. ה-ret שולף את הכתובת הראשונה מהמחסנית - זה גאדג'ט 1
  2. גאדג'ט 1 מבצע פעולה כלשהי ואז ret
  3. ה-ret של גאדג'ט 1 שולף את הכתובת הבאה - זה גאדג'ט 2
  4. וכך הלאה...
Fake stack:
+----------------------------+
| address of gadget 3         |
+----------------------------+
| value for gadget 2          |
+----------------------------+
| address of gadget 2         |
+----------------------------+
| value for gadget 1 (goes into rdi) |
+----------------------------+
| address of gadget 1         |  <-- ret jumps here
+----------------------------+

על ידי שרשור גאדג'טים, התוקף יכול לבצע כל פעולה - למשל לקרוא ל-system("/bin/sh") כדי לפתוח shell.

כלים למציאת גאדג'טים

ROPgadget --binary ./program
ropper --file ./program

הכלים האלו סורקים את הבינארי ומוצאים את כל הגאדג'טים האפשריים.

ROP הוא נושא מתקדם ולא ניכנס ליישום מלא שלו כאן, אבל חשוב להבין את העיקרון - הוא מראה למה NX לבד לא מספיק.


חולשות format string

עוד חולשה קלאסית ומעניינת:

// dangerous!
printf(user_input);

// safe!
printf("%s", user_input);

מה ההבדל? בשורה הראשונה, המשתמש שולט במחרוזת הפורמט. אם הוא מקליד %x %x %x, הוא יכול לקרוא ערכים מהמחסנית. אם הוא משתמש ב-%n, הוא יכול לכתוב לזיכרון!

קריאת הזיכרון

Input: %x.%x.%x.%x
Output: 7ffe1234.0.40123a.deadbeef

כל %x קורא את ה-"ארגומנט" הבא מהמחסנית. אבל אין ארגומנטים אמיתיים - אז printf קוראת מה שיש על המחסנית. התוקף רואה ערכים מהזיכרון.

כתיבה לזיכרון עם %n

הספציפייר %n כותב את מספר התווים שהודפסו עד כה לכתובת שנמצאת על המחסנית. עם מספיק שליטה, התוקף יכול לכתוב לכל כתובת בזיכרון.

קומפיילרים מודרניים מזהירים על זה:

gcc -Wformat -Wformat-security program.c


חולשות ב-heap - ערמה

נושא מתקדם, אבל חשוב להכיר את העקרונות:

שימוש לאחר שחרור - Use-After-Free (UAF)

char *ptr = malloc(64);
// ... using ptr ...
free(ptr);
// ... more code ...
ptr[0] = 'A';  // bug! ptr was already freed!

אחרי free, הזיכרון חוזר למנהל ה-heap ויכול להיות מוקצה מחדש למשהו אחר. כתיבה ל-ptr אחרי free דורסת נתונים שלא שייכים לנו.

שחרור כפול - Double Free

char *ptr = malloc(64);
free(ptr);
free(ptr);  // bug! double free!

זה גורם לקורפשן במבני הנתונים הפנימיים של ה-heap allocator, ויכול לאפשר לתוקף לשלוט בallocations עתידיות.

גלישת buffer בהיפ - Heap Overflow

char *buf = malloc(64);
strcpy(buf, very_long_input);  // overflows past 64 bytes!

דומה לstack overflow, אבל ב-heap. דורס את ה-metadata של ה-heap allocator או מידע שנמצא ב-chunk הבא.

חולשות heap הן מורכבות יותר לניצול מ-stack overflow, אבל הן נפוצות מאוד בתוכנות אמיתיות (דפדפנים, שרתים, קרנלים).


סיכום מנגנוני הגנה מודרניים

מנגנון הגנה מה הוא עושה נגד מה הוא מגן
קנרי על המחסנית - Stack Canary ערך אקראי בין buffer לכתובת חזרה גלישת buffer במחסנית
ללא הרצה - NX/DEP מחסנית וheap לא ניתנים להרצה הזרקת shellcode
כתובות אקראיות - ASLR כתובות זיכרון אקראיות בכל הרצה ניצול עם כתובות קבועות
קוד בלתי תלוי מיקום - PIE גם הקוד עצמו בכתובת אקראית ניצול עם כתובות קוד קבועות
טבלת קישור מוגנת - RELRO מגן על GOT/PLT מכתיבה דריסת כתובות בGOT
חיזוק מקור - FORTIFY_SOURCE בודק גבולות בפונקציות מסוכנות גלישות בפונקציות כמו strcpy
שלמות זרימת בקרה - CFI מוודא שקפיצות הולכות ליעדים חוקיים ROP ו-JOP

הכלי checksec

הכלי checksec בודק אילו מנגנוני הגנה פעילים בקובץ בינארי:

checksec --file=./program

פלט לדוגמה:

RELRO           STACK CANARY      NX            PIE
Full RELRO      Canary found      NX enabled    PIE enabled


הכלי fuzzing - מציאת קריסות אוטומטית

במקום לחפש חולשות ידנית, אפשר להשתמש בfuzzer - כלי שמייצר קלטים אקראיים ומריץ את התוכנית עם כל אחד מהם, ומחפש קריסות.

עקרון הפעולה

  1. ה-fuzzer מייצר קלט אקראי (או מוטציה של קלט קיים)
  2. מריץ את התוכנית עם הקלט
  3. בודק אם הייתה קריסה
  4. אם כן - שומר את הקלט שגרם לקריסה
  5. חוזר לשלב 1

כלי fuzzing נפוצים

  • AFL (American Fuzzy Lop) - אחד ה-fuzzer-ים הנפוצים ביותר
  • libFuzzer - fuzzer שמשולב בקומפיילר clang
  • honggfuzz - fuzzer של Google

תהליך העבודה הוא:
1. הfuzzer מוצא קריסה
2. אנחנו מנתחים את הקריסה - למה היא קורה?
3. האם אפשר לנצל אותה? או שזה פשוט באג שצריך לתקן?
4. בכל מקרה - מתקנים את הבאג


סיכום

בהרצאה הזו למדנו על:
- גלישת buffer במחסנית - החולשה הקלאסית ביותר
- מנגנוני הגנה: Stack Canary, NX, ASLR, ועוד
- ROP - טכניקה לעקיפת NX
- חולשות format string - כשהמשתמש שולט בפורמט
- חולשות heap - UAF, double free, heap overflow
- checksec - כלי לבדיקת הגנות
- fuzzing - מציאת באגים אוטומטית

כל מנגנון הגנה נועד לעצור סוג מסוים של התקפה, אבל אין מנגנון אחד שעוצר הכל. לכן משתמשים בכולם יחד - הגנה בעומק - defense in depth.

הדבר הכי חשוב: כתיבת קוד בטוח מלכתחילה. אל תשתמשו ב-gets, תמיד בדקו גבולות, שחררו זיכרון נכון, ואל תתנו למשתמש לשלוט במחרוזת הפורמט.