לדלג לתוכן

10.3 Fuzzing עם libFuzzer וsanitizers הרצאה

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

פאזינג בתוך התהליך - in-process fuzzing

ההבדל המהותי מ-AFL הקלאסי הוא איפה רץ הקוד הנבדק. ב-AFL כל קלט מורץ בתהליך נפרד (fork), וזה עולה זמן. libFuzzer עובד אחרת: הוא קורא לפונקציה שלכם שוב ושוב, מיליוני פעמים, באותו תהליך, בלולאה הדוקה.

Fuzzing outside the process (classic AFL)   Fuzzing inside the process (libFuzzer)
+----------------------------+          +----------------------------+
| fork -> exec -> input 1    |          | libFuzzer's main           |
| fork -> exec -> input 2    |          |   while(1):                |
| fork -> exec -> input 3    |          |     input = mutate(corpus) |
|   ...each input = new process |       |     LLVMFuzzerTestOneInput |
+----------------------------+          |   ...all in one memory     |
   safely isolated, but slow           +----------------------------+
                                           very fast, but shares state

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

הכלי libFuzzer הוא fuzzer מונחה-כיסוי (coverage-guided), בדיוק כמו AFL: הוא מודד אילו קטעי קוד כל קלט מפעיל, ומעדיף קלטים שפותחים מסלולים חדשים. ההנחיה (instrumentation) הזו נכנסת אוטומטית עם דגל אחד בזמן ההידור.

נקודת הכניסה - LLVMFuzzerTestOneInput

כל מטרת libFuzzer מתחילה מפונקציה אחת שאתם כותבים, ה-harness. החתימה קבועה:

#include <stdint.h>
#include <stddef.h>

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    // call the code you want to test here, with data of length size
    return 0;   // always return 0. Other values are reserved for the future
}

הפונקציה מקבלת מצביע לbuffer בתים (data) ואת אורכו (size), ואמורה להעביר אותם לקוד שאתם בודקים. libFuzzer ידאג להזרים לכאן קלטים ממוטַציה. שימו לב לכמה כללים חשובים:

  • הפונקציה חייבת להחזיר 0. כל ערך אחר שמור לשימוש עתידי של libFuzzer, אז אל תשתמשו בערך ההחזרה כדי לסמן שגיאה.
  • אסור לגעת בזיכרון מעבר ל-size. הbuffer data הוא בדיוק באורך size, וב-ASan הבתים שאחריו מורעלים. קריאה של data[size] היא כשלעצמה overflow שהסניטייזר יתפוס - וזה יבלבל אתכם עם באג של המטרה.
  • שאיפה לחוסר מצב: אם המטרה שומרת מצב גלובלי, אתחלו אותו בתחילת ה-harness או נקו בסוף, אחרת קריסות יהיו לא-דטרמיניסטיות.

אם צריך אתחול חד-פעמי (למשל לטעון קובץ תצורה פעם אחת), יש נקודת כניסה אופציונלית שרצה פעם אחת לפני הלולאה:

extern "C" int LLVMFuzzerInitialize(int *argc, char ***argv) {
    // one-time initialization: this runs only once, before the loop
    return 0;
}

בנייה עם libFuzzer

הקסם כולו בדגל אחד ל-clang: -fsanitize=fuzzer. הדגל הזה עושה שני דברים - הוא מוסיף את ההנחיה למדידת כיסוי, והוא מקשר פנימה את ה-main של libFuzzer (זה שמריץ את הלולאה). כמעט תמיד נצרף אליו סניטייזר, מופרד בפסיק:

clang -g -O1 -fsanitize=fuzzer,address -o fuzz_target harness.c lib.c

בואו נפרק את הדגלים:

  • הדגל -g שומר סמלי ניפוי (debug info), כדי שדוח הקריסה יראה שמות פונקציות ומספרי שורות ולא רק כתובות.
  • הדגל -O1 נותן אופטימיזציה קלה. מהדרים בלי אופטימיזציה איטיים בפאזינג; אבל -O2 ומעלה עלול לקפל (inline) פונקציות ולטשטש את הדוח, אז -O1 הוא איזון נפוץ.
  • הדגל -fsanitize=fuzzer,address מפעיל את libFuzzer ואת AddressSanitizer יחד.

אם אתם בונים ספרייה גדולה ורק חלק ממנה הוא ה-harness, יש וריאנט חשוב: -fsanitize=fuzzer-no-link. הוא מוסיף את הנחיית הכיסוי אבל לא מקשר את ה-main. מהדרים איתו את כל קבצי הספרייה, ואת ה-main של libFuzzer מקשרים רק פעם אחת בשלב הקישור. זה גם הדגל שמאפשר לקחת את אותו harness ולהריץ אותו תחת AFL++ במקום, כמו שנראה בהמשך.

הרצה ומה שרואים

מריצים את הבינארי שנוצר. אפשר בלי ארגומנטים (libFuzzer ייצר קלטים מאפס), אבל מומלץ לתת תיקיית קורפוס - אוסף קלטי-זרע (seeds) שנותנים ל-fuzzer נקודת פתיחה טובה:

mkdir corpus
./fuzz_target corpus/

הפלט נראה כך:

INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 2748109034
INFO: Loaded 1 modules   (14 inline 8-bit counters)
INFO: 0 files found in corpus/
INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes
#2      INITED cov: 3 ft: 3 corp: 1/1b exec/s: 0 rss: 29Mb
#8      NEW    cov: 5 ft: 6 corp: 2/4b lim: 4 exec/s: 0 rss: 29Mb L: 3/3
#4096   pulse  cov: 6 ft: 8 corp: 3/9b lim: 43 exec/s: 0 rss: 30Mb

כדאי להכיר את השדות: cov הוא מספר קטעי הקוד שכוסו, ft (features) הוא מדד כיסוי עדין יותר (כולל ספירות והשוואות), corp הוא גודל הקורפוס (מספר קבצים/סך בתים), ו-exec/s הוא הרצות לשנייה. השורות NEW מסמנות שהקלט האחרון פתח כיסוי חדש ולכן נשמר בקורפוס.

כשמתרחשת קריסה, libFuzzer מדפיס את דוח הסניטייזר, שומר את הקלט שהפיל, ועוצר:

==31337==ERROR: AddressSanitizer: heap-buffer-overflow ...
...
SUMMARY: AddressSanitizer: heap-buffer-overflow lib.c:12 in store_record
artifact_prefix='./'; Test unit written to ./crash-0eb8e4b1...
Base64: ...

הקובץ crash-0eb8e4b1... הוא בדיוק הבתים שהפילו את התוכנית. כדי לשחזר את הקריסה בכל רגע, מריצים את אותו בינארי עם הקובץ הזה כארגומנט:

./fuzz_target crash-0eb8e4b1...

דגלים שימושיים שכדאי להכיר: -max_len=N מגביל אורך קלט, -timeout=N מגדיר תקרת זמן לקלט בודד (למציאת תקיעות), -rss_limit_mb=N תופס בלוני זיכרון, -jobs=N ו--workers=N מריצים כמה מופעים במקביל, ו--max_total_time=N מגביל את משך הריצה בשניות.

סניטייזרים - sanitizers

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

חיטוי כתובות - AddressSanitizer (ASan)

הסניטייזר החשוב ביותר לפאזינג. הוא תופס את משפחת באגי הזיכרון:

  • גלישת חוצץ ב-heap, ב-stack ובמשתנים גלובליים (heap/stack/global buffer overflow).
  • שימוש לאחר שחרור - use-after-free, ושימוש לאחר חזרה מפונקציה - use-after-return.
  • שחרור כפול - double free, ושחרור של מצביע לא חוקי.
  • דליפות זיכרון - memory leaks (דרך LeakSanitizer שמשולב בו).

איך הוא עובד? ASan מקצה סביב כל הקצאה אזורי-מגן (redzones) מורעלים, ומנהל צל זיכרון (shadow memory) - בית צל אחד לכל 8 בתים אמיתיים, שאומר אילו בתים חוקיים לגישה. כל קריאה וכתיבה עוברות בדיקה מול הצל. המחיר הוא האטה של פי 2 בערך והכפלת צריכת הזיכרון, אבל בשביל דיוק זה משתלם.

אפשר לכוון את ASan דרך משתנה סביבה. למשל, כדי לתפוס גם use-after-return ולעצור על השגיאה הראשונה:

ASAN_OPTIONS=detect_stack_use_after_return=1:abort_on_error=1 ./fuzz_target corpus/

חיטוי התנהגות לא-מוגדרת - UndefinedBehaviorSanitizer (UBSan)

הסניטייזר הזה תופס התנהגות לא-מוגדרת בשפת C/C++: גלישת חשבון של מספרים מסומנים, הזזת ביטים מעבר לרוחב הטיפוס, alignment לא חוקי של מצביעים, פנייה דרך מצביע null, המרות טיפוס שמאבדות ערך, ועוד. הרבה מהחולשות האמיתיות מתחילות בדיוק שם - חישוב אורך שגולש ל-integer overflow ואז מקצה buffer קטן מדי.

יש כאן מלכודת אחת קריטית: כברירת מחדל UBSan רק מדפיס אזהרה וממשיך לרוץ. בפאזינג זה חסר תועלת - הקלט הבא ידרוס את הדוח וה-fuzzer לא ידע שקרה משהו. חובה להוסיף דגל שהופך את זה לקריסה אמיתית:

clang -g -O1 -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all \
      -o fuzz_target harness.c lib.c

הדגל -fno-sanitize-recover=all אומר: על כל שגיאת סניטייזר, תפיל את התוכנית מיד. עכשיו libFuzzer יתפוס גם באגי UB. שימו לב ש-ASan ו-UBSan מסתדרים יחד באותו בינארי, וזה השילוב הנפוץ ביותר.

חיטוי זיכרון - MemorySanitizer (MSan)

הסניטייזר MSan תופס משהו ש-ASan לא רואה: קריאה מזיכרון לא-מאותחל (uninitialized memory). זה באג ערמומי - למשל, פונקציה שמחזירה מבנה שחלק משדותיו לא נכתבו, וקוד אחר מסתמך עליהם. אלה מקורות נפוצים לleaks מידע.

שתי מגבלות חשובות: אי אפשר לשלב MSan עם ASan באותו בינארי (הם מנהלים זיכרון בדרכים מתנגשות), ו-MSan דורש שכל הקוד שרץ - כולל ספריות שאתם קוראים - יהיה מהודר עם MSan, אחרת יהיו התרעות שווא (false positives) מקוד לא-מנוטר. לכן בפועל מריצים סבב נפרד עם MSan:

clang -g -O1 -fsanitize=fuzzer,memory -fsanitize-memory-track-origins \
      -o fuzz_target_msan harness.c lib.c

הדגל -fsanitize-memory-track-origins עולה בביצועים אבל שווה זהב: הוא אומר לכם לא רק שנקראה מילה לא-מאותחלת, אלא גם היכן היא הוקצתה מלכתחילה.

טבלת השילובים

+---------------------+-------------------------------+------------------+
| Sanitizer            | What it catches                | Combine with     |
+---------------------+-------------------------------+------------------+
| ASan  (address)     | overflows, UAF, double-free   | + UBSan          |
| UBSan (undefined)   | UB: overflow, shift, null     | + ASan           |
| MSan  (memory)      | reads from uninitialized memory | alone (not with ASan) |
+---------------------+-------------------------------+------------------+

הפרקטיקה המקובלת: סבב ראשי עם fuzzer,address,undefined, וסבב שני נפרד עם fuzzer,memory.

קריאת דוח קריסה - reading a sanitizer report

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

// lib.c - stores a "record" from the input
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stddef.h>

void store_record(const uint8_t *data, size_t size) {
    if (size < 1) return;
    uint8_t declared = data[0];             // the first byte declares the length
    if (declared > size - 1)                // clamp to the input size (no over-read)
        declared = size - 1;
    char *rec = malloc(8);                  // the bug: always 8 bytes, regardless of declared
    memcpy(rec, data + 1, declared);        // if declared > 8 -> heap overflow on write
    free(rec);
}

וה-harness שקורא לה:

// harness.c
#include <stdint.h>
#include <stddef.h>

void store_record(const uint8_t *data, size_t size);

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    store_record(data, size);
    return 0;
}

בונים ומריצים:

clang -g -O1 -fsanitize=fuzzer,address -o fuzz_target harness.c lib.c
./fuzz_target

תוך שבריר שנייה libFuzzer מגלה שהבית הראשון שולט בזרימה, מגדיל אותו מעל 8, ומייצר overflow. הנה הדוח (מקוצר):

==31337==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000018
WRITE of size 20 at 0x602000000018 thread T0
    #0 0x49b2a0 in memcpy (/path/fuzz_target+0x49b2a0)
    #1 0x4f1c33 in store_record lib.c:12:5
    #2 0x4f1c9a in LLVMFuzzerTestOneInput harness.c:8:5
    ...
0x602000000018 is located 0 bytes to the right of 8-byte region [0x602000000010,0x602000000018)
allocated by thread T0 here:
    #0 0x4a1b40 in malloc
    #1 0x4f1c0e in store_record lib.c:11:17

SUMMARY: AddressSanitizer: heap-buffer-overflow lib.c:12:5 in store_record

בואו נקרא אותו שורה-שורה, כי כל שורה מספרת חלק מהסיפור:

  • השורה הראשונה נותנת את סוג הבאג: heap-buffer-overflow. זו כבר כותרת אבחון.
  • השורה WRITE of size 20 אומרת שזו הייתה כתיבה של 20 בתים. כתיבה מסוכנת יותר מקריאה - היא בדרך כלל בת-ניצול.
  • מחסנית הקריאות (stack trace) היא הזהב. מדלגים על מסגרת memcpy (זה רק המיירט של ASan) ומגיעים למסגרת #1: store_record lib.c:12:5. זו השורה המדויקת שגלשה, שורה 12, עמודה 5.
  • השורה 0 bytes to the right of 8-byte region מספרת שהoverflow החלה מיד אחרי אזור בן 8 בתים. שמונה בתים - זה בדיוק ה-malloc(8) שלנו.
  • החלק allocated by thread T0 here מצביע על מקום ההקצאה: store_record lib.c:11. עכשיו יש לנו את שתי הנקודות - איפה הוקצה הbuffer (שורה 11) ואיפה הוא נדרס (שורה 12).
  • השורה SUMMARY מסכמת הכל בשורה אחת, שימושי לחיפוש בלוגים.

הצלבנו הכל: הקצאה של 8 בתים בשורה 11, כתיבה של עד 255 בתים בשורה 12. הבאג נמצא, ובלי לפתוח את הקוד בכלל, רק מהדוח.

פאזינג מודע-מבנה - structure-aware fuzzing

עד עכשיו ה-fuzzer זרק בתים אקראיים. זה מצוין לformat שטוח, אבל נכשל מול formats עם מבנה נוקשה - נניח מנתח (parser) שבודק מספר קסם (magic), סוכם ביקורת (checksum), או שדות אורך. רוב הבתים האקראיים ידחו בשער הראשון ולא יגיעו לקוד המעניין.

הכלי הבסיסי לצריכת בתים בצורה מובנית הוא הכותרת FuzzedDataProvider. במקום לפרש את data ידנית, מבקשים ממנו ערכים בטיפוסים שרוצים, והוא חותך את הבתים בשבילכם:

#include <fuzzer/FuzzedDataProvider.h>

extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    FuzzedDataProvider fdp(data, size);
    uint8_t opcode  = fdp.ConsumeIntegral<uint8_t>();       // first byte = opcode
    uint32_t length = fdp.ConsumeIntegral<uint32_t>();      // 4 bytes = length
    std::string body = fdp.ConsumeRemainingBytesAsString(); // rest = body
    handle_command(opcode, length, body);
    return 0;
}

שיטות נוספות:

  • מילון - dictionary. אם הformat דורש מילות מפתח (למשל GET, POST, או מספר קסם), נותנים ל-libFuzzer מילון: קובץ עם מחרוזות, ומריצים עם -dict=http.dict. ה-fuzzer ישתול את המילים האלה בקלטים במקום לנחש אותן בית-בית.
  • פרופיל ערכים - value profile. הדגל -use_value_profile=1 גורם ל-libFuzzer לעקוב אחרי השוואות (==, memcmp) ולפרק אותן שלב-שלב. ככה הוא "מטפס" אל מספר קסם בן 4 בתים בלי לנחש 4 מיליארד אפשרויות.
  • מוטטור מותאם - custom mutator. לformats מורכבים באמת (protobuf, קבצי מדיה) כותבים LLVMFuzzerCustomMutator, או משתמשים ב-libprotobuf-mutator שממַטֵּט ישירות על עץ ההודעה במקום על הבתים הגולמיים. ככה כל קלט הוא תמיד תקין תחבירית, וה-fuzzer מבזבז את זמנו על הלוגיקה ולא על התחביר.

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

השוואה בין libFuzzer ל-AFL++ - בתוך התהליך ומחוצה לו

שני הכלים מחפשים את אותו דבר, אבל דרך המימוש שונה, וזה משפיע על מתי כדאי לבחור כל אחד.

+--------------------+-----------------------------+-----------------------------+
|                    | libFuzzer                   | AFL++                       |
+--------------------+-----------------------------+-----------------------------+
| Execution model     | in-process                   | out-of-process (fork server)|
| Speed per function   | very high                    | lower (fork cost)           |
| Isolation between inputs | weak (shared state)     | strong (clean process per input) |
| Resilience to severe crashes | crash stops the fuzzer | the fork server keeps going |
| Entry point         | LLVMFuzzerTestOneInput       | stdin/file, or similar harness |
| Built-in integration | clang -fsanitize=fuzzer     | afl-clang-fast / afl-cc     |
+--------------------+-----------------------------+-----------------------------+

מתי מה? libFuzzer מבריק כשיש לכם קוד מקור ופונקציה קטנה וחסרת מצב לבדוק - ספריית ניתוח, מפענח, מנתח פרוטוקול. הוא מהיר, קל להטמעה, ומשתלב מושלם עם סניטייזרים. AFL++ מבריק כשהמטרה היא בינארי שלם, מלוכלך במצב, או שאין לכם קוד מקור (אפשר להריץ אותו במצב QEMU על בינארי סגור). הוא גם עמיד יותר: אם קלט מקריס את התהליך בצורה שמשחיתה מצב, ה-fork server פשוט מרים ילד חדש ונקי וממשיך.

כדאי לדעת שני דברים שמטשטשים את הגבול:

  • ל-libFuzzer יש מצב -fork=1, שמריץ את הלולאה בתת-תהליכים ומתאושש מקריסות ומ-OOM-ים במקום להיעצר. זה נותן חלק מהחוסן של המודל החוץ-תהליכי.
  • ל-AFL++ יש מצב עמידה (persistent mode) שהוא בעצם in-process - לולאה עם __AFL_LOOP שחוסכת fork. יתרה מזו, AFL++ יודע לקמפל ולהריץ harness בסגנון LLVMFuzzerTestOneInput ישירות. כלומר, אותו harness שכתבתם ל-libFuzzer בדרך כלל ירוץ גם תחת AFL++ בלי שינוי. זו סיבה מצוינת לכתוב harness אחד ולהריץ אותו בשני הכלים.

הערה לגבי הכיוון של libFuzzer: הוא נמצא היום במצב תחזוקה (maintenance) ופרויקט היורש שלו מבית LLVM נקרא Centipede, שמיועד לפאזינג בקנה מידה גדול ומבוזר. עדיין, libFuzzer הוא המימוש שהגדיר את הז'אנר של פאזינג בתוך התהליך, וההבנה שלו מעבירה ישירות לכל כלי אחר.

עבודה מסודרת - קורפוס וטריאז'

כמה הרגלים שהופכים ריצת פאזינג מצעצוע לתהליך אמיתי:

  • זרעים - seeds. תתחילו מקורפוס של קלטים תקינים (דוגמאות אמיתיות של הformat). זרע טוב חוסך ל-fuzzer שעות של גישוש עד לקוד המעניין.
  • מיזוג ומזעור - merge. הפקודה ./fuzz_target -merge=1 corpus_min corpus_big שומרת רק את הקלטים שתורמים כיסוי ייחודי, וכך הקורפוס נשאר קטן ומהיר.
  • טריאז' - triage. לכל קובץ crash-* מריצים ./fuzz_target crash-* כדי לשחזר, קוראים את הדוח כדי לזהות את סוג הבאג ואת השורה, ומקבצים כפילויות לפי ה-SUMMARY. באג יחיד לרוב מפיל כמה קלטים שונים.
  • מזעור הקלט - minimize. הדגל -minimize_crash=1 לוקח קלט שמפיל ומכווץ אותו לקלט המינימלי שעדיין מפיל, מה שמקל מאוד על ההבנה מה בדיוק מפעיל את הבאג.

סיכום

  • פאזינג בתוך התהליך עם libFuzzer קורא ל-LLVMFuzzerTestOneInput שלכם בלולאה מהירה באותו תהליך, בלי fork לכל קלט.
  • ה-harness מקבל data ו-size, חייב להחזיר 0, ואסור לו לגעת מעבר לקלט או לשמור מצב מלוכלך.
  • בונים עם clang -fsanitize=fuzzer,<סניטייזר>. הדגל מוסיף גם הנחיית כיסוי וגם את ה-main של libFuzzer.
  • ASan תופס overflows, use-after-free ו-double-free; UBSan תופס התנהגות לא-מוגדרת (עם -fno-sanitize-recover=all); MSan תופס זיכרון לא-מאותחל (בסבב נפרד, לא עם ASan).
  • דוח ASan נקרא לפי מבנה קבוע: סוג הבאג, WRITE/READ וגודלו, מחסנית הקריאות עד השורה הבעייתית, וגודל האזור שנדרס ומקום הקצאתו.
  • לformats נוקשים משתמשים במודעות-מבנה: FuzzedDataProvider, מילון, פרופיל ערכים, או מוטטור מותאם.
  • libFuzzer מהיר וקל להטמעה על פונקציה חסרת מצב; AFL++ מבודד ועמיד יותר ומתאים לבינארי שלם. אותו harness לרוב רץ בשני הכלים.