לדלג לתוכן

הגנות וחיי מיפוי

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

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

משנה הגנה בלי להזיז את הנתונים

ניצור דף, נכתוב בו ערך ונעבור למצב read only. לאחר מכן נחזיר write לפני שחרור. הקוד הבא הוא תוכנית מלאה.

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

int main() {
    SYSTEM_INFO system{};
    GetSystemInfo(&system);
    void* memory = VirtualAlloc(nullptr, system.dwPageSize,
        MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!memory) return 1;
    auto* value = static_cast<volatile unsigned char*>(memory);
    *value = 71;

    DWORD previous = 0;
    if (!VirtualProtect(memory, system.dwPageSize, PAGE_READONLY, &previous)) {
        VirtualFree(memory, 0, MEM_RELEASE);
        return 2;
    }
    std::printf("read-only value=%u old=%lx\n",
        static_cast<unsigned>(*value), previous);

    DWORD read_only = 0;
    if (!VirtualProtect(memory, system.dwPageSize, PAGE_READWRITE, &read_only)) {
        VirtualFree(memory, 0, MEM_RELEASE);
        return 3;
    }
    *value = 72;
    std::printf("writable value=%u\n", static_cast<unsigned>(*value));
    return VirtualFree(memory, 0, MEM_RELEASE) ? 0 : 4;
}
cl /nologo /std:c++20 /EHsc /W4 protect.cpp /Feprotect.exe

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

אזעקה חד־פעמית באמצעות guard

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

הניסוי הבא משתמש בהרחבת SEH של MSVC. שומרים אותו בקובץ נפרד ובונים עם MSVC ב־Windows; הוא אינו קוד C++ נייד. פונקציית הגישה אינה מכילה אובייקטים עם destructors כדי להימנע מערבוב לא מתוכנן של SEH ושל unwinding.

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

unsigned read_guarded(const volatile unsigned char* value, bool* faulted) {
    *faulted = false;
    __try {
        return *value;
    } __except (GetExceptionCode() == EXCEPTION_GUARD_PAGE ?
                EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) {
        *faulted = true;
        return 0;
    }
}

int main() {
    SYSTEM_INFO system{};
    GetSystemInfo(&system);
    void* memory = VirtualAlloc(nullptr, system.dwPageSize,
        MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!memory) return 1;
    auto* value = static_cast<volatile unsigned char*>(memory);
    *value = 19;
    DWORD previous = 0;
    if (!VirtualProtect(memory, system.dwPageSize,
                        PAGE_READWRITE | PAGE_GUARD, &previous)) {
        VirtualFree(memory, 0, MEM_RELEASE);
        return 2;
    }
    bool first_fault = false;
    bool second_fault = false;
    const unsigned first = read_guarded(value, &first_fault);
    const unsigned second = read_guarded(value, &second_fault);
    std::printf("first=%u fault=%d second=%u fault=%d\n",
        first, first_fault ? 1 : 0, second, second_fault ? 1 : 0);
    return VirtualFree(memory, 0, MEM_RELEASE) ? 0 : 3;
}

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

שמירת הגנה קודמת דורשת טווח נכון

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

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

ההרשאה לבצע קוד היא החלטה נפרדת מהרשאה לכתוב. מנוע JIT אמיתי צריך לתכנן מעברים בין כתיבה להרצה, coherency של instruction cache ומדיניות הגנות נוספות. אין סיבה לתת PAGE_EXECUTE_READWRITE ל־buffer רגיל רק כדי להימנע מהבנת הכשל.

מיפוי וחיי אובייקט אינם אותו דבר

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

כאשר בונים אובייקט מורכב ב־VirtualAlloc, משתמשים ב־construct_at או placement new עם יישור מתאים, ומפעילים destroy_at לפני שחרור המיפוי. עבור מערך אובייקטים צריך לטפל גם בבנייה חלקית שנכשלה.

ב־file mapping קיימים לפחות שני משאבים נפרדים: handle של אובייקט המיפוי ו־view במרחב הכתובות. סגירת handle אינה בהכרח מסירה view שכבר נוצר; UnmapViewOfFile היא הפעולה שמסיימת את ה־view. נעמיק בכך בשיעור המיפויים.

הגנות עוזרות למצוא את הכותב

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

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

לקריאה נוספת: שינוי הגנות דפים והתנהגות guard pages.