לדלג לתוכן

פגמי זיכרון ופאזינג ממוקד

שלושה סוגי כשל שדורשים שאלות שונות

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

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

פגם שאפשר לגלות בלי קוד בלתי בטוח

ניצור ספרייה בשם frame_lab באמצעות cargo new --lib frame_lab. הפורמט שלנו מתחיל באורך בעל שתי ספרות בתים בסדר רשת, ואחריו גוף בגודל המוצהר. הקוד הבא, שנשמור ב־src/lib.rs, מכיל במכוון בדיקת אורך חסרה. אין בו גישה בלתי בטוחה, אך הוא עלול להפסיק בגלל חיתוך מחוץ לטווח.

pub fn payload(data: &[u8]) -> Result<&[u8], &'static str> {
    if data.len() < 2 { return Err("short header"); }
    let length = u16::from_be_bytes([data[0], data[1]]) as usize;
    if length > 4096 { return Err("too large"); }
    Ok(&data[2..2 + length])
}

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

יעד חיפוש קטן

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

cargo fuzz init
cargo fuzz add frame

נחליף את תוכן fuzz/fuzz_targets/frame.rs בקוד הבא. המטרה היא שכל קלט יביא לתוצאה מוגדרת, בלי הפסקה בלתי מתוכננת ובלי זמן עבודה בלתי מוגבל.

#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    if let Ok(body) = frame_lab::payload(data) {
        assert!(body.len() <= 4096);
        assert_eq!(data.len(), body.len() + 2);
    }
});

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

cargo +nightly fuzz run frame -- -max_total_time=30 -max_len=4098

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

מה מנוע החיפוש אינו יודע

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

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

בדיקת גישה אחרי שחרור

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

#[inline(never)]
unsafe fn observe(pointer: *const u8) -> u8 {
    unsafe { pointer.read_volatile() }
}

fn main() {
    let allocation = Box::new(73_u8);
    let pointer = &*allocation as *const u8;
    drop(allocation);
    println!("{}", unsafe { observe(pointer) });
}

נבנה במערכת לינוקס נתמכת עם בדיקת כתובות ועם מידע ניפוי. התמיכה תלויה ביעד ובשרשרת הכלים, ולכן כשל בבנייה מחייב בירור תמיכה ולא הסקת מסקנה על תקינות הקוד.

rustc +nightly --edition=2024 -g -Zsanitizer=address \
  --target=x86_64-unknown-linux-gnu uaf_lab.rs -o uaf_lab
./uaf_lab

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

בחירת מכשור וגבולותיו

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

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

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

להפוך ממצא לידע קבוע

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

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