阅读时间: 8-10 分钟
前置知识: 理解内存映射文件检测原理、C/C++基础、Windows API基础
学习目标: 掌握PE文件结构解析,实现修复导入表丶重定位表


配套视频教程

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

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

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

DLL依赖的函数从哪来?手工映射的基址重定位与IAT修复

承接上节

回顾:手工映射分为三期讲解:

  • 上篇:PE结构基础 + 内存镜像构建 ✅
  • 中篇(本期):基址重定位 + 导入表修复
  • 下篇:远程写入 + 线程劫持触发DllMain

上节课,我们完成了:

  • ✅ 读取PE文件并验证签名
  • ✅ 构建内存镜像(文件布局 → 内存布局)

但内存镜像构建好了,还不能直接执行,因为有两个问题:

问题1:代码中的绝对地址是基于编译时的 ImageBase 计算的,如果实际加载地址不同,这些地址全都错了!

问题2:DLL调用的外部函数(如 MessageBoxA)地址还是空的!

这节课我们来解决这两个问题。


基址重定位

为什么需要重定位?

DLL编译时,编译器会假设它加载到一个固定地址(ImageBase),代码中的绝对地址都是基于这个假设计算的。

问题场景

编译时:
  ImageBase = 0x180000000(首选基址)
  代码中有一条指令:mov rax, [0x180005000]  // 访问全局变量g_Data

实际加载时:
  VirtualAllocEx 分配的地址 = 0x7FF800000000(随机地址)

问题:
  这条指令还在访问 0x180005000,但g_Data实际在 0x7FF800005000!
  → 程序崩溃

类比

  • ImageBase = 公司原定办公地址 “中关村100号”
  • 实际加载地址 = 公司搬到 “望京200号”
  • 重定位 = 把所有文件中的 “中关村100号” 改成 “望京200号”

重定位表结构

重定位信息存放在 DataDirectory[5]IMAGE_DIRECTORY_ENTRY_BASERELOC)指向的位置。

┌─────────────────────────────────────────────────────────────┐
│  重定位表整体结构                                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────┐                   │
│  │  IMAGE_BASE_RELOCATION (块1)        │                   │
│  │    VirtualAddress = 0x1000 (页基址) │                   │
│  │    SizeOfBlock = 0x0018 (块大小)    │                   │
│  │    ┌─────────────────────────────┐  │                   │
│  │    │ 重定位项[0] = 0xA234        │  │                   │
│  │    │ 重定位项[1] = 0xA456        │  │                   │
│  │    │ 重定位项[2] = 0x0000 (填充) │  │                   │
│  │    └─────────────────────────────┘  │                   │
│  └─────────────────────────────────────┘                   │
│                                                             │
│  ┌─────────────────────────────────────┐                   │
│  │  IMAGE_BASE_RELOCATION (块2)        │                   │
│  │    VirtualAddress = 0x2000 (下一页) │                   │
│  │    ...                              │                   │
│  └─────────────────────────────────────┘                   │
│                                                             │
│  ┌─────────────────────────────────────┐                   │
│  │  全0结构 (结束标志)                 │                   │
│  │    VirtualAddress = 0x0000          │                   │
│  └─────────────────────────────────────┘                   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

重定位项的解析

每个重定位项是2字节(16位),需要拆分成两部分:

┌─────────────────────────────────────────────────────────────┐
│  重定位项结构(2字节)                                       │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  示例:重定位项 = 0xA234                                    │
│                                                             │
│  二进制:1010 0010 0011 0100                                │
│          ^^^^                                               │
│          高4位 = 类型                                       │
│               ^^^^ ^^^^ ^^^^                                │
│               低12位 = 页内偏移                             │
│                                                             │
│  拆分方法:                                                 │
│    类型 = 0xA234 >> 12 = 0xA                               │
│    偏移 = 0xA234 & 0x0FFF = 0x234                          │
│                                                             │
│  类型含义:                                                 │
│    0x0 = IMAGE_REL_BASED_ABSOLUTE(填充项,跳过)          │
│    0x3 = IMAGE_REL_BASED_HIGHLOW(x86,修改4字节)         │
│    0xA = IMAGE_REL_BASED_DIR64(x64,修改8字节)★常用      │
│                                                             │
│  完整含义:                                                 │
│    在当前页(VirtualAddress)的0x234偏移处                   │
│    有一个8字节(DIR64)地址需要修正                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

重定位公式

新地址 = 旧地址 + delta
delta = 实际加载基址 - 编译时ImageBase

示例:
  编译时 ImageBase = 0x180000000
  实际加载到 = 0x7FF800000000
  delta = 0x7FF800000000 - 0x180000000 = 0x7FF680000000

  原地址 0x180005000 + delta = 0x7FF800005000 ✓

代码实现

/**
 * @brief 处理基址重定位
 * @param pMemoryImage 内存镜像基址
 * @param pRemoteBase 远程基址(实际加载地址)
 * @return 成功返回TRUE
 */
BOOL ProcessRelocation(LPVOID pMemoryImage, LPVOID pRemoteBase)
{
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pMemoryImage;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pMemoryImage + pDosHeader->e_lfanew);
    // 计算基址差 delta = 实际基址 - 编译时基址
    ULONGLONG originalBase = pNtHeaders->OptionalHeader.ImageBase;
    ULONGLONG newBase = (ULONGLONG)pRemoteBase;
    LONGLONG delta = newBase - originalBase;
    // 如果加载到首选基址,无需重定位
    if (delta == 0) return TRUE;
    // 获取重定位表
    DWORD relocRVA = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress;
    if (relocRVA == 0) return TRUE;  // 无重定位表(少见)
    // 遍历重定位块
    PIMAGE_BASE_RELOCATION pReloc = (PIMAGE_BASE_RELOCATION)((BYTE*)pMemoryImage + relocRVA);
    while (pReloc->VirtualAddress)  // 以VirtualAddress=0的块作为结束标志
    {
        // 计算当前块有多少个重定位项
        // 块大小 - 块头大小 = 重定位项数组大小
        DWORD numEntries = (pReloc->SizeOfBlock - sizeof(IMAGE_BASE_RELOCATION)) / sizeof(WORD);
        PWORD pRelocData = (PWORD)((BYTE*)pReloc + sizeof(IMAGE_BASE_RELOCATION));
        for (DWORD i = 0; i < numEntries; i++)
        {
            // 拆分:高4位=类型,低12位=页内偏移
            WORD relocType = pRelocData[i] >> 12;
            WORD offset = pRelocData[i] & 0x0FFF;
            if (relocType == IMAGE_REL_BASED_DIR64)  // 0xA
            {
                // x64:需要修改的是8字节地址
                ULONGLONG* pPatchAddr = (ULONGLONG*)((BYTE*)pMemoryImage + pReloc->VirtualAddress + offset);
                *pPatchAddr += delta;  // 旧地址 + delta = 新地址
            }
            else if (relocType == IMAGE_REL_BASED_HIGHLOW)  // 0x3
            {
                // x86:需要修改的是4字节地址
                DWORD* pPatchAddr = (DWORD*)((BYTE*)pMemoryImage + pReloc->VirtualAddress + offset);
                *pPatchAddr += (DWORD)delta;
            }
            // IMAGE_REL_BASED_ABSOLUTE (0x0) 是填充项,用于对齐,直接跳过
        }
        // 移动到下一个重定位块
        pReloc = (PIMAGE_BASE_RELOCATION)((BYTE*)pReloc + pReloc->SizeOfBlock);
    }
    return TRUE;
}

导入表原理

为什么需要IAT修复

当DLL调用外部函数时,编译器不知道这些函数在哪里。它只会在PE文件中记录一个”待填充”的地址槽。

类比

  • 导入表 = 通讯录(只有姓名,没有电话号码)
  • IAT修复 = 查电话簿,把号码填上
  • GetProcAddress = 电话簿查询服务

正常加载时,Windows加载器会帮我们填充这些地址。手工映射必须自己做。

导入表结构

PE文件中的导入信息由两个核心结构组成:

┌───────────────────────────────────────────────────────────┐
│  IMAGE_IMPORT_DESCRIPTOR(每个依赖DLL一个)               │
│    ├─ Name: "KERNEL32.dll"(依赖的DLL名称RVA)           │
│    ├─ OriginalFirstThunk: → INT(导入名称表)            │
│    └─ FirstThunk: → IAT(导入地址表,需要填充)          │
├───────────────────────────────────────────────────────────┤
│                                                           │
│  INT(导入名称表)              IAT(导入地址表)         │
│  ┌─────────────────┐           ┌─────────────────┐       │
│  │ → "LoadLibraryA"│           │ 0x00000000      │ ← 待填│
│  │ → "GetProcAddr" │           │ 0x00000000      │ ← 待填│
│  │ → 序号#123     │           │ 0x00000000      │ ← 待填│
│  │ NULL           │           │ NULL            │       │
│  └─────────────────┘           └─────────────────┘       │
│                                                           │
│  加载后:                                                 │
│  ┌─────────────────┐           ┌─────────────────┐       │
│  │ → "LoadLibraryA"│           │ 0x7FF812340000  │ ← 已填│
│  │ → "GetProcAddr" │           │ 0x7FF812345678  │ ← 已填│
│  └─────────────────┘           └─────────────────┘       │
└───────────────────────────────────────────────────────────┘

两种导入方式

方式

特征

示例

按名称导入

高位为0

GetProcAddress(hDll, "LoadLibraryA")

按序号导入

高位为1(IMAGE_ORDINAL_FLAG

GetProcAddress(hDll, (LPCSTR)123)


找到了盲区:ASLR问题

什么是ASLR

ASLR(Address Space Layout Randomization):地址空间布局随机化。

每次系统启动时,系统DLL(如 kernel32.dll)的加载地址都不同。

问题:我们在本地进程获取的函数地址,在目标进程中可能不一样!

┌─────────────────────────────────────────────────────────┐
│  本地进程                      目标进程                 │
├─────────────────────────────────────────────────────────┤
│  kernel32.dll @ 0x7FF8A0000000  kernel32.dll @ 0x7FF8B0000000 │
│  LoadLibraryA @ 0x7FF8A0012345  LoadLibraryA @ 0x7FF8B0012345 │
│                                                         │
│  偏移相同!0x12345 = 0x12345                            │
└─────────────────────────────────────────────────────────┘

解决方案

虽然基址不同,但函数在DLL内部的偏移是相同的

计算公式

目标函数地址 = 目标DLL基址 + (本地函数地址 - 本地DLL基址)

动手实现

步骤1:获取目标进程的模块基址

首先,我们需要知道依赖的DLL在目标进程中的加载地址。

/**
 * @brief 获取目标进程中指定模块的基址
 * @param dwProcessId 目标进程ID
 * @param moduleName 模块名称(如"kernel32.dll")
 * @return 成功返回模块基址,失败返回NULL
 */
HMODULE GetRemoteModuleHandle(DWORD dwProcessId, const char* moduleName)
{
    // 创建模块快照
    HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, dwProcessId);
    if (hSnapshot == INVALID_HANDLE_VALUE)
    {
        return NULL;
    }
    // 转换为宽字符
    WCHAR wModuleName[MAX_PATH];
    MultiByteToWideChar(CP_ACP, 0, moduleName, -1, wModuleName, MAX_PATH);
    // 遍历模块列表
    MODULEENTRY32 me32;
    me32.dwSize = sizeof(MODULEENTRY32);
    if (Module32First(hSnapshot, &me32))
    {
        do
        {
            if (_wcsicmp(me32.szModule, wModuleName) == 0)
            {
                CloseHandle(hSnapshot);
                return me32.hModule;  // 返回模块基址
            }
        } while (Module32Next(hSnapshot, &me32));
    }
    CloseHandle(hSnapshot);
    return NULL;
}

为什么用 CreateToolhelp32Snapshot

  • GetModuleHandle 只能获取本进程的模块
  • CreateToolhelp32Snapshot 可以枚举其他进程的模块

步骤2:修复导入地址表(IAT)

这是导入表修复的核心逻辑:

/**
 * @brief 修复导入地址表(IAT)
 * @param pMemoryImage 内存镜像基址
 * @param pRemoteBase 远程基址
 * @param dwProcessId 目标进程ID
 * @return 成功返回TRUE
 */
BOOL FixImportTable(LPVOID pMemoryImage, LPVOID pRemoteBase, DWORD dwProcessId)
{
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pMemoryImage;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pMemoryImage + pDosHeader->e_lfanew);
    // 获取导入表RVA
    DWORD importRVA = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
    if (importRVA == 0)
    {
        return TRUE;  // 无导入表
    }
    // 遍历导入描述符(以全0描述符结尾)
    PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)pMemoryImage + importRVA);
    while (pImportDesc->Name)
    {
        // 获取依赖DLL名称
        char* dllName = (char*)((BYTE*)pMemoryImage + pImportDesc->Name);
        // 在本地加载依赖DLL(获取函数偏移)
        HMODULE hLocalModule = LoadLibraryA(dllName);
        if (!hLocalModule)
        {
            pImportDesc++;
            continue;
        }
        // 获取目标进程中该DLL的基址
        HMODULE hRemoteModule = GetRemoteModuleHandle(dwProcessId, dllName);
        if (!hRemoteModule)
        {
            // 如果目标进程没有这个DLL,假设基址相同
            hRemoteModule = hLocalModule;
        }
        // 获取INT和IAT指针
        PIMAGE_THUNK_DATA pOriginalThunk = (PIMAGE_THUNK_DATA)((BYTE*)pMemoryImage + pImportDesc->OriginalFirstThunk);
        PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((BYTE*)pMemoryImage + pImportDesc->FirstThunk);
        // 遍历所有导入函数
        while (pOriginalThunk->u1.AddressOfData)
        {
            LPVOID pLocalFunc = NULL;
            // 判断导入方式
            if (pOriginalThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG)
            {
                // 按序号导入
                WORD ordinal = IMAGE_ORDINAL(pOriginalThunk->u1.Ordinal);
                pLocalFunc = GetProcAddress(hLocalModule, (LPCSTR)ordinal);
            }
            else
            {
                // 按名称导入
                PIMAGE_IMPORT_BY_NAME pImportName = (PIMAGE_IMPORT_BY_NAME)((BYTE*)pMemoryImage + pOriginalThunk->u1.AddressOfData);
                pLocalFunc = GetProcAddress(hLocalModule, pImportName->Name);
            }
            if (pLocalFunc)
            {
                // 【关键】计算跨进程地址
                ULONGLONG offset = (ULONGLONG)pLocalFunc - (ULONGLONG)hLocalModule;
                ULONGLONG remoteFunc = (ULONGLONG)hRemoteModule + offset;
                // 填充到IAT
                pThunk->u1.Function = remoteFunc;
            }
            pOriginalThunk++;
            pThunk++;
        }
        FreeLibrary(hLocalModule);
        pImportDesc++;
    }
    return TRUE;
}

核心逻辑图解

┌─────────────────────────────────────────────────────────────┐
│  IAT修复流程                                                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. 遍历导入描述符(每个依赖DLL一个)                       │
│     │                                                       │
│     ├─ 获取DLL名称(如 "kernel32.dll")                    │
│     │                                                       │
│  2. ├─ 本地加载该DLL → hLocalModule                        │
│     │                                                       │
│  3. ├─ 获取目标进程中该DLL基址 → hRemoteModule             │
│     │                                                       │
│  4. └─ 遍历该DLL的所有导入函数                             │
│          │                                                  │
│          ├─ GetProcAddress获取本地函数地址                 │
│          │                                                  │
│          ├─ 计算偏移:offset = pLocalFunc - hLocalModule   │
│          │                                                  │
│          └─ 计算远程地址:remoteFunc = hRemoteModule + offset│
│                                                             │
│  5. 填充到IAT槽位                                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

完整流程图

┌─────────────────────────────────────────────────────────────┐
│                    手工映射完整流程                         │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────┐                                        │
│  │ 1. 读取PE文件   │ ← 上节课                              │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 2. 构建内存镜像 │ ← 上节课                              │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 3. 处理重定位   │ ← 本节课 ★                            │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 4. 修复导入表   │ ← 本节课 ★                            │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 5. 写入目标进程 │ ← 下节课                              │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 6. 设置段保护   │ ← 下节课                              │
│  └────────┬────────┘                                        │
│           ↓                                                 │
│  ┌─────────────────┐                                        │
│  │ 7. 执行DllMain  │ ← 下节课                              │
│  └─────────────────┘                                        │
│                                                             │
└─────────────────────────────────────────────────────────────┘

阶段验证

验证IAT修复

用调试器查看内存镜像中的IAT槽位:

// 打印IAT内容
void DumpIAT(LPVOID pMemoryImage)
{
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pMemoryImage;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((BYTE*)pMemoryImage + pDosHeader->e_lfanew);
    DWORD importRVA = pNtHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress;
    PIMAGE_IMPORT_DESCRIPTOR pImportDesc = (PIMAGE_IMPORT_DESCRIPTOR)((BYTE*)pMemoryImage + importRVA);
    while (pImportDesc->Name)
    {
        char* dllName = (char*)((BYTE*)pMemoryImage + pImportDesc->Name);
        std::cout << "DLL: " << dllName << "n";
        PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((BYTE*)pMemoryImage + pImportDesc->FirstThunk);
        int index = 0;
        while (pThunk->u1.Function)
        {
            std::cout << "  [" << index++ << "] = 0x" << std::hex << pThunk->u1.Function << "n";
            pThunk++;
        }
        pImportDesc++;
    }
}

预期结果

DLL: KERNEL32.dll
  [0] = 0x7FF8A0012345  ← 已填充,非0
  [1] = 0x7FF8A0023456  ← 已填充,非0
DLL: USER32.dll
  [0] = 0x7FF8B0034567  ← 已填充,非0

本节小结

我们完成了什么

步骤

内容

关键函数/API

1

处理基址重定位

ProcessRelocation()

2

获取远程模块基址

CreateToolhelp32Snapshot

3

修复导入地址表

GetProcAddress + 偏移计算

核心知识点回顾

  1. 重定位原理新地址 = 旧地址 + (实际基址 - 编译时ImageBase)
  2. 重定位项解析:高4位=类型,低12位=页内偏移(0xA = DIR64)
  3. 导入表结构IMAGE_IMPORT_DESCRIPTOR → INT/IAT
  4. 两种导入方式:按名称 vs 按序号(IMAGE_ORDINAL_FLAG
  5. ASLR处理远程地址 = 远程DLL基址 + (本地函数 - 本地DLL基址)

下节预告

内存镜像已经在本地准备好了,但还没写入目标进程。下节课我们将完成:

  • 远程写入:把内存镜像写入目标进程
  • 设置节区保护:.text=RX,.data=RW,.rdata=R
  • 线程劫持触发DllMain:不创建新线程,直接劫持现有线程执行

推荐阅读

By UD2

发表回复

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