10.2 Fuzzing עם AFL פתרון
פתרון - Fuzzing עם AFL¶
נעבור תרגיל אחרי תרגיל עם הפקודות האמיתיות, הפלט הצפוי, והבאג המדויק. הכתובות והזמנים בפלט הם דוגמה - אצלכם הם ייראו מעט אחרת בין הרצות ובין גרסאות מהדר, אז תמיד תסתמכו על הפלט האמיתי אצלכם ולא תעתיקו מספרים בעיוורון.
פתרון תרגיל 1 - קריאת המטרה וניחוש הבאג¶
שלוש התשובות:
- שער הכניסה הוא
if (memcmp(buf, "PWN!", 4) != 0) return 1;. כל קלט שלא מתחיל בארבעת הבתים"PWN!"נזרק מיד, ולכן ל-fuzzer טיפש אין סיכוי מעשי להגיע לקוד המעניין (1 ל-2^32). זה בדיוק המקום שבו זרע תקין או מילון מכריעים. - הbuffer הוא
char name[32]בתוךhandle_name- 32 בתים על המחסנית. - הערך
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
בדיקת שפיות שהזרע באמת עובר את השער ומגיע להדפסה:
מצוין - הזרע חוקי ומריץ את 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 לכל קלט. שימו לב שכאן משמיטים את @@, כי הקלט מגיע בזיכרון משותף.
מילון:
הרצה מקבילה על כמה ליבות:
$ 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 כדי להגיע לשורש. ההבדל היחיד בקוד אמיתי הוא הגודל - שם הרתמה, הזרעים והמילון הם מה שקובע אם הפאזינג ייתקע בשער הראשון או יחפור עמוק לתוך הלוגיקה.