לדלג לתוכן

שירות מול משתמש

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

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

חיבור אינו הרשאה לכל פקודה

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

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

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

גבול הpipe

בעת יצירת הpipe ניתן להעביר security descriptor מפורש. חשוב לבחור זכויות שמתאימות לכיוון התקשורת. הזכות FILE_CREATE_PIPE_INSTANCE חולקת ערך עם FILE_APPEND_DATA, ולכן שימוש נוח מדי בזכויות כתיבה גנריות עלול לאפשר גם יצירת instance. הפרט הזה מופיע בתיעוד הרשאות named pipes.

במעבדה נבחר SID מפורש של חשבון השירות וSID מפורש של משתמש הבדיקה. השירות יקבל זכויות ניהול על הpipe, והלקוח יקבל רק קריאה, כתיבה וסנכרון הנדרשים לחוזה שבנינו. אם הלקוח פותח עם GENERIC_ALL, נשנה את הלקוח; לא נרחיב את הDACL רק כדי להעלים access denied.

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

שימוש בזהות של הודעה

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

נכתוב פונקציה שבודקת האם הלקוח הוא בעל העבודה. היא לא מקבלת SID מההודעה, אלא SID מתוך רשומת עבודה שהשירות מחזיק. שמרו את הקוד בקובץ authorize.cpp; זאת יחידת ספרייה שאפשר לבנות עם /c ולשלב בשרת מהפרק הקודם, אחרי קריאת בקשה ולפני כל פעולה על העבודה.

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <exception>
#include <stdexcept>
#include <string>
#include <vector>

#pragma comment(lib, "advapi32.lib")

[[noreturn]] static void fail(const char* call, DWORD error = GetLastError()) {
    throw std::runtime_error(std::string(call) + ": " + std::to_string(error));
}

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

class pipe_identity {
public:
    explicit pipe_identity(HANDLE pipe) {
        token_owner previous;
        if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &previous.value))
            throw std::runtime_error("nested impersonation is not supported");
        DWORD error = GetLastError();
        if (error != ERROR_NO_TOKEN) fail("inspect current identity", error);
        if (!ImpersonateNamedPipeClient(pipe)) fail("impersonate client");
    }
    pipe_identity(const pipe_identity&) = delete;
    pipe_identity& operator=(const pipe_identity&) = delete;
    ~pipe_identity() {
        if (!RevertToSelf()) std::terminate();
    }
};

bool client_owns_job(HANDLE connected_pipe, PSID stored_owner) {
    if (!stored_owner || !IsValidSid(stored_owner))
        throw std::invalid_argument("invalid stored owner");
    pipe_identity identity(connected_pipe);
    token_owner token;
    if (!OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &token.value))
        fail("open client token");
    DWORD required = 0;
    if (GetTokenInformation(token.value, TokenUser, nullptr, 0, &required))
        throw std::runtime_error("unexpected empty token user");
    DWORD error = GetLastError();
    if (error != ERROR_INSUFFICIENT_BUFFER) fail("token size", error);
    std::vector<BYTE> bytes(required);
    if (!GetTokenInformation(token.value, TokenUser, bytes.data(),
                             static_cast<DWORD>(bytes.size()), &required))
        fail("token user");
    auto user = reinterpret_cast<const TOKEN_USER*>(bytes.data());
    return EqualSid(user->User.Sid, stored_owner) != FALSE;
}

בנו את יחידת הספרייה כך.

cl /std:c++20 /EHsc /W4 /c authorize.cpp

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

עכשיו נחבר את הבדיקה לפעולה

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

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

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

מעבר לworker אחר

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

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