לדלג לתוכן

10.2 Fuzzing עם AFL פתרון

פתרון - Fuzzing עם AFL

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

פתרון תרגיל 1 - קריאת המטרה וניחוש הבאג

שלוש התשובות:

  1. שער הכניסה הוא if (memcmp(buf, "PWN!", 4) != 0) return 1;. כל קלט שלא מתחיל בארבעת הבתים "PWN!" נזרק מיד, ולכן ל-fuzzer טיפש אין סיכוי מעשי להגיע לקוד המעניין (1 ל-2^32). זה בדיוק המקום שבו זרע תקין או מילון מכריעים.
  2. הbuffer הוא char name[32] בתוך handle_name - 32 בתים על המחסנית.
  3. הערך len הוא uint8_t, כלומר עד 255. השורה if (off + len > n) len = n - off; מגבילה רק את הקריאה מהקלט (שלא נקרא מעבר לסוף הקובץ), אבל לא את הכתיבה ל-name. לכן אם הקובץ מכיל רשומת-שם עם len גדול מ-32 ומספיק בתים אחריו, memcpy תכתוב עד 255 בתים לתוך buffer של 32 - overflow מחסנית קלאסית.

פתרון תרגיל 2 - קורפוס זרעים

בונים שני זרעים תקינים שמפעילים את מסלול השם:

mkdir -p in
# PWN! | version=1 | count=1 | type=0x01 len=0x05 "hello"
printf 'PWN!\x01\x01\x01\x05hello' > in/name1
# two name records
printf 'PWN!\x01\x02\x01\x03abc\x02\x02hi' > in/two

בדיקת שפיות שהזרע באמת עובר את השער ומגיע להדפסה:

$ clang -g -o parser parser.c
$ ./parser in/name1
name = hello

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

פתרון תרגיל 3 - הידור והרצה

הידור עם אינסטרומנטציה ו-ASAN:

$ AFL_USE_ASAN=1 afl-clang-fast -g -O1 -o parser_afl parser.c
afl-clang-fast++4.21c by Michal Zalewski
[+] Instrumented 7 locations (non-hardened, ASAN mode).

הכנת המערכת והרצה:

$ sudo sh -c 'echo core > /proc/sys/kernel/core_pattern'
$ afl-fuzz -i in -o out -m none -- ./parser_afl @@

תוך שניות עד דקה, ה-fuzzer מגדיל את len מעבר ל-32 והקריסות מתחילות להיערם:

+-- overall results --------+
| cycles done  : 4          |
| corpus count : 63         |
| saved crashes: 5          |
+---------------------------+
| exec speed   : ~3800/s    |

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

פתרון תרגיל 4 - טריאז' עד לשורש הבאג

שחזור. בוחרים קובץ קריסה ומריצים:

$ ls out/default/crashes/
id:000000,sig:06,src:000001,time:9033,execs:41200,op:havoc,rep:2
$ ./parser_afl "out/default/crashes/id:000000,sig:06,src:000001,time:9033,execs:41200,op:havoc,rep:2"

דוח מדויק עם ASAN. מריצים את הבינ' של ASAN (או פשוט את parser_afl, שגם הוא כולל ASAN). הדוח מצביע ישירות על הפשע:

$ clang -g -fsanitize=address -o parser_asan parser.c
$ ./parser_asan out/default/crashes/id:000000,...
==5142==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffd...ac0
WRITE of size 96 at 0x7ffd...ac0 thread T0
    #0 0x... in __asan_memcpy
    #1 0x... in handle_name parser.c:17
    #2 0x... in parse parser.c:31
    #3 0x... in main parser.c:41

Address 0x7ffd...ac0 is located in stack of thread T0 at offset 32
  in frame #0 handle_name
  This frame has 1 object(s):
    [32, 64) 'name' (line 16) <== overflow here
SUMMARY: AddressSanitizer: stack-buffer-overflow parser.c:17 in handle_name

זה כל הסיפור בשורה אחת: WRITE של 96 בתים לתוך אובייקט name שגודלו 32, בשורה 17 - כלומר ה-memcpy. ה-len היה 96 והbuffer הוא 32.

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

$ afl-tmin -i out/default/crashes/id:000000,... -o crash_min -- ./parser_afl @@
$ xxd crash_min
00000000: 504e 4e21 0001 0121 4141 4141 4141 4141  PWN!...!AAAAAAAA
00000010: 4141 4141 4141 4141 4141 4141 4141 4141  AAAAAAAAAAAAAAAA
...

רואים בבירור: PWN! (מספר קסם), \x00 (version), \x01 (count), ואז רשומה אחת: \x01 (type=name), \x21 (len=33), ואחריה 33+ בתים של A. כלומר len=0x21=33 גדול מ-32 - וזה מספיק כדי לגלוש.

בדיקה ב-gdb. על בינ' רגיל (עם canary כברירת מחדל של gcc/clang), נראה זיהוי stack-smashing:

$ gcc -g -o parser_plain parser.c        # with -fstack-protector by default
$ gdb --args ./parser_plain crash_min
(gdb) run
*** stack smashing detected ***: terminated
Program received signal SIGABRT.
(gdb) bt
#0 ... in __pthread_kill
#4 0x... in __stack_chk_fail
#5 0x... in handle_name (data=..., len=33 '!') at parser.c:18
#6 0x... in parse (buf=..., n=...) at parser.c:31

גם ה-canary וגם ASAN מצביעים על אותו מקום: handle_name, ה-memcpy מציף את name.

שורש הבאג והתיקון. שורש הבאג: handle_name מעתיקה len בתים (עד 255) לתוך buffer קבוע של 32 בתים בלי לבדוק ש-len קטן מהbuffer. התיקון - להגביל את len לגודל הbuffer (כולל מקום ל-null):

static void handle_name(const uint8_t *data, uint8_t len) {
    char name[32];
    if (len > sizeof(name) - 1)      // the fix: clamp to the buffer size
        len = sizeof(name) - 1;
    memcpy(name, data, len);
    name[len] = '\0';
    printf("name = %s\n", name);
}

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

ניצוליות. במקרה הזה מדובר בoverflow מחסנית לינארית שנשלטת במלואה על ידי התוקף (תוכן ואורך), עם ההגנות במצב ברירת-מחדל: NX פעיל, ויש canary. הדריסה עוברת דרך ה-canary ולכן במצב הזה זו קריסת abort ולא השתלטות מיידית. בלי canary (הידור עם -fno-stack-protector), אותה overflow הופכת ל-ret2win/ROP קלאסי כמו שלמדנו בפרקים 2 ו-4 - וזו בדיוק הסיבה ש-fuzzing הוא רק מוצא החולשות, וההערכה אם הן מנוצלות היא צעד נפרד.

פתרון תרגיל 5 - בונוס: מצב מתמשך ומילון

רתמת persistent mode:

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

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

__AFL_FUZZ_INIT();

int main(void) {
    __AFL_INIT();
    unsigned char *buf = __AFL_FUZZ_TESTCASE_BUF;   // outside the loop!
    while (__AFL_LOOP(10000)) {
        int len = __AFL_FUZZ_TESTCASE_LEN;
        parse(buf, (size_t)len);
    }
    return 0;
}
$ afl-clang-fast -g -O2 -DPERSISTENT -o parser_persist harness_persist.c parser.c
$ afl-fuzz -i in -o out2 -- ./parser_persist

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

מילון:

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

הרצה מקבילה על כמה ליבות:

$ afl-fuzz -i in -o out -M main    -m none -- ./parser_afl @@   # terminal 1
$ afl-fuzz -i in -o out -S sec1    -m none -- ./parser_afl @@   # terminal 2
$ afl-fuzz -i in -o out -S sec2    -m none -- ./parser_afl @@   # terminal 3

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

איך להכליל. התהליך שעברנו כאן זהה לכל מטרה אמיתית: מזהים את שער-הכניסה של הformat ומזינים לו זרע/מילון, מהדרים עם אינסטרומנטציה ו-ASAN, מריצים עד שיש קריסות, ואז עושים טריאז' ל-ASAN + gdb + afl-tmin כדי להגיע לשורש. ההבדל היחיד בקוד אמיתי הוא הגודל - שם הרתמה, הזרעים והמילון הם מה שקובע אם הפאזינג ייתקע בשער הראשון או יחפור עמוק לתוך הלוגיקה.