לדלג לתוכן

8.1 - זהות והרשאות על אובייקטים - הרצאה

מי מבקש לעשות מה

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

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

זהות שאפשר להשוות

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

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

whoami /user
whoami /groups
whoami /priv

שימו לב לעמודת המאפיינים של הקבוצות, ולא רק לשם הקבוצה. חברות שמופיעה ברשימה אינה אומרת שהיא פעילה לצורך כל בדיקת allow. למשל, קבוצה שמסומנת כ־deny-only יכולה להשתתף בבדיקת deny, אבל אינה נותנת הרשאה דרך רשומת allow.

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

המדיניות נמצאת בצד האובייקט

ה־security descriptor מתאר מידע אבטחה של אובייקט. בין השדות שלו נמצאים הבעלים, הקבוצה ורשימת ה־DACL. הרשימה הזאת מכילה ACE-ים שמגדירים הרשאות ביחס ל־SID-ים.

נבחר אובייקט לימודי קטן: רשומה בתוכנה שלנו. נגדיר שהביט הראשון מייצג קריאה והביט השני מייצג שינוי:

constexpr DWORD record_read  = 0x1;
constexpr DWORD record_write = 0x2;
constexpr DWORD record_all   = record_read | record_write;

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

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

רשימה וסדר הבדיקה

נחשוב על בקשה לשני הביטים. במהלך בדיקת ה־DACL עדיין צריך לקבל הרשאה לכל הביטים שלא אושרו. רשומת allow מתאימה יכולה לאשר חלק מהם; רשומת deny מתאימה יכולה לעצור את הבדיקה כשנותר ביט שהיא אוסרת. סדר הרשומות משמעותי, ולכן ההסבר "תמיד בודקים את כל ה־deny-ים קודם" אינו תיאור כללי של כל רשימה שנבנתה ידנית.

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

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

ניסוי שלא משנה הרשאות של קבצים

נבנה descriptor בזיכרון בלבד ונבדוק מולו את הזהות שלנו. התוכנית אינה משנה הרשאות של קובץ או של תיקייה. המחרוזת הבאה כתובה ב־SDDL, שהוא ייצוג טקסטואלי של security descriptor:

O:SYG:SYD:(A;;0x1;;;AU)

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

שמרו את התוכנית המלאה בקובץ check.cpp. הארגומנט הראשון מאפשר להחליף את המדיניות בניסויים הבאים:

#include <windows.h>
#include <sddl.h>
#include <cstdio>
#include <memory>
#include <system_error>
#include <vector>

struct token_handle {
    HANDLE value = nullptr;
    token_handle() = default;
    token_handle(const token_handle&) = delete;
    token_handle& operator=(const token_handle&) = delete;
    ~token_handle() { if (value) CloseHandle(value); }
};

struct local_free {
    void operator()(void* p) const noexcept { LocalFree(p); }
};

[[noreturn]] void fail(const char* operation) {
    const DWORD error = GetLastError();
    throw std::system_error(static_cast<int>(error),
                            std::system_category(), operation);
}

int wmain(int argc, wchar_t** argv) {
    try {
        token_handle primary;
        token_handle client;
        if (!OpenProcessToken(GetCurrentProcess(),
                              TOKEN_QUERY | TOKEN_DUPLICATE,
                              &primary.value)) fail("OpenProcessToken");
        if (!DuplicateToken(primary.value, SecurityImpersonation,
                            &client.value)) fail("DuplicateToken");

        const wchar_t* text = argc > 1
            ? argv[1] : L"O:SYG:SYD:(A;;0x1;;;AU)";
        PSECURITY_DESCRIPTOR raw = nullptr;
        if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
                text, SDDL_REVISION_1, &raw, nullptr)) fail("SDDL");
        std::unique_ptr<void, local_free> descriptor(raw);

        GENERIC_MAPPING mapping{0x1, 0x2, 0x1, 0x3};
        const DWORD requests[]{GENERIC_READ, GENERIC_WRITE,
                               GENERIC_READ | GENERIC_WRITE};
        for (DWORD request : requests) {
            MapGenericMask(&request, &mapping);
            DWORD granted = 0;
            BOOL allowed = FALSE;
            DWORD bytes = 0;
            BOOL evaluated = AccessCheck(raw, client.value, request,
                &mapping, nullptr, &bytes, &granted, &allowed);
            if (!evaluated) {
                if (GetLastError() != ERROR_INSUFFICIENT_BUFFER)
                    fail("AccessCheck size");
                std::vector<BYTE> buffer(bytes);
                auto* privileges = reinterpret_cast<PRIVILEGE_SET*>(buffer.data());
                if (!AccessCheck(raw, client.value, request, &mapping,
                                 privileges, &bytes, &granted, &allowed))
                    fail("AccessCheck");
            }
            std::printf("requested=%lu allowed=%d granted=%lu\n",
                        request, static_cast<int>(allowed), granted);
        }
    } catch (const std::exception& error) {
        std::fprintf(stderr, "%s\n", error.what());
        return 1;
    }
}

הבנייה מתבצעת מתוך מסוף כלי הפיתוח של MSVC:

cl /nologo /std:c++20 /EHsc /W4 check.cpp /link Advapi32.lib
check.exe

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

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

הבדיקה והפעולה צריכות להתייחס לאותו דבר

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

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

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

שינוי קטן שכדאי לנסות

הריצו את אותה תוכנית עם המדיניות הבאה:

check.exe "O:SYG:SYD:"

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