לדלג לתוכן

מבנה PE וטעינה לזיכרון

נניח שאנחנו רוצים למצוא את נקודת הכניסה של תוכנית בתוך הקובץ שלה. אפשר לחפש bytes שנראים כמו prolog, אבל זו השערה חלשה: פונקציה יכולה להיראות אחרת, והמידע יכול להופיע גם כנתונים. פורמט PE מספק מפה מפורשת של ה־image.

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

שלושה מספרים שנראים דומים

ההיסט בקובץ הוא מרחק מתחילת הקובץ בדיסק. ה־RVA הוא מרחק מבסיס ה־image לאחר מיפוי. הכתובת הווירטואלית בזמן ריצה היא בסיס הטעינה בפועל ועוד ה־RVA.

אם section מתחיל ב־RVA של 0x1000 וב־file offset של 0x400, אז בית במרחק 0x20 מתחילתו יהיה ב־RVA של 0x1020 ובהיסט קובץ 0x420. החיבור תקף רק אם הבית נמצא בחלק שמגובה בפועל בנתוני הקובץ. זנב וירטואלי מאופס יכול להתקיים בזיכרון בלי bytes מקבילים בדיסק.

קוראים כותרת בלי להאמין לה

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

#include <cstdint>
#include <cstdio>
#include <fstream>
#include <limits>
#include <stdexcept>
#include <vector>

class Bytes {
    std::vector<unsigned char> data_;
public:
    explicit Bytes(const char* path) {
        std::ifstream file(path, std::ios::binary | std::ios::ate);
        if (!file) throw std::runtime_error("open failed");
        const auto end = file.tellg();
        if (end < 0 || end > 64 * 1024 * 1024)
            throw std::runtime_error("invalid file size");
        data_.resize(static_cast<std::size_t>(end));
        file.seekg(0);
        if (!data_.empty() && !file.read(
                reinterpret_cast<char*>(data_.data()),
                static_cast<std::streamsize>(data_.size())))
            throw std::runtime_error("read failed");
    }
    void require(std::size_t offset, std::size_t length) const {
        if (offset > data_.size() || length > data_.size() - offset)
            throw std::runtime_error("truncated file");
    }
    std::uint16_t u16(std::size_t offset) const {
        require(offset, 2);
        return static_cast<std::uint16_t>(
            data_[offset] | (static_cast<unsigned>(data_[offset + 1]) << 8));
    }
    std::uint32_t u32(std::size_t offset) const {
        require(offset, 4);
        std::uint32_t value = 0;
        for (unsigned i = 0; i < 4; ++i)
            value |= static_cast<std::uint32_t>(data_[offset + i]) << (8 * i);
        return value;
    }
};

int main(int argc, char** argv) {
    try {
        if (argc != 2) throw std::runtime_error("supply one path");
        Bytes bytes(argv[1]);
        if (bytes.u16(0) != 0x5A4D)
            throw std::runtime_error("missing MZ");
        const std::size_t nt = bytes.u32(0x3c);
        bytes.require(nt, 24);
        if (bytes.u32(nt) != 0x00004550)
            throw std::runtime_error("missing PE signature");
        const auto machine = bytes.u16(nt + 4);
        const auto sections = bytes.u16(nt + 6);
        const auto optional_size = bytes.u16(nt + 20);
        const auto optional = nt + 24;
        bytes.require(optional, optional_size);
        if (optional_size < 20)
            throw std::runtime_error("short optional header");
        const auto magic = bytes.u16(optional);
        if (magic != 0x10b && magic != 0x20b)
            throw std::runtime_error("unsupported image");
        const auto entry = bytes.u32(optional + 16);
        std::printf("machine=%04x sections=%u entry_rva=%08x\n",
            static_cast<unsigned>(machine), static_cast<unsigned>(sections),
            static_cast<unsigned>(entry));
    } catch (const std::exception& error) {
        std::fprintf(stderr, "%s\n", error.what());
        return 1;
    }
}
cl /nologo /std:c++20 /EHsc /W4 pe_head.cpp /Fepe_head.exe
.\pe_head.exe .\pe_head.exe
dumpbin /headers pe_head.exe

הכלי משווה את חתימות MZ ו־PE ובודק אורך לפני כל קריאה. הוא אינו ממיר את ה־buffer למצביע למבנה Windows. כך איננו מניחים שהקלט מיושר, שה־packing של המהדר תואם או שאובייקט מהטיפוס כבר קיים באותו אחסון.

בדיקה מקומית אינה אישור לכל הפורמט

הכלי מדפיס שדות בסיסיים בלבד. הוא אינו מוכיח שטבלת ה־sections, כל ה־directories וה־imports תקינים. השם optional header היסטורי; ב־image שנועד לטעינה נדרש מידע מתוכו, ואסור לפרש את המילה כהיתר לדלג עליו.

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

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

הטעינה משנה את התמונה

ה־image מכיל בסיס מועדף, אך ASLR יכול להביא למיפוי בבסיס אחר. במקומות שמכילים כתובות שדורשות התאמה, מידע ה־base relocations מאפשר לטוען להוסיף את ההפרש בין הבסיס בפועל לבסיס המועדף.

לא כל כתובת בקוד דורשת relocation כזה. הפניות יחסיות יכולות להמשיך לעבוד כשה־image כולו זז יחד. גם relocation ב־COFF בזמן link אינו זהה בהכרח ל־base relocation שמופעל בזמן טעינה.

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

בודקים קובץ פגום באופן מבוקר

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

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

לקריאה נוספת: המפרט הרשמי של PE ו־COFF.