8.2 - הקשר אבטחה והגבהה - הרצאה
אותו קוד וזהות אחרת¶
נניח ששני לקוחות מבקשים משירות לקרוא קובץ. הלקוח הראשון רשאי לקרוא אותו והלקוח השני אינו רשאי. הקוד של השירות זהה בשתי הבקשות, וגם שם הקובץ זהה. מה שצריך להשתנות הוא הקשר האבטחה שבו הפעולה מתבצעת.
בהרצאה הקודמת ראינו שהרשאות נבדקות מול token. עכשיו נבדוק איפה ה־token הזה נמצא, מה קורה כשמשנים את זהות התהליכון ומה חייב לקרות לפני שהוא חוזר לטפל בבקשה הבאה. זאת שאלה של מחזור חיים, ממש כמו buffer שאסור לשחרר לפני סיום הפעולה שמשתמשת בו.
התהליך והתהליכון¶
לתהליך יש primary token. כאשר תהליכון אינו מתחזה ללקוח, פעולותיו משתמשות בדרך כלל בהקשר האבטחה של התהליך. תהליכון יכול לקבל impersonation token שמתאר הקשר אחר שבו הוא פועל. השינוי אינו מחליף את כל התהליך בתהליך חדש ואינו משנה אוטומטית את הזהות של כל ה־threadים האחרים.
חשוב להפריד בין החזקת handle ל־token לבין הפעלתו כהקשר האבטחה של התהליכון. גם אם פתחנו token של לקוח, עצם ההחזקה ב־handle אינה גורמת לפתיחת הקובץ הבאה להשתמש בו. היישום צריך לבצע את המעבר המתאים ולבדוק שהוא הצליח.
הפונקציות שנפגוש מציינות גם רמות impersonation. הרמה של identification מאפשרת זיהוי ובדיקות מתאימות, אבל אינה זהה להרשאה לפעול מקומית בשם הלקוח. הרמה של delegation מתייחסת ליכולת להמשיך את הזהות מעבר למחשב המקומי בתנאים המתאימים. אין להסיק מהשם של הרמה שסיסמה זמינה לתוכנית או שכל שרת מרוחק יקבל את הזהות.
ניסוי ראשון עם הזהות שלנו¶
נשתמש ב־ImpersonateSelf כדי ליצור מצב שקל לחקור: התהליכון פועל באמצעות impersonation token של ההקשר שלו עצמו. המשתמש נשאר אותו משתמש, אבל אפשר לראות שהמקום שממנו מגיע ההקשר השתנה. בניסוי הזה אין צורך בחשבון נוסף או בלקוח רשת.
שמרו את הקוד בקובץ identity.cpp:
#include <windows.h>
#include <cstdio>
#include <exception>
#include <stdexcept>
#include <system_error>
[[noreturn]] void fail(const char* operation) {
DWORD error = GetLastError();
throw std::system_error(static_cast<int>(error),
std::system_category(), operation);
}
void print_context(const char* stage) {
HANDLE token = nullptr;
const char* origin = "thread";
if (!OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &token)) {
DWORD error = GetLastError();
if (error != ERROR_NO_TOKEN) {
SetLastError(error);
fail("OpenThreadToken");
}
origin = "process";
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token))
fail("OpenProcessToken");
}
struct close_handle {
HANDLE value;
~close_handle() { CloseHandle(value); }
} owner{token};
TOKEN_TYPE type{};
DWORD bytes = 0;
if (!GetTokenInformation(token, TokenType, &type, sizeof(type), &bytes))
fail("GetTokenInformation");
std::printf("%s: origin=%s type=%s\n", stage, origin,
type == TokenPrimary ? "primary" : "impersonation");
}
class self_impersonation {
public:
self_impersonation() {
HANDLE existing = nullptr;
if (OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, TRUE, &existing)) {
CloseHandle(existing);
throw std::logic_error("nested impersonation is unsupported");
}
if (GetLastError() != ERROR_NO_TOKEN) fail("OpenThreadToken entry");
if (!ImpersonateSelf(SecurityImpersonation)) fail("ImpersonateSelf");
}
self_impersonation(const self_impersonation&) = delete;
self_impersonation& operator=(const self_impersonation&) = delete;
~self_impersonation() noexcept {
if (!RevertToSelf()) std::terminate();
}
};
int main() {
try {
print_context("before");
try {
self_impersonation scope;
print_context("inside");
throw std::runtime_error("simulated request failure");
} catch (const std::runtime_error&) {
std::puts("request failed");
}
print_context("after");
} catch (const std::exception& error) {
std::fprintf(stderr, "%s\n", error.what());
return 1;
}
}
הפקודות הבאות בונות ומריצות את הניסוי:
הפלט הצפוי מתחיל בהקשר של התהליך, עובר להקשר של התהליכון וחוזר לתהליך למרות החריגה. ההדפסה אינה מוכיחה שהמשתמש השתנה; הפעם בחרנו במפורש להתחזה לעצמנו. היא מוכיחה שהמסלול שממנו אנחנו קוראים את ה־token השתנה ונוקה.
שימו לב שהמחלקה מסרבת להיכנס כאשר כבר קיים impersonation token. אחרת, הקריאה ל־RevertToSelf לא הייתה משחזרת בהכרח את ההקשר הקודם שממנו נכנסנו. עטיפה כללית שתומכת בקינון צריכה לשמור את ההקשר הקודם ולשחזר אותו במדויק. בקוד שלנו החוזה הפשוט נאכף במקום להעמיד פנים שיש תמיכה כזאת.
הסיום הוא חלק מהפעולה¶
נניח שפתיחת הקובץ נכשלה. אם כתבתם את קריאת החזרה לזהות המקורית רק בסוף המסלול המצליח, ה־thread עלול להמשיך לבקשה הבאה תחת זהות שגויה. גם מסלול שזורק exception חייב להחזיר את ההקשר למצב שהחוזה מבטיח.
ה־destructor מדגים ניקוי תלוי scope, אבל כשל בחזרה לזהות המקורית אינו שגיאת לוג רגילה. לפי חוזה החזרה מהתחזות, התוכנית עלולה להמשיך לפעול בהקשר הלקוח אם החזרה נכשלת. לכן הניסוי מפסיק את התהליך במקרה כזה. בשירות אמיתי מגדירים מדיניות סיום ודיווח שאינה מאפשרת להמשיך לשרת בקשות בזהות לא ידועה.
אסור גם להתחיל impersonation בתהליכון אחד ולהניח שהקשר הזה יעבור אוטומטית ל־callback שירוץ ב־thread pool. הקשר אבטחה שייך כאן לתהליכון של מערכת ההפעלה, ולא למשתנה מקומי שנושא שם של בקשה. בהמשך נבנה IPC עם גבולות ברורים שמגדירים מי מאמת את הלקוח ומתי מתבצעת הפעולה בשם הלקוח.
הרשאות ורמת שלמות¶
ה־DACL אינה כל הסיפור. המנגנון של mandatory integrity מוסיף רמות שלמות ומדיניות שבין היתר יכולה למנוע כתיבה מאובייקט בעל רמה נמוכה לאובייקט בעל רמה גבוהה יותר. זאת יכולה להיות הסיבה לפעולה שנדחית למרות שרשומת allow נראית מתאימה.
הרמה נמצאת ב־token של ההקשר ובתווית האובייקט. היא אינה מספר שאומר כמה המשתמש חכם או כמה אמינה התוכנית, ואינה מחליפה את בדיקת ההרשאות הרגילה. כשחוקרים כשל, בודקים איזו שכבת מדיניות עצרה את הפעולה. אפשר לקרוא את פרטי התוויות והמדיניות בתיעוד רמות השלמות.
למה מנהל מערכת עדיין מקבל דחייה¶
משתמש ששייך לקבוצת מנהלי המערכת יכול להריץ תוכנה עם token מסונן. במצב של אישור מנהל, מנגנון ה־UAC מאפשר להפעיל פעולה מוגבהת באמצעות ההקשר המתאים לאחר תהליך האישור. זה מסביר למה אותו שם משתמש עשוי להופיע בשני חלונות, אבל הרשאותיהם אינן זהות.
במקום להניח ששם החשבון אומר הכול, בדקו את נתוני ה־token ואת רמת השלמות. בדיקה של TokenElevationType יכולה לסייע להבחין בין מצבים, אך אין להסיק שכל מחשב וכל סוג חשבון חייבים להציג זוג tokens באותה צורה. המדיניות ותצורת ההפעלה משפיעות על התוצאה. ההבחנה בין חשבון רגיל, חשבון מנהל ותהליך מוגבה מוסברת בתיאור פעולת UAC.
החלונית שמבקשת אישור אינה תחליף לגבול אבטחה של מוצר. אם אנחנו מריצים שירות חזק, הוא עדיין צריך לאמת כל פקודה ולהחליט מה מותר ללקוח. אסור להעביר אליו בקשה בנוסח "הרץ כל דבר שהלקוח מבקש" ולהסתפק בכך שמישהו אישר התקנה פעם אחת.
מורידים סמכויות לפני שמוסיפים עוד בדיקות¶
ה־restricted token מאפשר לצמצם חלק מהסמכויות של ההקשר שנמסור לרכיב. זאת החלטה תכנונית: רכיב שמפענח מסמך לא חייב לקבל את כל ההרשאות של רכיב שמתקין עדכון. הצמצום צריך לכלול גם handles שכבר פתוחים וגישה דרך IPC, מפני שהחלפת token אינה מבטלת בהכרח יכולות שכבר נמסרו.
בואו נחשוב על השירות כעל broker עם רשימת פעולות קטנה. לכל פעולה יש זהות לקוח, קלט מוגדר ומשאב מוגדר. ההרשאה מתייחסת לשלושתם יחד. בהרצאה הבאה נמשיך מהרעיון הזה לבידוד תהליכים ולהגנות על קוד שמטפל בקלט שאינו בשליטתנו.