10.3 Fuzzing עם libFuzzer וsanitizers פתרון
פתרון - Fuzzing עם libFuzzer וsanitizers¶
נעבור תרגיל אחרי תרגיל עם הקוד המלא, הפקודות האמיתיות, והפלט הצפוי. הכתובות והגיבובים (hashes) בפלט הם דוגמה בלבד - אצלכם הם ייראו אחרת, וזה תקין.
פתרון תרגיל 1 - כתיבת ה-harness¶
ה-harness פשוט מעביר את הקלט הגולמי ל-tp_parse:
// harness.c
#include "tinyparse.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
tp_parse(data, size);
return 0;
}
שלוש הנקודות החשובות: החזרנו 0, לא נגענו ב-data מעבר ל-size (כל הפירוק קורה בתוך tp_parse, שגם היא נזהרת), ואין כאן מצב גלובלי. זה הופך את ה-harness לחסר-מצב ואת הקריסות לדטרמיניסטיות.
פתרון תרגיל 2 - בנייה עם ASan¶
פקודת ההידור:
הדגל -fsanitize=fuzzer,address עושה את שני הדברים בבת אחת: מוסיף את הנחיית הכיסוי ואת ה-main של libFuzzer, ומפעיל את AddressSanitizer. הדגל -g נותן שמות ומספרי שורות בדוח, ו--O1 שומר על מהירות סבירה בלי לקפל יותר מדי פונקציות. אם הכל תקין, נוצר בינארי הרצה בשם fuzz_tp.
פתרון תרגיל 3 - קורפוס זרע והרצה¶
יוצרים זרע תקין אחד. זה הצעד שחוסך ל-fuzzer את כל הזמן של גישוש עיוור אל מעבר ל-magic:
mkdir -p corpus
python3 -c "open('corpus/seed','wb').write(b'TP01' + bytes([1]) + bytes([0x02, 3]) + b'abc')"
הזרע הזה הוא קלט חוקי לגמרי: magic תקין, רשומה אחת מסוג 0x02 עם ערך באורך 3. הוא עובר בלי overflow, אבל הוא מלמד את ה-fuzzer את המבנה. מריצים:
הפלט (מקוצר) נראה כך, וכעבור שניות בודדות מגיעה קריסה:
INFO: Seed: 1904511731
INFO: 1 files found in corpus/
#2 INITED cov: 8 ft: 8 corp: 1/9b exec/s: 0 rss: 29Mb
#512 NEW cov: 11 ft: 14 corp: 3/24b lim: 21 exec/s: 0 rss: 30Mb L: 9/12
=================================================================
==29841==ERROR: AddressSanitizer: heap-buffer-overflow ...
מה שקרה מאחורי הקלעים: ה-fuzzer מוטט את בית ה-len של הרשומה כלפי מעלה, עבר את הבדיקה off + len > size על ידי הוספת בתים לקלט, וכך יצר len גדול מ-16 שנכתב לתוך buffer של 16 בתים.
פתרון תרגיל 4 - קריאת הדוח ואיתור השורה¶
הנה הדוח המלא (מקוצר בחלקים הלא-חשובים):
==29841==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000030
WRITE of size 40 at 0x602000000030 thread T0
#0 0x49b2a0 in memcpy (/path/fuzz_tp+0x49b2a0)
#1 0x4f1d61 in tp_parse tinyparse.c:18:13
#2 0x4f1e0a in LLVMFuzzerTestOneInput harness.c:4:5
...
0x602000000030 is located 0 bytes to the right of 16-byte region [0x602000000020,0x602000000030)
allocated by thread T0 here:
#0 0x4a1b40 in malloc
#1 0x4f1d2e in tp_parse tinyparse.c:17:24
SUMMARY: AddressSanitizer: heap-buffer-overflow tinyparse.c:18:13 in tp_parse
התשובות לשאלות:
- סוג הבאג:
heap-buffer-overflow- גלישת חוצץ ב-heap. זו לא UAF ולא double-free. - זו כתיבה:
WRITE of size 40. גודל הכתיבה תלוי בערך ה-lenשה-fuzzer מצא (כאן 40). - הפעולה הפוגעת בשורה 18 (
memcpy(name, data + off, len)), והbuffer שנדרס הוקצה בשורה 17 (malloc(16)). את שתי השורות מוצאים במחסנית: מסגרת#1נותנת את מקום הכתיבה, וחלק ה-allocated by thread T0 hereנותן את מקום ההקצאה. - גודל האזור שנדרס: 16 בתים (
16-byte region), שזה בדיוק ה-malloc(16). ההערה0 bytes to the rightאומרת שהכתיבה חרגה בדיוק מסוף האזור והלאה - כלומר ה-memcpyהתחיל לכתוב מהתחלה, מילא את 16 הבתים החוקיים, והבית ה-17 שלו הוא הבית הראשון מעבר לגבול ש-ASan תפס.
הצלבנו הכל: הקצאה קבועה של 16 בתים בשורה 17, כתיבה של len בתים (עד 255) בשורה 18. כשה-len שבקלט גדול מ-16, יש overflow. הבאג נמצא ואותר לשורה - בלי שקראנו את הקוד לחיפוש.
פתרון תרגיל 5 - שחזור ומזעור¶
שחזור הוא פשוט הרצה של הבינארי על קובץ הקריסה:
הבינארי מריץ את הקלט הזה פעם אחת, מפיל את אותה קריסה, ומדפיס את אותו דוח. זה מוכיח שהבאג דטרמיניסטי. עכשיו ממזערים:
הכלי libFuzzer מנסה לחתוך בתים שוב ושוב כל עוד הקריסה נשמרת, ושומר קובץ minimized-from-* קטן ככל האפשר. מסתכלים בו:
$ xxd minimized-from-9f2c1a4b7e...
00000000: 5450 3031 0102 1141 4141 4141 4141 4141 TP01...AAAAAAAAAA
00000010: 4141 4141 4141 4141 AAAAAAAA
מפרקים את הבתים: 54 50 30 31 הוא TP01 (ה-magic), הבית 01 הוא ספירת רשומות (רשומה אחת), הבית 02 הוא ה-type (רשומת שם), והבית 11 הוא ה-len בהקסה - כלומר 17 בעשרוני. שבעה עשר גדול מ-16, אז זו הoverflow המינימלית: בית אחד מעבר לbuffer. אחרי זה יש 17 בתי ערך (ה-AAAA...), הכרחיים כדי שהבדיקה off + len > size תעבור.
הבית שמגדיר את האורך הוא ה-len, וערכו המינימלי כדי לחרוג מ-16 הוא 17 (0x11). זו התובנה שהמזעור נתן לנו כמעט בחינם: מספיק בית len אחד ששווה 17.
פתרון תרגיל 6 (בונוס) - הוספת UBSan ומילון¶
בנייה עם UBSan בנוסף ל-ASan, כולל הדגל שהופך שגיאות סניטייזר לקריסה אמיתית:
clang -g -O1 -fsanitize=fuzzer,address,undefined -fno-sanitize-recover=all \
-o fuzz_tp_ubsan harness.c tinyparse.c
בלי -fno-sanitize-recover=all, UBSan רק מדפיס אזהרה וממשיך, וה-fuzzer לא היה יודע שקרה משהו. עם הדגל, כל UB (למשל overflow חשבון של מספר מסומן) הופך לקריסה שה-fuzzer תופס. בספרייה הזו הבאג הראשי הוא overflow ה-heap, אז ASan עדיין יתפוס אותו קודם; אבל בקוד עם חישובי אורך, UBSan תופס לרוב את שורש הבעיה מוקדם יותר.
יצירת מילון והרצה איתו:
השוואה מעניינת: אם מריצים בלי קורפוס זרע ובלי מילון (./fuzz_tp לבד), ה-fuzzer צריך לנחש ארבעה בתים מדויקים לפני שהוא בכלל נכנס ל-parser - זה 1 מתוך כ-4 מיליארד, וזה יכול לקחת המון זמן. עם המילון (או עם -use_value_profile=1), המחסום הזה כמעט נעלם.
התשובה לשאלת ההבנה: המחסום memcmp(data, "TP01", 4) הוא השוואה של הכל-או-כלום מבחינת בתים אקראיים. מילון שותל את המחרוזת TP01 שלמה בתוך הקלטים, אז ה-fuzzer מקבל את ה-magic "מתנה" במקום לנחש. פרופיל ערכים עובד אחרת אבל משיג דבר דומה: libFuzzer מפרק את ההשוואה ומתגמל קלט שהתאים 1 בית, אז 2, אז 3 - וכך הוא "מטפס" אל ההתאמה המלאה שלב-שלב במקום לנחש הכל בבת אחת.
למה זה עבד ואיך להכליל: פאזינג בתוך התהליך + ASan נתן לנו את הבאג, את סוגו, ואת השורה המדויקת שלו, בלי ניתוח ידני. המזעור צמצם את הקלט לגרעין המהותי, וזה בדיוק מה שצריך כדי לכתוב תיקון או להתחיל לבחון exploit. הדפוס הכללי חוזר בכל מטרה עם קוד מקור: כותבים harness קטן וחסר-מצב, בונים עם -fsanitize=fuzzer,address, נותנים זרע וקצת עזרה מבנית (מילון או value profile), וקוראים את דוח הסניטייזר כמו מפה אל הבאג.