תוכן עניינים
הפאזר רץ 18 חודשים. הבאג עדיין היה שם.
מאמר חדש בבלוג של GitHub מאת Antonio Morales חושף אמת לא נוחה על שיטות האבטחה שלנו: פאזינג רצוף הוא לא הפתרון הקסם שחשבנו שהוא. אפילו יוזמות ברמה עולמית כמו OSS-Fuzz, שהכו פרויקטי קוד פתוח בוגרים במשך שנים, ממשיכות להחמיץ פגיעויות קריטיות.
זה חשוב לכולנו כי הפעלנו תחת הנחה מסוכנת - שאם קודבייס עבר פאזינג רצוף, במיוחד על ידי משהו מקיף כמו OSS-Fuzz, הבאגים הקשים נעלמו. אנחנו עושים דיפלוי לספריות האלה בביטחון. אנחנו בונים את האפליקציות שלנו על גביהן. ואנחנו טועים.
המציאות הלא נוחה
Morales מצא פגיעויות קריטיות בפרויקטים שעברו פאזינג כבד כמו Gstreamer, Poppler ו-Exiv2 - כולם היו תחת פאזינג רצוף לתקופות ממושכות. אלה לא מקרי קצה נדירים בקוד שמשתמשים בו לעיתים רחוקות. אלה פגיעויות אמיתיות ששרדו מיליוני מקרי בדיקה וחודשים של בדיקות אוטומטיות.
הדוגמה הכי בולטת: Gstreamer יושב על רק 19% כיסוי קוד למרות מאמצי פאזינג רצוף, בעוד שפרויקטים כמו OpenSSL משיגים אחוזי כיסוי הרבה יותר גבוהים. אבל הנה הטוויסט - אפילו כיסוי גבוה לא מבטיח שנמצא את כל הבאגים. הבעיה עמוקה יותר מאחוזי כיסוי.
למה פאזינג סטנדרטי מחמיץ באגים
הבעיה המרכזית נמצאת במה שפאזרים סטנדרטיים בעצם מודדים. רוב הפאזרים, כולל כלים פופולריים כמו AFL++, עוקבים אחרי מה שנקרא "edge coverage" - כלומר, האם ביצענו את שורת הקוד הזו? תחשבו על זה כמו לבדוק אם פתחנו כל דלת בבניין בלי להסתכל על מה שיש בתוך החדרים.
הנה דוגמה קונקרטית. פאזר יכול לבצע פעולת חלוקה מיליון פעמים:
int result = numerator / denominator;הפאזר רואה: "מעולה! פגענו בשורה הזו מיליון פעמים עם קלטים שונים. ממשיכים הלאה." אבל אם הוא אף פעם לא ניסה לחלק בערכים קרובים לאפס, או אף פעם לא בדק את הטווח הספציפי שבו מתרחש גלישת מספרים שלמים, הוא מפספס את הקריסה לגמרי. מדד הכיסוי אומר 100%, אבל מרחב הערכים בקושי נחקר.
שלושת מצבי הכשל
Morales מזהה שלוש סיבות מרכזיות למה באגים שורדים פאזינג רצוף:
- תלויות חיצוניות חלשות - כשהקוד שלנו מסתמך על ספריות חיצוניות עם כיסוי פאזינג גרוע, אנחנו יורשים נקודות עיוורות. הקוד שלנו יכול לעבור פאזינג טוב, אבל התלות שהוא קורא לה לא.
- כיסוי לא מספק - פרויקטים רבים מרחפים סביב 20-30% כיסוי קוד. חלקים גדולים של הקוד פשוט לא מבוצעים במהלך הפאזינג, יוצרים מקלטים בטוחים לבאגים.
- מגבלות של כיסוי קצוות - זו הבעיה הבסיסית. כיסוי קצוות עוקב אחרי זרימת בקרה (אילו נתיבים עברנו) אבל מתעלם מזרימת נתונים (אילו ערכים זרמו דרך הנתיבים האלה).
תהליך העבודה בחמישה שלבים לפאזינג
המחקר מציע גישה שיטתית שעוברת מעבר לשיטות פאזינג סטנדרטיות. הנה איך אנחנו צריכים לחשוב על פאזינג של קודבייס בוגרים:
שלב 1: הכנה
אופטימיזציה של הקוד שלנו לפאזינג לפני שאנחנו מתחילים. זה אומר:
- הסרה או מוקינג של פעולות יקרות (קריאות רשת, קלט/פלט קבצים) שמאטות את הפאזינג
- יצירת harnesses ממוקדים לרכיבים שונים
- הגדרת סניטייזרים מתאימים (AddressSanitizer, UndefinedBehaviorSanitizer)
שלב 2: כיסוי
שאיפה ל->90% כיסוי קוד כקו בסיס. זה לא ניתן למשא ומתן. אם אנחנו יושבים על 20-30%, אפילו לא התחלנו למצוא את הבאגים הקשים עדיין. השתמשו בפאזינג מונחה-כיסוי כדי לחקור באופן שיטתי את הקודבייס שלנו.
שלב 3: הקשר (משנה את המשחק)
פה זה נהיה מעניין. כיסוי תלוי-הקשר (Context-Sensitive) עוקב אחרי הסדר שבו אנחנו מבצעים קוד, לא רק איזה קוד ביצענו. אנחנו משתמשים בטכניקות כמו N-Gram coverage או Branch coverage.
תחשבו על זה כמו להקליט לא רק אילו חדרים ביקרתם בבניין, אלא את המסלול הספציפי שעשיתם. הבאג עשוי להופיע רק כשאתם הולכים מחדר A → חדר C → חדר B, לא כשאתם הולכים A → B → C, גם אם שני המסלולים מבקרים בכל שלושת החדרים.
// כיסוי קצוות סטנדרטי מתייחס לאלה בצורה זהה
// נתיב 1: A() -> B() -> C()
// נתיב 2: A() -> C() -> B()
// אבל עם N-Gram coverage, אנחנו עוקבים אחרי רצפים:
// נתיב 1 מייצר: [A,B], [B,C]
// נתיב 2 מייצר: [A,C], [C,B]
// נתיבים שונים = כיסוי שונה!שלב 4: כיסוי ערכים (החלק החסר)
זו הטכניקה שתופסת באגים שפאזרים סטנדרטיים מחמיצים. כיסוי ערכים (Value Coverage) עוקב אחרי הטווחים של ערכים שזורמים דרך הקוד שלנו. במקום רק "האם ביצענו את החלוקה הזו?", אנחנו שואלים "האם ביצענו את החלוקה הזו עם ערכים קרובים לאפס? עם מספרים שלמים מקסימליים? עם מספרים שליליים?"
המימוש דורש אינסטרומנטציה מותאמת של LLVM FunctionPass. הנה הקונספט:
// פאזינג מסורתי רואה:
void process(int x) {
if (x < 100) {
// ענף A - מכוסה ✓
} else {
// ענף B - מכוסה ✓
}
}
// כיסוי ערכים עוקב:
// האם ניסינו x = 0? x = 99? x = 100? x = 101?
// האם ניסינו ערכים שליליים? MAX_INT? MIN_INT?
// כל טווח נעקב בנפרדשלב 5: טריאז'
ברגע שאנחנו מוצאים קריסות, אנחנו צריכים לקבוע אילו הן פגיעויות ניצלות לעומת כשלי assertion לא מזיקים. השלב הזה דורש ניתוח ידני אבל הוא קריטי למתן עדיפות לתיקונים.
המימוש הטכני
המאמר צולל עמוק לתוך המימוש של כיסוי ערכים באמצעות LLVM. הגישה הבסיסית:
// פאס LLVM מותאם לאינסטרומנט משתנים
class ValueCoveragePass : public FunctionPass {
void instrumentVariable(Value *V) {
// עקוב אחרי טווחי ערכים למשתנה הזה
// צור ID ייחודי למשתנה הזה
// הכנס callback להקלטת ערך בזמן ריצה
}
};
// בזמן ריצה, עקוב אחרי טווחים:
void __value_coverage_callback(uint64_t var_id, int64_t value) {
// חלק את הערך לטווחים
// עדכן bitmap כיסוי לטווח הזה
// כוון את הפאזר לכיוון טווחים לא נחקרים
}מהניסיון שלי
ראיתי את זה מתרחש בפרודקשן יותר פעמים ממה שהייתי רוצה להודות. אנחנו עושים פאזינג לרכיב ביסודיות, משיגים כיסוי גבוה, עושים דיפלוי בביטחון - ואז מקבלים דוח קריסה מפרודקשן עם שילובי קלט שמעולם לא בדקנו. הפאזר פגע בכל שורת קוד אבל אף פעם לא ניסה את השילוב הספציפי הזה של ערכים.
ההתמקדות בכיסוי ערכים מטפלת בנקודה עיוורת שהייתה לנו במשך שנים בקהילת הפאזינג. אופטימיזנו לרוחב (פגיעה בכל נתיבי הקוד) בלי מספיק עומק (חקירת מרחב הערכים בתוך הנתיבים האלה). זה כמו לבדוק מנעול על ידי בדיקה שכל הטמבלרים זזים, בלי לנסות בעצם צירופי מפתח שונים.
טיפ מעשי מהעבודה שלי: כשמיישמים כיסוי ערכים, התחילו עם הפונקציות הכי קריטיות - פרסרים, ולידטורים, כל דבר שנוגע בקלט חיצוני. הנטל של האינסטרומנטציה משמעותי, אז אנחנו לא יכולים באופן ריאליסטי ליישם את זה בכל מקום. אבל כיסוי ערכים ממוקד בנתיבי קוד בסיכון גבוה מצא לנו באגים שישבו שם במשך שנים.
בעיניי
לעניות דעתי, המחקר הזה צריך לשנות באופן בסיסי איך שאנחנו ניגשים לפאזינג של קודבייס בוגרים. אנחנו צריכים לעבור מעבר למנטליות הפשטנית "עשינו פאזינג, עושים דיפלוי" להבנה יותר ניואנסית של מה שטכניקות פאזינג שונות יכולות ולא יכולות לתפוס.
תהליך העבודה בחמישה שלבים הוא לא רק תיאוריה אקדמית - זו תוכנית מעשית למציאת הבאגים ששורדים פאזינג רצוף. השילוב של כיסוי גבוה, רגישות להקשר ומעקב אחרי ערכים נותן לנו תמונה הרבה יותר שלמה של מרחב ההתנהגות של הקוד שלנו.
מה אעשה אחרת: לכל רכיב קריטי שאנחנו עושים לו פאזינג, אני עכשיו שואל את השאלות האלה: מה אחוז הכיסוי שלנו? האם אנחנו עוקבים אחרי הקשר ביצוע? האם אנחנו חוקרים את מרחב הערכים למשתנים קריטיים? אם התשובה לאחד מאלה היא לא, עדיין לא סיימנו את הפאזינג.
קראו את הניתוח הטכני המלא עם דוגמאות מימוש מפורטות: Bugs that survive the heat of continuous fuzzing
