注入攻防篇09-攻击篇-手工映射(上)-PE解析与内存镜像构建


阅读时间: 8-10 分钟
前置知识: 理解内存映射文件检测原理、C/C++基础、Windows API基础
学习目标: 掌握PE文件结构解析,实现DLL内存镜像构建


配套视频教程

本文配套视频教程已发布在 B 站,建议结合视频学习效果更佳:

💡 提示: 点击视频右下角可全屏观看,建议配合文章食用!

视频链接: https://www.bilibili.com/video/BV1AH2jBKEe8/


承接上节:为什么LDR断链还是被发现了?

上节课我们讲到,防御者为了对付LDR断链,祭出了 内存映射文件名检测 这一招。

他们的逻辑非常粗暴:直接把所有”带文件名的内存块”全扫一遍,跟PEB里的名单一一核对。只要发现这块内存里明明映射了某个DLL,但PEB里却没记录,那一定是有人在搞鬼。

这招确实狠,直接打穿了我们上一层的伪装。

但大家仔细想一下,这套检测逻辑的死穴在哪里?


攻击方的分析思路

死穴在这里:它太依赖”内存映射”这个特征了

防御者默认:所有的DLL,都必须通过系统机制映射到内存里。

为什么他们敢这么假设?

因为传统的 LoadLibrary 就是这么干的:它会调用底层API,把硬盘上的文件”投影”到内存里。这种内存块带有一个明显的标签叫 SEC_IMAGE(或者说 MEM_IMAGE 类型),扫描器一抓一个准。

打破思维定势

关键发现:上节课我们只断了PEB链,但DLL文件还在内存里”挂着”(映射),一查就露馅。

那如果我们换个思路呢?

如果不调用 LoadLibrary,也不走系统的文件映射,而是直接简单粗暴地申请一块 私有内存VirtualAlloc),然后把DLL像读TXT文本一样读进去,自己把代码铺好。

手工映射的行为:
┌──────────────────────────────────────────────────────────┐
│  VirtualAllocEx(..., MEM_COMMIT, PAGE_READWRITE)         │
│         ↓                                                │
│  ReadFile(hDllFile, ...)  // 像读普通文件一样读DLL       │
│         ↓                                                │
│  内存块属性 = MEM_PRIVATE(普通私有内存)                │
│         ↓                                                │
│  防御者查询这块内存时看到:                              │
│  "这就是一块普通的私有数据,没有文件名,没有模块特征"    │
└──────────────────────────────────────────────────────────┘

这时候,在系统和扫描器眼里,这块内存就是一堆毫无意义的”私有数据”,没有文件名,没有模块特征,自然也就躲过了”内存映射检测”。


绕过方案

重新定义LoadLibrary

这听起来很完美,但门槛也很高。

因为 LoadLibrary 虽然看着简单,背后其实是个超级勤劳的”搬运工”:

LoadLibrary 帮我们干的脏活累活:
┌─────────────────────────────────────────────────────────┐
│  1. 读取DLL文件                                          │
│  2. 解析PE结构                                           │
│  3. 在内存中展开(按4KB对齐)                            │
│  4. 修复导入表(填充外部函数地址)                       │
│  5. 处理重定位(修正绝对地址)                           │
│  6. 注册到PEB模块链表 ← 我们要跳过这步                   │
│  7. 调用DllMain入口函数                                  │
└─────────────────────────────────────────────────────────┘

一旦我们决定抛弃 LoadLibrary,步骤1-5和步骤7就得全部自己动手实现

这就是大名鼎鼎的——手工映射注入(Manual Mapping),也叫内存加载

说白了,就是我们要自己手写一个微型的Windows加载器。

路线图

这工程量不小,为了讲透,我们分三期来拆解:

期数

内容

状态

上篇(本期)

PE结构基础 + 内存镜像构建

✅ 本期完成

中篇

基址重定位 + 导入表修复 + 远程写入

下篇

线程劫持触发DllMain

用”搬家”来比喻

  • LoadLibrary = 找搬家公司(物业会登记你搬进来了)
  • 手工映射 = 自己偷偷搬运(物业根本不知道有人住进来)

好,话不多说,我们先来看第一步:如何完美复刻内存镜像。


动手实现:构建内存镜像

直奔主题:手工映射第一步——完美复刻内存镜像。

核心就一句话:DLL在硬盘里的布局,和内存里的布局,完全是两码事。

硬盘上的数据是紧凑排列的,为了省空间;内存里的数据是按页对齐的,为了配合CPU管理。你要是直接把文件内容原封不动拷过去,代码肯定跑不起来。

所以,理解PE结构,本质上就是理解”文件布局”怎么变成”内存布局”。

现在咱们动手写代码,边写边讲这些结构到底怎么用。


PE 的四部分结构

在写代码之前,先建立一个整体认知:一个 PE 文件,从头到尾就四部分

部分

结构体

一句话解释

DOS头

IMAGE_DOS_HEADER

PE文件的”门牌号”:验证文件合法性(MZ签名),并指向NT头的位置

NT头

IMAGE_NT_HEADERS

PE文件的”户口本”:记录入口点、镜像大小、节区数量等所有加载信息

节表

IMAGE_SECTION_HEADER[]

PE文件的”楼层索引”:告诉你每个节(代码/数据)在文件和内存中的位置

节数据

.text、.data、.rdata 等

PE文件的”房间内容”:实际的代码和数据,按节表的描述来定位

接下来的代码,就是按顺序处理这四部分:

  1. 验证 DOS头和NT头的签名
  2. 复制 头部(DOS头 + NT头 + 节表)
  3. 展开 节数据到内存的正确位置

第一步:读取文件,验证 DOS头 和 NT头

先把DLL文件读进来,验证它是不是合法的PE文件——也就是检查前两部分(DOS头、NT头)的签名。

/**
 * @brief 读取PE文件到内存并验证签名
 * @param filePath PE文件完整路径
 * @param pFileSize 返回文件大小
 * @return 成功返回文件缓冲区,失败返回NULL
 */
LPVOID ReadPEFile(const char* filePath, DWORD* pFileSize)
{
    // 打开文件
    HANDLE hFile = CreateFileA(filePath, GENERIC_READ, FILE_SHARE_READ,
                               NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (hFile == INVALID_HANDLE_VALUE) return NULL;
    // 获取文件大小
    DWORD fileSize = GetFileSize(hFile, NULL);
    if (fileSize == INVALID_FILE_SIZE || fileSize < sizeof(IMAGE_DOS_HEADER))
    {
        CloseHandle(hFile);
        return NULL;
    }
    // 分配内存并读取
    LPVOID pFileBuffer = VirtualAlloc(NULL, fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    DWORD bytesRead = 0;
    ReadFile(hFile, pFileBuffer, fileSize, &bytesRead, NULL);
    CloseHandle(hFile);

文件读进来了,现在要验证它是不是PE文件。

DOS头是PE文件的第一个结构,共64字节。虽然大部分字段是DOS时代的遗留(1983年的设计),但有两个字段对我们至关重要:

  • e_magic:PE文件的第一个签名,必须是 0x5A4D(即ASCII的”MZ”)
  • e_lfanew:告诉我们NT头在文件的哪个位置
    // 验证DOS签名 "MZ"
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBuffer;
    if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE)  // 0x5A4D = "MZ"
    {
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        return NULL;
    }

NT头才是PE文件的核心,包含了加载和执行所需的所有关键信息。它由三部分组成:

  • Signature:PE签名,必须是 0x4550(即”PE”)
  • FileHeader:文件头,包含节区数量、机器类型等
  • OptionalHeader:可选头(其实是必选的),包含入口点、镜像大小等
    // 通过 e_lfanew 找到NT头,验证NT签名 "PE"
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBuffer + pDosHeader->e_lfanew);
    if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE)  // 0x4550 = "PE"
    {
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        return NULL;
    }
    if (pFileSize) *pFileSize = fileSize;
    return pFileBuffer;
}

这一步的定位逻辑

pFileBuffer (文件基址)
    │
    ├──→ pDosHeader (直接就是文件开头)
    │         │
    │         └──→ e_lfanew = 0xE0 (NT头偏移)
    │
    └──→ pFileBuffer + 0xE0 = pNtHeaders

第二步:分配内存,复制头部(DOS头 + NT头 + 节表)

文件验证通过了,现在要把它”铺”到内存里。这个过程叫做构建内存镜像

第一个问题:分配多大的内存?

答案不是文件大小,而是 NT头 中的 SizeOfImage——这个字段告诉我们DLL加载到内存后占多大。

/**
 * @brief 构建PE的内存镜像(文件布局 → 内存布局)
 * @param pFileBase PE文件数据(磁盘布局)
 * @param pImageSize 返回镜像大小
 * @return 内存镜像指针,失败返回NULL
 */
LPVOID BuildMemoryImage(LPVOID pFileBase, DWORD* pImageSize)
{
    // 获取PE头信息
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBase;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBase + pDosHeader->e_lfanew);
    // 分配SizeOfImage大小的内存(不是文件大小!)
    // SizeOfImage是PE在内存中展开后的总大小
    DWORD imageSize = pNtHeaders->OptionalHeader.SizeOfImage;
    LPVOID pMemoryImage = VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!pMemoryImage) return NULL;
    // 先清零整个镜像(处理.bss等未初始化区域)
    memset(pMemoryImage, 0, imageSize);
    // 复制PE头(DOS头 + NT头 + 节表)
    // 这部分在文件和内存中布局相同,直接复制
    memcpy(pMemoryImage, pFileBase, pNtHeaders->OptionalHeader.SizeOfHeaders);

这一步用到的PE结构

字段

位置

作用

SizeOfImage

NT头.OptionalHeader

内存镜像总大小,分配内存用

SizeOfHeaders

NT头.OptionalHeader

PE头总大小,复制头部用


第三步:根据节表,展开节数据

PE头(包含节表)复制完了,现在要把第四部分——节数据(.text、.data等)复制到内存的正确位置。

这是最关键的一步,因为文件里的位置和内存里的位置不一样。而节表,正是告诉我们”从哪复制到哪”的对照表。

节表是什么? 紧跟在NT头后面的一个数组,每个元素描述一个节:

节表 (IMAGE_SECTION_HEADER[]):
┌────────────┬──────────────────────────────────────────────┐
│ 字段       │ 作用                                          │
├────────────┼──────────────────────────────────────────────┤
│ Name       │ 节名称(.text、.data等,最多8字节)           │
│ VirtualSize│ 节的实际内容大小                              │
│ VirtualAddress    │ 节在【内存】中的偏移(相对于镜像基址)  │
│ SizeOfRawData     │ 节在【文件】中占用的大小(含填充)      │
│ PointerToRawData  │ 节在【文件】中的偏移                    │
│ Characteristics   │ 节属性(可读/可写/可执行)              │
└────────────┴──────────────────────────────────────────────┘

现在遍历节表,把每个节的数据复制到正确位置:

    // 复制每个节到内存中的正确位置
    PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
    for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
    {
        // 节表里有4个关键字段,决定了"从哪复制到哪":
        // PointerToRawData = 节在【文件】中的偏移
        // VirtualAddress   = 节在【内存】中的偏移(相对于镜像基址)
        // SizeOfRawData    = 节在文件中占用的大小(按512字节对齐)
        // VirtualSize      = 节的实际内容大小
        // 目标地址:内存镜像基址 + 节的VirtualAddress
        LPVOID pDest = (BYTE*)pMemoryImage + pSection[i].VirtualAddress;
        // 源地址:文件基址 + 节的PointerToRawData
        LPVOID pSrc = (BYTE*)pFileBase + pSection[i].PointerToRawData;
        // 【关键】复制大小:取 min(SizeOfRawData, VirtualSize)
        DWORD copySize = min(pSection[i].SizeOfRawData, pSection[i].Misc.VirtualSize);
        if (copySize > 0)
        {
            memcpy(pDest, pSrc, copySize);
        }
    }
    if (pImageSize) *pImageSize = imageSize;
    return pMemoryImage;
}

为什么要用 min(SizeOfRawData, VirtualSize)

这是一个常见的坑,两种特殊情况都会出问题:

情况

VirtualSize

SizeOfRawData

问题

普通节(.text)

0x1CC2

0x1E00

SizeOfRawData 包含填充字节,不应该全复制

未初始化数据(.bss)

0x1000

0x0000

文件中根本没有数据,用VirtualSize会越界

正确做法:取两者最小值,既不会复制多余填充,也不会越界读取。


本期代码

/**
 * @brief 读取PE文件到内存并验证签名
 * @param filePath PE文件完整路径
 * @param pFileSize 返回文件大小
 * @return 成功返回文件缓冲区,失败返回NULL
 * @details 读取原始PE文件(磁盘布局),验证DOS和NT签名
 */
LPVOID ReadPEFile(const char* filePath, DWORD* pFileSize)
{
    // 打开文件
    HANDLE hFile = CreateFileA(filePath,GENERIC_READ,FILE_SHARE_READ,NULL,OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL,NULL );
    if (hFile == INVALID_HANDLE_VALUE)
    {
        std::cout << "  失败:无法打开文件,错误码: " << GetLastError() << "n";
        return NULL;
    }

    // 获取文件大小
    DWORD fileSize = GetFileSize(hFile, NULL);
    if (fileSize == INVALID_FILE_SIZE || fileSize < sizeof(IMAGE_DOS_HEADER))
    {
        std::cout << "  失败:文件大小无效n";
        CloseHandle(hFile);
        return NULL;
    }

    // 分配内存缓冲区
    LPVOID pFileBuffer = VirtualAlloc(NULL, fileSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!pFileBuffer)
    {
        std::cout << "  失败:无法分配本地内存n";
        CloseHandle(hFile);
        return NULL;
    }

    // 读取文件内容
    DWORD bytesRead = 0;
    if (!ReadFile(hFile, pFileBuffer, fileSize, &bytesRead, NULL) || bytesRead != fileSize)
    {
        std::cout << "  失败:无法读取文件内容n";
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        CloseHandle(hFile);
        return NULL;
    }
    CloseHandle(hFile);

    // 验证DOS签名
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBuffer;
    if (pDosHeader->e_magic != IMAGE_DOS_SIGNATURE)
    {
        std::cout << "  失败:无效的DOS签名(应为'MZ')n";
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        return NULL;
    }

    // 检查PE头偏移
    if (pDosHeader->e_lfanew > fileSize - sizeof(IMAGE_NT_HEADERS))
    {
        std::cout << "  失败:无效的PE头偏移n";
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        return NULL;
    }

    // 验证NT签名
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBuffer + pDosHeader->e_lfanew);
    if (pNtHeaders->Signature != IMAGE_NT_SIGNATURE)
    {
        std::cout << "  失败:无效的NT签名(应为'PE')n";
        VirtualFree(pFileBuffer, 0, MEM_RELEASE);
        return NULL;
    }

    // 返回结果
    if (pFileSize)
    {
        *pFileSize = fileSize;
    }
    return pFileBuffer;
}

/**
 * @brief 构建PE的内存镜像(文件布局 → 内存布局)
 *
 * 【为什么需要这一步】
 * PE文件在磁盘上是紧凑排列(节省空间),在内存中需要按VirtualAddress对齐。
 *
 * 【类比】
 * - 磁盘文件 = 压缩包(紧凑,但不能直接用)
 * - 内存镜像 = 解压后的文件夹(占空间,但可以执行)
 *
 * 【操作步骤】
 * 1. 分配SizeOfImage大小内存(不是文件大小!)
 * 2. 复制PE头(包含DOS头+NT头+节表)
 * 3. 复制每个节到VirtualAddress位置(使用 min(SizeOfRawData, VirtualSize))
 * 4. .bss节用0填充(VirtualSize > SizeOfRawData的部分)
 *
 * 【重要】复制大小计算:
 * - 错误做法: if (SizeOfRawData <= VirtualSize) → 会导致.text等节完全不复制
 * - 正确做法: copySize = min(SizeOfRawData, VirtualSize)
 * - 原因: 文件对齐(0x200)和内存对齐(0x1000)不同
 *   例如: .text节 VirtualSize=0x1CC2, SizeOfRawData=0x1E00
 *         如果用 <= 判断会跳过,导致代码段全是0!
 *
 * 【内存布局示意】
 * ┌─────────────┐  ← pMemoryImage (基址0x0)
 * │ PE头        │  拷贝SizeOfHeaders字节
 * ├─────────────┤  ← +VirtualAddress[0] (例如0x1000)
 * │ .text节     │  拷贝SizeOfRawData字节
 * ├─────────────┤  ← +VirtualAddress[1] (例如0x3000)
 * │ .data节     │  拷贝SizeOfRawData字节
 * ├─────────────┤
 * │ .bss节      │  用0填充VirtualSize字节(未初始化数据)
 * └─────────────┘  ← +SizeOfImage
 *
 * @param pFileBase PE文件数据(磁盘布局)
 * @param pImageSize 返回镜像大小
 * @return 内存镜像指针,失败返回NULL
 */
LPVOID BuildMemoryImage(LPVOID pFileBase, DWORD* pImageSize)
{
    // 获取PE头信息
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pFileBase;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pFileBase + pDosHeader->e_lfanew);

    // 分配SizeOfImage大小的内存(不是文件大小!)
    // SizeOfImage是PE在内存中展开后的总大小
    DWORD imageSize = pNtHeaders->OptionalHeader.SizeOfImage;
    LPVOID pMemoryImage = VirtualAlloc(NULL, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!pMemoryImage)
    {
        return NULL;
    }

    // 先清零整个镜像(处理.bss等未初始化区域)
    memset(pMemoryImage, 0, imageSize);

    // 复制PE头(DOS头+NT头+节表)
    // 这部分在文件和内存中布局相同,直接复制
    memcpy(pMemoryImage, pFileBase, pNtHeaders->OptionalHeader.SizeOfHeaders);
    std::cout << "  复制PE头: " << std::dec << pNtHeaders->OptionalHeader.SizeOfHeaders << " 字节n";

    // 复制每个节到内存中的正确位置
    PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
    for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
    {
        // 目标地址:内存镜像基址 + 节的VirtualAddress
        LPVOID pDest = (BYTE*)pMemoryImage + pSection[i].VirtualAddress;

        // 源地址:文件基址 + 节的PointerToRawData
        LPVOID pSrc = (BYTE*)pFileBase + pSection[i].PointerToRawData;

        // 复制大小:取 min(SizeOfRawData, VirtualSize)
        // 原因:文件对齐(0x200)和内存对齐(0x1000)不同,SizeOfRawData 可能 > VirtualSize
        // 例如:.text节 VirtualSize=0x1CC2, SizeOfRawData=0x1E00(向上对齐到512倍数)
        DWORD copySize = min(pSection[i].SizeOfRawData, pSection[i].Misc.VirtualSize);
        if (copySize > 0)
        {
            memcpy(pDest, pSrc, copySize);
        }

        // 处理.bss节:VirtualSize > copySize的部分用0填充
        // 例如:.data节中的未初始化全局变量
        if (pSection[i].Misc.VirtualSize > copySize)
        {
            DWORD bssSize = pSection[i].Misc.VirtualSize - copySize;
            memset((BYTE*)pDest + copySize, 0, bssSize);
        }

        // 输出节信息(Name字段不保证null结尾,需要安全处理)
        char sectionName[9] = {0};
        memcpy(sectionName, pSection[i].Name, 8);
        std::cout << "  映射节区: " << sectionName;
        for (int j = 0; j < 8 - strlen(sectionName); j++)
        {
            std::cout << " ";
        }
        std::cout << " VirtualAddress=0x" << std::hex << pSection[i].VirtualAddress;
        std::cout << " VirtualSize=" << std::dec << pSection[i].Misc.VirtualSize;
        std::cout << " (复制了 " << copySize << " 字节)n";
    }

    // 返回结果
    if (pImageSize)
    {
        *pImageSize = imageSize;
    }
    return pMemoryImage;
}

本节小结

PE 四部分 vs 我们做了什么

PE 部分

结构体

我们的操作

用到的关键字段

1. DOS头

IMAGE_DOS_HEADER

验证 “MZ” 签名

e_magice_lfanew

2. NT头

IMAGE_NT_HEADERS

验证 “PE” 签名,读取镜像大小

SignatureSizeOfImageSizeOfHeaders

3. 节表

IMAGE_SECTION_HEADER[]

遍历,确定每个节的复制参数

VirtualAddressPointerToRawDataVirtualSizeSizeOfRawData

4. 节数据

.text、.data 等

按节表描述,复制到内存正确位置

(由节表描述)

代码函数对照

函数

处理的 PE 部分

ReadPEFile()

读取整个文件,验证 DOS头、NT头

BuildMemoryImage()

复制头部(DOS头+NT头+节表),展开节数据

PE结构速查

下节预告

内存镜像构建好了,但这个镜像还不能直接运行,有两个致命问题:

问题1:地址全错了

DLL 在编译时,编译器假设它会被加载到一个”理想地址”(比如 0x10000000),代码里的跳转、全局变量访问都是按这个地址算的。

但我们用 VirtualAlloc 分配的内存,地址是随机的(比如 0x01A50000)。这时候代码里写死的地址全都指向了错误的位置。

问题2:外部函数调用是空的

DLL 里调用了 MessageBoxAGetProcAddress 这些外部函数,但编译时只留了个”占位符”,真正的地址要等加载时才填进去。

LoadLibrary 会帮我们填,但我们现在是手工加载,这些占位符还是空的——一调用就崩溃。

下节课解决这两个问题:

  • 基址重定位:把代码里的”理想地址”修正成”实际地址”
  • 导入表修复:找到外部函数的真实地址,填进占位符里
  • 将完整镜像写入目标进程

推荐阅读

By UD2

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注