לדלג לתוכן

10.2 Fuzzing עם AFL הרצאה

בשיעור הקודם (10.1) עברנו על סקירת קוד מקור - ישבנו מול הקוד וחיפשנו באגים בעיניים. זו שיטה חזקה, אבל היא איטית ומוגבלת לקוד שאנחנו מספיקים לקרוא ולהבין. עכשיו נלמד את הגישה המשלימה וההפוכה: נותנים למכונה להריץ את התוכנית עשרות אלפי פעמים בשנייה עם קלטים משתנים, ומחכים שהיא תקרוס. זה נקרא fuzzing, וכבר הזכרנו אותו בקצרה בקורס הבסיס (7.6). כאן נצלול לעומק אל AFL++, ה-fuzzer מונחה-הכיסוי (coverage-guided) הנפוץ ביותר לבינארים מהודרים, ונלמד את כל שרשרת העבודה: מהידור עם אינסטרומנטציה, דרך בניית קורפוס והרצה, ועד טריאז' של הקריסות שנמצא.


למה fuzzing אקראי לבד לא מספיק - the coverage problem

בואו נתחיל מהבעיה. נניח שיש לנו parser שמתחיל ככה:

if (memcmp(buf, "PWN!", 4) != 0)
    return 1;          // any input that doesn't start with "PWN!" is rejected here
// ... all the interesting code (and the bugs) is from here on

כלי fuzzer "טיפש" (dumb fuzzer) שמגריל בתים אקראיים לגמרי צריך לפגוע בדיוק ב-"PWN!" בארבעת הבתים הראשונים. ההסתברות לזה היא 1 חלקי 2^32, כלומר בערך אחת ל-4 מיליארד. בפועל הוא לעולם לא יעבור את השער הזה, וכל הקוד המעניין יישאר לא-נבדק. זו בדיוק הסיבה שפאזינג אקראי נאיבי כמעט חסר תועלת על קלט מבני.

הרעיון הגאוני של AFL הוא משוב כיסוי (coverage feedback). במקום להריץ בעיוורון, ה-fuzzer מודד אילו קטעי קוד הורצו בכל הרצה. אם קלט חדש גרם לתוכנית לבצע מעבר חדש בגרף הבקרה (edge שלא נראה קודם), הקלט הזה "מעניין" - שומרים אותו וממשיכים לפתח ממנו. ככה ה-fuzzer מטפס בהדרגה עמוק יותר ויותר לתוך הקוד, שער אחרי שער, בלי לדעת מראש שום דבר על הformat.

זה הופך את הפאזינג לתהליך אבולוציוני (genetic): הקלטים ה"טובים" שורדים ומתרבים, המוטציות שלא תרמו כיסוי חדש נזרקות.

                +------------------+
                |  corpus - queue  |  <-- collection of "interesting" inputs
                +------------------+
                         |
                         v  pick a favored input
                +------------------+
                |  mutation (havoc)|  <-- flip bits, add/remove bytes
                +------------------+
                         |
                         v  run the target with the mutated input
                +------------------+
                | measure edge coverage |  <-- which transitions ran?
                +------------------+
                    |            |
        new edge?   |            |  no new -> discarded
                    v            
                +------------------+
                | save to corpus    |  and if it crashed -> to crashes dir
                +------------------+
                         |
                         +----> loop back to the top, again and again, thousands of times per second

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


הכלי AFL++ - התקנה ומשפחת המהדרים

הפרויקט AFL++ הוא ממשיך הדרך של AFL המקורי של Michal Zalewski, והוא הסטנדרט דה-פקטו היום. התקנה מהירה:

# Debian/Ubuntu - package exists but usually outdated
sudo apt install afl++

# recommended: build from source to get the full LLVM compilers
git clone https://github.com/AFLplusplus/AFLplusplus
cd AFLplusplus
make distrib          # also builds the clang-fast, lto, cmplog, qemu modes
sudo make install

בסיום מקבלים משפחת כלים. אלה שנשתמש בהם:

  • הכלי afl-cc (ושמות-העט שלו afl-clang-fast, afl-gcc-fast) - מהדר עוטף שמזריק אינסטרומנטציה בזמן הקומפילציה.
  • הכלי afl-clang-lto - אינסטרומנטציה איכותית יותר (ללא התנגשויות ב-bitmap) ומילון אוטומטי. מומלץ כשיש קוד מקור מלא.
  • הכלי afl-fuzz - מנוע הפאזינג עצמו.
  • הכלים afl-cmin ו-afl-tmin - מזעור קורפוס ומזעור מקרה-בדיקה בודד.
  • הכלי afl-showmap - מציג את הכיסוי של הרצה בודדת.

מה האינסטרומנטציה בעצם מזריקה? בכל נקודת הסתעפות בקוד (כל edge בגרף הבקרה), המהדר מוסיף כמה הוראות שמעדכנות מפת כיסוי משותפת (shared memory bitmap) בגודל 64KB כברירת מחדל. כל edge ממופה לתא במפה, וכשעוברים בו מגדילים מונה. בנוסף, האינסטרומנטציה מקימה forkserver: התוכנית נטענת פעם אחת עד ל-main, ומשם AFL עושה fork לכל קלט. זה חוסך את כל עלות ה-execve וטעינת ה-libc בכל הרצה, ומכאן המהירות של אלפי הרצות בשנייה.


אינסטרומנטציה של המטרה - הידור עם afl-cc

נניח שיש לנו parser.c שקורא קובץ ומנתח אותו. במקום gcc נהדר עם afl-clang-fast:

# instrumented build + AddressSanitizer (recommended - exposes memory corruption that wouldn't crash on its own)
AFL_USE_ASAN=1 afl-clang-fast -g -O1 -o parser_afl parser.c

שימו לב לשני משתני-הסביבה השימושיים ביותר בזמן הבניה:

  • המשתנה AFL_USE_ASAN=1 מוסיף את AddressSanitizer. בלי זה, overflow קטנה שדורסת כמה בתים על המחסנית לרוב לא תקריס את התוכנית וה-fuzzer לא ישים לב אליה. ASAN הופך כל גישת-זיכרון לא-חוקית לקריסה מיידית עם דוח מדויק. המחיר: ריצה איטית יותר וצריכת זיכרון גבוהה, אז נגדיר בהרצה -m none.
  • המשתנה AFL_HARDEN=1 מוסיף בדיקות קלות (fortify, stack protector) בלי העלות של ASAN - חלופה קלה כשלא רוצים ASAN.

עצה מעשית: כדאי להחזיק שני בינארים - אחד מאונסטרומנט לפאזינג (parser_afl), ואחד עם ASAN בלבד להדגמת הקריסה בטריאז' (parser_asan, מהודר ב-clang -fsanitize=address). אפשר גם למזג את שניהם, אבל הפרדה עוזרת לדבג.


קורפוס זרעים - seed corpus

ה-fuzzer צריך נקודת התחלה: אוסף קטן של קלטים תקינים שנקרא קורפוס זרעים (seed corpus). הזרעים ממלאים שני תפקידים: הם נותנים ל-fuzzer דוגמה של format חוקי (כדי שלא יבזבז זמן על השער "PWN!"), והם מהווים בסיס למוטציות.

כללי אצבע לזרעים טובים:

  • קטנים ככל האפשר. קלט קטן -> מוטציות ממוקדות -> ריצה מהירה יותר.
  • מגוונים. זרע לכל "ענף" בformat - קובץ עם רשומה אחת, קובץ עם כמה, קובץ עם סוגי רשומות שונים.
  • תקינים (או כמעט-תקינים) כדי לעבור את שערי-הכניסה של ה-parser.
mkdir -p in
printf 'PWN!\x01\x01\x01\x05hello' > in/name1
printf 'PWN!\x01\x02\x01\x03abc\x02\x02hi' > in/two

אם יש לנו הרבה זרעים חופפים, נמזער אותם עם afl-cmin - הוא משאיר תת-קבוצה מינימלית שמשמרת את אותו כיסוי:

afl-cmin -i in -o in_min -- ./parser_afl @@

הרתמה - the harness

הרתמה (harness) היא נקודת הכניסה שדרכה AFL מזין את הקלט למטרה. יש שתי צורות עיקריות:

1. קלט מקובץ, דרך @@. אם התוכנית מקבלת נתיב-קובץ בשורת הפקודה, AFL כותב כל קלט לקובץ זמני ומחליף את @@ בנתיב שלו:

afl-fuzz -i in -o out -- ./parser_afl @@

אם התוכנית קוראת מ-stdin, פשוט משמיטים את @@ ו-AFL מזין דרך הקלט הסטנדרטי:

afl-fuzz -i in -o out -- ./parser_afl

2. הרתמה ייעודית שקוראת לפונקציה. לרוב לא רוצים לפזז את כל התוכנית אלא פונקציה מרכזית אחת (למשל parse()). כותבים main קטן שקורא בתים ומעביר אותם ישירות לפונקציה. זה גם מהיר יותר וגם ממקד את הפאזינג. הסגנון מזכיר את LLVMFuzzerTestOneInput של libFuzzer (נראה אותו בשיעור הבא):

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

int parse(const uint8_t *buf, size_t n);   // the function being fuzzed

int main(void) {
    uint8_t buf[8192];
    ssize_t n = read(0, buf, sizeof(buf));  // read from stdin
    if (n <= 0) return 0;
    parse(buf, (size_t)n);
    return 0;
}

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


הרצת afl-fuzz - running the fuzzer

לפני ההרצה הראשונה, לינוקס דורש שני כיוונונים קטנים (afl-fuzz יתלונן אם לא נעשה אותם):

# prevent the system's crash reporter from intercepting the crashes
sudo sh -c 'echo core > /proc/sys/kernel/core_pattern'

# CPU frequency policy for maximum speed (or bypass with AFL_SKIP_CPUFREQ=1)
cd /sys/devices/system/cpu
echo performance | sudo tee cpu*/cpufreq/scaling_governor

ועכשיו ההרצה:

afl-fuzz -i in -o out -m none -- ./parser_afl @@
  • הדגל -i in - תיקיית הזרעים.
  • הדגל -o out - תיקיית הפלט (הקורפוס שנצבר, הקריסות, סטטיסטיקות).
  • הדגל -m none - בלי הגבלת זיכרון (הכרחי כשמשתמשים ב-ASAN).
  • אחרי -- באה שורת הפקודה של המטרה, עם @@ במקום הקובץ.

דגלים חשובים נוספים:

  • הדגל -x dict.txt - מילון (נסביר בהמשך).
  • הדגל -c ./parser_cmplog - מצב cmplog לפתירת השוואות ומספרי-קסם.
  • הדגלים -M main ו--S sec1 - הרצה מקבילה: מופע ראשי אחד וכמה משניים, שחולקים את אותה תיקיית out ומחליפים ביניהם קלטים מעניינים. ככה מנצלים את כל הליבות.

קריאת מסך הסטטוס - the status screen

ברגע שמריצים, AFL מציג מסך סטטוס חי. חובה לדעת לקרוא אותו:

      american fuzzy lop ++4.21c  {default}  (./parser_afl)
+-- process timing --------------------+-- overall results --------+
| run time         : 0d, 0h, 12m       | cycles done  : 6          |
| last new find     : 0d, 0h, 0m       | corpus count : 214        |
| last saved crash  : 0d, 0h, 1m       | saved crashes: 3          |
| last saved hang   : none seen yet    | saved hangs  : 0          |
+-- cycle progress ------+-- map coverage -----------------------+
| now processing : 41.2  | map density    : 3.11% / 6.40%        |
| runs timed out : 0      | count coverage : 2.13 bits/tuple      |
+-- stage progress ------+-- findings in depth ------------------+
| now trying   : havoc   | favored items  : 58                   |
| stage execs  : 210/512 | new edges on   : 79                   |
| total execs  : 3.42M   | total crashes  : 41 (3 saved)         |
| exec speed   : 4210/s  | total tmouts   : 0                     |
+------------------------+---------------------------------------+

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

  • הערך exec speed - כמה הרצות בשנייה. מתחת ל-500/s זה איטי וכדאי לבדוק למה (I/O, ASAN כבד, forkserver לא עלה). מעל 1000/s זה בריא.
  • הערך saved crashes - כמה קריסות ייחודיות נשמרו. זה מה שבאנו בשבילו.
  • הערך last new find - מתי לאחרונה נמצא כיסוי חדש. אם זה "תקוע" על שעות בלי חדש, ה-fuzzer רווי - זה הזמן להוסיף מילון, cmplog, או זרע טוב יותר.
  • הערך map density - כמה מהמפה מכוסה. צפיפות נמוכה מאוד עם מטרה גדולה מרמזת שאנחנו תקועים בשער כניסה.
  • הערך cycles done - כמה סבבים מלאים על הקורפוס הושלמו. אחרי שהמונה מתייצב וכבר לא צצות קריסות, אפשר לשקול לעצור.
  • שדה ה-stability (מוצג במסכים מסוימים) - אם הוא לא 100%, יש אי-דטרמיניזם במטרה (זמן, אקראיות, מצב גלובלי) שמבלבל את הכיסוי. שווה לתקן ברתמה.

מצב מתמשך - persistent mode לביצועים

ה-forkserver כבר חוסך את ה-execve, אבל עדיין עושים fork לכל קלט. מצב מתמשך (persistent mode) הולך צעד קדימה: מריצים לולאה בתוך אותו תהליך שמעבדת אלפי קלטים לפני fork מחדש. זה נותן פי 10 עד פי 100 מהירות, וזו אולי הטכניקה החשובה ביותר להאצת פאזינג.

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

int parse(const uint8_t *buf, size_t n);

__AFL_FUZZ_INIT();                                 // defines the shared input memory

int main(void) {
    __AFL_INIT();                                  // forkserver paused - we stop here once
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;  // pointer to the input (must be outside the loop!)

    while (__AFL_LOOP(10000)) {                     // up to 10000 inputs per fork
        int len = __AFL_FUZZ_TESTCASE_LEN;         // length of the current input
        parse(buf, (size_t)len);
    }
    return 0;
}
afl-clang-fast -g -O2 -o parser_persist harness_persist.c parser.c
afl-fuzz -i in -o out -- ./parser_persist        # no @@ - the input arrives via shared memory

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


מילונים - dictionaries לformats מבניים

כשלformat יש מילות-מפתח או מספרי-קסם (magic numbers), כמו "PWN!" שלנו, מילון עוזר ל-fuzzer להזריק אותם ישירות במקום לגלות אותם בהגרלה. קובץ המילון פשוט:

# parser.dict
magic="PWN!"
type_name="\x01"
version="\x01"
afl-fuzz -i in -o out -x parser.dict -- ./parser_afl @@

יש שתי חלופות אוטומטיות מצוינות למילון ידני:

  • הידור ב-afl-clang-lto מפיק מילון אוטומטי מהקבועים והמחרוזות שבקוד - כמעט "בחינם".
  • מצב cmplog (הדגל -c) עוקב אחרי השוואות בזמן ריצה (memcmp, if (x == 0xdeadbeef)) ו"מזין בחזרה" את הערכים שהמטרה משווה מולם. זה פותר שערי מספרי-קסם שמילון סטטי לא היה תופס. בונים בינארי cmplog נפרד:
AFL_LLVM_CMPLOG=1 afl-clang-fast -o parser_cmplog parser.c
afl-fuzz -i in -o out -c ./parser_cmplog -- ./parser_afl @@

טריאז' של קריסות - crash triage

מצאנו קריסות - עכשיו העבודה האמיתית מתחילה. הקריסות יושבות ב-out/default/crashes/:

$ ls out/default/crashes/
README.txt
id:000000,sig:06,src:000112,time:41003,execs:170200,op:havoc,rep:2
id:000001,sig:11,src:000090,time:98771,execs:402113,op:havoc,rep:4

שם הקובץ מקודד מטא-דאטה: sig:06 הוא SIGABRT (בדרך כלל ASAN או stack-smashing), sig:11 הוא SIGSEGV, src הוא הזרע שממנו הגיע, op הוא סוג המוטציה. שימו לב: AFL כבר מבצע דה-דופ גס לפי חתימת הכיסוי, אבל עדיין ייתכן שכמה קבצים הם אותו באג. הטריאז' ממיין את זה.

צעד 1 - שחזור. מריצים את המטרה על קובץ הקריסה ומוודאים שהיא באמת קורסת:

./parser_afl out/default/crashes/id:000001,sig:11,src:000090,...

צעד 2 - דוח מדויק עם ASAN. מריצים את בינארי ה-ASAN על אותו קובץ. הוא יצביע על השורה המדויקת:

./parser_asan out/default/crashes/id:000001,...

צעד 3 - מזעור. משתמשים ב-afl-tmin כדי לצמצם את קובץ-הקריסה לגרסה המינימלית שעדיין מקריסה - הרבה יותר קל לנתח 20 בתים מ-2000:

afl-tmin -i out/default/crashes/id:000001,... -o crash_min -- ./parser_afl @@

צעד 4 - ניתוח שורש עם gdb. פותחים ב-gdb (או pwndbg) ורואים איפה בדיוק נשבר:

gdb --args ./parser_afl crash_min
(gdb) run
(gdb) bt
(gdb) info registers

צעד 5 - שאלת הניצוליות. לא כל קריסה היא חולשה מנוצלת. שואלים: האם התוקף שולט בערך שנדרס? האם זה write-what-where או רק קריאה מכתובת NULL? דריסה של return address על המחסנית (SIGSEGV ב-ret עם כתובת שנשלטת) היא סימן חזק לניצוליות. קריסה ב-memcpy שדורס את המחסנית מעבר לbuffer - בדיוק החולשה שנפגוש בתרגול - היא מועמדת מצוינת. לניתוח ניצוליות אוטומטי אפשר להיעזר בכלים כמו casr או תוסף exploitable של gdb, אבל שיפוט אנושי תמיד גובר.


סיכום

  • פאזינג אקראי לבד נכשל מול שערי-כניסה (מספרי-קסם); משוב כיסוי הוא מה שהופך את הפאזינג לחכם ואבולוציוני.
  • הכלי AFL++ מזריק אינסטרומנטציה בהידור (afl-clang-fast), מודד edges במפת-כיסוי של 64KB, ומריץ forkserver למהירות.
  • מהדרים עם AFL_USE_ASAN=1 כדי ש-קורפשן זיכרון שקטה תהפוך לקריסה שה-fuzzer יזהה.
  • בונים קורפוס זרעים קטן, תקין ומגוון; ממזערים עם afl-cmin.
  • הרתמה מזינה קלט דרך @@ (קובץ), stdin, או ישירות לפונקציה בסגנון persistent mode.
  • מריצים עם afl-fuzz -i in -o out, קוראים את מסך הסטטוס (exec speed, saved crashes, last new find, map density).
  • מצב מתמשך נותן פי 10-100 מהירות; מילונים ו-cmplog פותרים formats מבניים ומספרי-קסם.
  • טריאז': שחזור -> ASAN לשורה המדויקת -> מזעור עם afl-tmin -> ניתוח ב-gdb -> הערכת ניצוליות.