לדלג לתוכן

8.3 - בידוד וגבולות אמון - הרצאה

מה קורה אם מפענח הקובץ טועה

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

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

גבול של תהליך אינו מספיק לבדו

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

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

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

שלוש הגנות ושלוש שאלות שונות

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

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

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

בודקים מה התהליך באמת קיבל

נשמור את התוכנית הבאה בקובץ mitigations.cpp. היא רק קוראת את המדיניות של עצמה:

#include <windows.h>
#include <cstdio>

template<class T>
void show(PROCESS_MITIGATION_POLICY policy, const char* name) {
    T value{};
    if (!GetProcessMitigationPolicy(GetCurrentProcess(), policy,
                                     &value, sizeof(value))) {
        std::printf("%s query-error=%lu\n", name, GetLastError());
        return;
    }
    std::printf("%s flags=0x%08lx\n", name, value.Flags);
}

int main() {
    show<PROCESS_MITIGATION_ASLR_POLICY>(ProcessASLRPolicy, "ASLR");
    show<PROCESS_MITIGATION_CONTROL_FLOW_GUARD_POLICY>(
        ProcessControlFlowGuardPolicy, "CFG");
    show<PROCESS_MITIGATION_DYNAMIC_CODE_POLICY>(
        ProcessDynamicCodePolicy, "dynamic-code");
}

הפקודה הבאה מבקשת גם תמיכת CFG בזמן הקומפילציה וגם את הדגל המתאים בזמן הקישור:

cl /nologo /std:c++20 /EHsc /W4 /guard:cf mitigations.cpp /link /guard:cf /DYNAMICBASE /NXCOMPAT
mitigations.exe
dumpbin /loadconfig mitigations.exe

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

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

שם קבוע וקובץ שהתחלף

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

צרו תיקיית ניסוי חדשה והכניסו אליה קובץ:

New-Item -ItemType Directory -Path .\identity-lab
Set-Content -LiteralPath .\identity-lab\input.txt -Value 'before' -Encoding utf8NoBOM

שמרו את הקוד הבא כ־opened.cpp והריצו אותו מתוך אותה תיקיית עבודה שבה נוצרה תיקיית הניסוי:

#include <windows.h>
#include <cstdio>

int main() {
    HANDLE file = CreateFileW(L"identity-lab\\input.txt", GENERIC_READ,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
    if (file == INVALID_HANDLE_VALUE) {
        std::printf("open error=%lu\n", GetLastError());
        return 1;
    }
    std::puts("File is open. Replace the path, then press Enter.");
    std::getchar();
    char buffer[128]{};
    DWORD received = 0;
    if (!ReadFile(file, buffer, sizeof(buffer) - 1, &received, nullptr)) {
        DWORD error = GetLastError();
        CloseHandle(file);
        std::printf("read error=%lu\n", error);
        return 1;
    }
    CloseHandle(file);
    buffer[received] = '\0';
    std::printf("opened object: %s\n", buffer);
}

הבנייה וההרצה:

cl /nologo /W4 opened.cpp
opened.exe

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

Move-Item -LiteralPath .\identity-lab\input.txt -Destination .\identity-lab\original.txt
Set-Content -LiteralPath .\identity-lab\input.txt -Value 'after' -Encoding utf8NoBOM
Get-Content -LiteralPath .\identity-lab\input.txt

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

נקודות הפניה וגבולות פתרון

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

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

בונים גבול שאפשר לבדוק

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

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