阅读时间: 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 │ ← 已填│
│ └─────────────────┘ └─────────────────┘ │
└───────────────────────────────────────────────────────────┘
两种导入方式:
|
方式 490_64b5f3-2c> |
特征 490_375038-59> |
示例 490_17c8fc-ff> |
|---|---|---|
|
按名称导入 490_08d4b6-1a> |
高位为0 490_ed05e5-6e> |
|
|
按序号导入 490_5b8ca2-5f> |
高位为1( |
|
找到了盲区: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
本节小结
我们完成了什么
|
步骤 490_f6fb8e-9e> |
内容 490_7b77de-64> |
关键函数/API 490_0208d3-dc> |
|---|---|---|
|
1 490_1e060f-28> |
处理基址重定位 490_a91c0a-bf> |
|
|
2 490_298bfa-af> |
获取远程模块基址 490_c93985-fc> |
|
|
3 490_b4f050-b6> |
修复导入地址表 490_794bb6-48> |
|
核心知识点回顾
- 重定位原理:
新地址 = 旧地址 + (实际基址 - 编译时ImageBase) - 重定位项解析:高4位=类型,低12位=页内偏移(
0xA= DIR64) - 导入表结构:
IMAGE_IMPORT_DESCRIPTOR→ INT/IAT - 两种导入方式:按名称 vs 按序号(
IMAGE_ORDINAL_FLAG) - ASLR处理:
远程地址 = 远程DLL基址 + (本地函数 - 本地DLL基址)
下节预告
内存镜像已经在本地准备好了,但还没写入目标进程。下节课我们将完成:
- 远程写入:把内存镜像写入目标进程
- 设置节区保护:.text=RX,.data=RW,.rdata=R
- 线程劫持触发DllMain:不创建新线程,直接劫持现有线程执行
推荐阅读:
