大家好,你们可以叫我凌。
今天我们来讲讲 PE文件导出表结构 以及 ROR13哈希算法。
本文对应完整笔记:
Github笔记仓库入口https://github.com/LingBale/My-Learning-Journey/tree/114455cdac3e272f8605ed6a362fb8bc523710e9/Windows%20%E7%BA%A2%E9%98%9F%E5%AE%9E%E6%88%98/%E7%AC%AC3%E8%AF%BE.PE%E6%96%87%E4%BB%B6%E5%AF%BC%E5%87%BA%E8%A1%A8%E7%BB%93%E6%9E%84%E4%B8%8EROR13%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95
为什么要学习导出表结构及ROR13
通常我们写注入器时,会出现这样的代码:
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll"); FARPROC funcAddr = GetProcAddress(hKernel32, "CreateRemoteThread");虽很代码很简洁,但有个致命的缺陷:在二进制文件里会明文出现CreateRemoteThread字符串。
杀毒软件和 EDR 在做静态扫描时,会直接搜索二进制文件中的敏感字符串。一旦发现 "CreateRemoteThread"、"WriteProcessMemory"、"VirtualAllocEx" 这类字符串同时出现,就基本可以断定这是个注入工具,直接拉黑。
所以我们需要一种方法:不写出函数名的明文,也能找到它的地址。
这就是用哈希值代替明文函数名,手工遍历DLL的导出表,找到目标函数。
导出表是什么(回顾及深入)
一个DLL(比如 kernel32.dll)本身是个 PE 文件。PE文件里有段区域,专门记录了“这个 DLL 对外提供了哪些函数”,这段区域就叫导出表(Export Table)。
导出表里最核心的是三张“清单”:
| 清单名字 | 它存了什么 |
| 名字数组(AddressOfNames) | 每个导出函数的名字(如 "CreateFile"、"ReadFile") |
| 序号数组(AddressOfNameOrdinals) | 每个函数名对应的序号(用来和下面那张表对应) |
| 地址数组(AddressOfFunctions) | 每个导出函数的真实地址偏移量(RVA) |
它们的关系是这样的:
名字数组[0] = "CreateFile" → 序号数组[0] = 5 → 地址数组[5] = 函数地址的RVA
名字数组[1] = "ReadFile" → 序号数组[1] = 8 → 地址数组[8] = 函数地址的RVA
物理意义:你拿个函数名,先在“名字数组”里找到它的位置(索引 i),然后用索引 i 去“序号数组”里取出一个数字,最后用这个数字去“地址数组”里取出真正的函数地址。 这就是 GetProcAddress 内部在做的事。我们现在要手工把它复现遍。
ROR13 哈希讲解
如果我们不想在代码里写明文 "CreateRemoteThread",我们需要一种方法:把字符串转换 成一个数字,然后只存这个数字。
这个“把字符串变成数字”的过程就叫哈希。哈希算法有很多种,这里用最简单的一种:ROR13。
ROR13 的物理动作
ROR 是Rotate Right(循环右移)的缩写。
➢ 普通右移(>>):把二进制位往右移,右边移出去的位直接丢掉。
➢ 循环右移:把二进制位往右移,右边移出去的位补到左边来。
比如一个 32 位整数 0x00000001:
➢ 普通右移 1 位:0x00000000(丢掉了 1)
➢ 循环右移 1 位:0x80000000(1 从右边跑到左边去了)
ROR13 的意思就是:循环右移 13 位。
说再多也不如代码来的清楚,以下为 ROR13 的完整流程:
DWORD Ror13Hash(const char* str){ DWORD hash{0}; while(*str){ hash = (hash >> 13) | (hash << (32-13)); hash += (unsigned char)*str; str++; } return hash; }注意:这个过程是不可逆的。你只能从字符串算出哈希值,不能从哈希值反推出字符串。 但因为它对不同的字符串产生的哈希值几乎不可能相同(碰撞概率低),所以我们可以用哈希值来唯一标识一个函数名。
当然,目前安全厂商可能已经对 Ror13 做了防护(因为太过经典),我们也可以靠自己或AI来自定义一套自己的Hash算法,来避免杀软对 Ror13 的查杀。
涉及的核心结构体
IMAGE_DOS_HEADER
DOS头,PE文件开头
typedef struct _IMAGE_DOS_HEADER { WORD e_magic; // 0x00 ← "MZ"(0x5A4D),校验是否为有效DOS文件 WORD e_cblp; // 0x02 WORD e_cp; // 0x04 WORD e_crlc; // 0x06 WORD e_cparhdr; // 0x08 WORD e_minalloc; // 0x0A WORD e_maxalloc; // 0x0C WORD e_ss; // 0x0E WORD e_sp; // 0x10 WORD e_csum; // 0x12 WORD e_ip; // 0x14 WORD e_cs; // 0x16 WORD e_lfarlc; // 0x18 WORD e_ovno; // 0x1A WORD e_res[4]; // 0x1C WORD e_oemid; // 0x24 WORD e_oeminfo; // 0x26 WORD e_res2[10]; // 0x28 LONG e_lfanew; // 0x3C ← 指向NT头的文件偏移量(核心字段) } IMAGE_DOS_HEADER;IMAGE_FILE_HEADER
PE文件头
typedef struct _IMAGE_FILE_HEADER { WORD Machine; // 0x00 CPU架构(0x8664=x64,0x14C=x86) WORD NumberOfSections; // 0x02 节区数量 DWORD TimeDateStamp; // 0x04 编译时间戳 DWORD PointerToSymbolTable; // 0x08 DWORD NumberOfSymbols; // 0x0C WORD SizeOfOptionalHeader; // 0x10 可选头大小 WORD Characteristics; // 0x12 文件属性 } IMAGE_FILE_HEADER;IMAGE_OPTIONAL_HEADER64
64位可选头(关键)
typedef struct _IMAGE_OPTIONAL_HEADER64 { WORD Magic; // 0x00 0x20B=PE32+ BYTE MajorLinkerVersion; // 0x02 BYTE MinorLinkerVersion; // 0x03 DWORD SizeOfCode; // 0x04 DWORD SizeOfInitializedData; // 0x08 DWORD SizeOfUninitializedData; // 0x0C DWORD AddressOfEntryPoint; // 0x10 入口点RVA DWORD BaseOfCode; // 0x14 ULONGLONG ImageBase; // 0x18 默认加载基址 DWORD SectionAlignment; // 0x20 节对齐 DWORD FileAlignment; // 0x24 文件对齐 // ... 省略中间字段 DWORD NumberOfRvaAndSizes; // 0x6C 数据目录数量 IMAGE_DATA_DIRECTORY DataDirectory[16]; // 0x70 ← 数据目录数组(核心) } IMAGE_OPTIONAL_HEADER64;关键偏移:DataDirectory[0](导出表目录)位于可选头偏移 0x70 + 0x08 = 0x78 处?
实际计算:DataDirectory在结构体中的偏移是0x70,DataDirectory[0]的VirtualAddress 在 0x70,Size在0x74。但ntHeaders + 0x88定位到的是 DataDirectory[0].VirtualAddress,这 是因为ntHeaders指向的是 IMAGE_NT_HEADERS 的起始(Signature 处)。
所以 0x88 = 0x04(Signature)+ 0x14(FileHeader)+ 0x70(DataDirectory偏移)。
当然,这个偏移量稍微看下就行了,因为写代码的时候通常还是使用结构体来进行快速定位的。
IMAGE_DATA_DIRECTORY
数据目录项
typedef struct _IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; // 0x00 数据目录的RVA(相对虚拟地址) DWORD Size; // 0x04 数据目录的大小 } IMAGE_DATA_DIRECTORY;索引含义:DataDirectory[0] = 导出表,DataDirectory[1] = 导入表,DataDirectory[5] = 重 定位表。
IMAGE_EXPORT_DIRECTORY
导出表核心结构
typedef struct _IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; // 0x00 未使用,恒为0 DWORD TimeDateStamp; // 0x04 时间戳 WORD MajorVersion; // 0x08 WORD MinorVersion; // 0x0A DWORD Name; // 0x0C 模块名称的RVA(如"kernel32.dll") DWORD Base; // 0x10 起始序号 DWORD NumberOfFunctions; // 0x14 导出函数总数(含无名函数) DWORD NumberOfNames; // 0x18 ← 按名称导出的函数数量 DWORD AddressOfFunctions; // 0x1C ← 函数地址表RVA DWORD AddressOfNames; // 0x20 ← 函数名称表RVA DWORD AddressOfNameOrdinals; // 0x24 ← 函数序号表RVA } IMAGE_EXPORT_DIRECTORY;关键字段:NumberOfNames、AddressOfFunctions、AddressOfNames、AddressOfNameOrdinals 四个字段是遍历导出表的核心。
IMAGE_NT_HEADERS64
NT头完整结构
typedef struct _IMAGE_NT_HEADERS64 { DWORD Signature; // 0x00 "PE\0\0"(0x00004550) IMAGE_FILE_HEADER FileHeader; // 0x04 IMAGE_OPTIONAL_HEADER64 OptionalHeader; // 0x18 } IMAGE_NT_HEADERS64;涉及的关键宏与常量
IMAGE_DOS_SIGNATURE(0x5A4D)
➢ 含义:DOS头的有效标识,ASCII字符为 "MZ"。
➢ 用途:校验文件是否为有效的PE文件。
IMAGE_NT_SIGNATURE(0x00004550)
➢ 含义:NT头的有效标识,ASCII字符为 "PE\0\0"。
➢ 用途:在DOS头之后校验,确认是Windows PE可执行文件。
IMAGE_DIRECTORY_ENTRY_EXPORT(值为 0)
➢ 含义:数据目录数组中的导出表索引。
➢ 用途:DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] 即为导出表目录项。
IMAGE_SIZEOF_SHORT_NAME(值为 8)
➢ 含义:PE导出/导入函数名的最大长度。
➢ 用途:某些API名称超过8字节需特别注意。
完整利用链
第 1 步:从模块基址出发
HMODULE hModule → BYTE* base(模块加载基址)
│
▼
第 2 步:解析DOS头,拿到NT头偏移
e_lfanew = *(DWORD*)(base + 0x3C)
│
└── ntHeaders = base + e_lfanew
│
▼
第 3 步:从NT头定位导出表数据目录
DataDirectory[0].VirtualAddress = *(DWORD*)(ntHeaders + 0x88)
│
└── 这个RVA就是导出表相对于模块基址的偏移
│
▼
第 4 步:定位导出表结构
ExportTable = (IMAGE_EXPORT_DIRECTORY*)(base + exportRVA)
│
▼
第 5 步:从导出表读取四个关键字段
┌─────────────────────────────────────────────┐
│ NumberOfNames = ExportTable + 0x18 │
│ AddressOfFunctions = ExportTable + 0x1C │
│ AddressOfNames = ExportTable + 0x20 │
│ AddressOfNameOrdinals= ExportTable + 0x24 │
└─────────────────────────────────────────────┘
│
▼
第 6 步:构建三个数组指针(RVA → 实际地址)
nameArray = (DWORD*)(base + AddressOfNames)
ordinalArray = (WORD*)(base + AddressOfNameOrdinals)
funcArray = (DWORD*)(base + AddressOfFunctions)
│
▼
第 7 步:遍历名称数组,ROR13哈希比对
for (i = 0; i < NumberOfNames; i++) {
funcName = (char*)(base + nameArray[i]);
if (Ror13Hash(funcName) == targetHash) {
ordinal = ordinalArray[i]; // 拿到序号索引
funcRVA = funcArray[ordinal]; // 通过序号拿函数RVA
return base + funcRVA; // 返回函数绝对地址
}
}
文字总结:
➢ DOS头 → 读 e_lfanew(偏移0x3C)→ 跳转到NT头。
➢ NT头 → 读 DataDirectory[0].VirtualAddress(偏移0x88)→ 拿到导出表RVA。
➢ 导出表 → 读四个关键字段(NumberOfNames、AddressOfFunctions、 AddressOfNames、AddressOfNameOrdinals)。
➢ 三数组 → 名称数组、序号数组、地址数组。
➢ 遍历匹配 → 对每个函数名算ROR13哈希,与目标哈希比对。
➢ 命中返回 → 通过序号数组找到函数RVA,加上基址返回绝对地址。
导出表三数组结构详解
三数组的关系
AddressOfNames(名字数组)
├─ [0] → RVA("CreateRemoteThread") ─┐
├─ [1] → RVA("VirtualAllocEx") ─┤
└─ [2] → RVA("WriteProcessMemory") ─┤
│
AddressOfNameOrdinals(序号数组) │ 一一对应
├─ [0] → 5 ───────────────────┤
├─ [1] → 12 ───────────────────┤
└─ [2] → 8 ───────────────────┘
│
AddressOfFunctions(地址数组) │ 通过序号索引
├─ [5] → RVA(CreateRemoteThread函数) ←┘
├─ [8] → RVA(WriteProcessMemory函数)
└─ [12] → RVA(VirtualAllocEx函数)
三数组的职责
| 数组 | 类型 | 作用 |
| AddressOfNames | DWORD* | 存储函数名字符串的RVA |
| AddressOfNameOrdinals | WORD* | 存储函数在地址数组中的索引 |
| AddressOfFunctions | DWORD* | 存储函数地址的RVA |
遍历逻辑
for (DWORD i = 0; i < NumberOfNames; i++) { char* funcName = (char*)(base + nameArray[i]); // 第i个函数名 if (Ror13Hash(funcName) == targetHash) { WORD ordinal = ordinalArray[i]; // 该函数在地址数组中的索引 DWORD funcRVA = funcArray[ordinal]; // 函数RVA return base + funcRVA; // 绝对地址 } }代码示例
以下为相关代码示例:
(1)基于结构体
#include <windows.h> #include <iostream> #include <iomanip> #include <string> #ifdef _WIN64 // 导出表 struct MY_IMAGE_DATA_DIRECTORY { DWORD VirtualAddress; // 数据目录的 RVA DWORD Size; // 数据目录的大小 }; struct MY_IMAGE_EXPORT_DIRECTORY { DWORD Characteristics; // 未使用,恒为0 DWORD TimeDateStamp; // 时间戳 WORD MajorVersion; // 主版本号 WORD MinorVersion; // 次版本号 DWORD Name; // 模块名称的RVA DWORD Base; // 起始序号 DWORD NumberOfFunctions; // 导出函数的总数 DWORD NumberOfNames; // 按名称导出的函数数量 DWORD AddressOfFunctions; // 导出函数地址表的RVA DWORD AddressOfNames; // 导出函数名称表的RVA DWORD AddressOfNameOrdinals; // 导出函数序号表的RVA }; struct MY_IMAGE_NT_HEADERS64 { DWORD Signature; // PE 签名:0x00004550,即 "PE\0\0" IMAGE_FILE_HEADER FileHeader; // PE 文件头 IMAGE_OPTIONAL_HEADER64 OptionalHeader; // 64 位可选头 }; // ROR13 哈希计算函数 DWORD Ror13Hash(const char* str){ DWORD hash{0}; while(*str){ hash = (hash >> 13) | (hash << (32-13)); hash += (unsigned char)*str; str++; } return hash; } void* FindFuncByHash(HMODULE hModule, DWORD targetHash){ BYTE* base = (BYTE*)hModule; DWORD e_lfanew = *(DWORD*)(base + 0x3c); BYTE* ntHeaders = base + e_lfanew; MY_IMAGE_DATA_DIRECTORY* pDataDirectory_0 =(MY_IMAGE_DATA_DIRECTORY*)&((MY_IMAGE_NT_HEADERS64*)ntHeaders)->OptionalHeader.DataDirectory[0]; MY_IMAGE_EXPORT_DIRECTORY* ExportTable = (MY_IMAGE_EXPORT_DIRECTORY*)(base + pDataDirectory_0->VirtualAddress); DWORD numberOfName = ExportTable->NumberOfNames; DWORD* funcArray = (DWORD*)(base + ExportTable->AddressOfFunctions); // 地址 DWORD* nameArray = (DWORD*)(base + ExportTable->AddressOfNames); // 名字 WORD* ordinalArray = (WORD*)(base + ExportTable->AddressOfNameOrdinals); // 序号 for(DWORD i{0}; i < numberOfName; i++){ char* funcName = (char*)(base + nameArray[i]); if(Ror13Hash(funcName) == targetHash){ WORD ordinal = ordinalArray[i]; DWORD funcRVA = funcArray[ordinal]; return base + funcRVA; } } return nullptr; } int main(){ DWORD targetHash = Ror13Hash("CreateRemoteThread"); std::cout << "[*] 目标哈希值: 0x" << std::hex << std::setw(8) << targetHash << std::dec << std::endl; HMODULE hKernel32 = GetModuleHandleA("kernel32.dll"); std::cout << "[*] kernel32.dll 基址: 0x" << std::hex << (uintptr_t)hKernel32 << std::dec << std::endl; void* addr = FindFuncByHash(hKernel32, targetHash); if (addr) { std::cout << "[+] 找到 CreateRemoteThread 地址: 0x" << std::hex << (uintptr_t)addr << std::dec << std::endl; // 用 GetProcAddress 对比验证 FARPROC check = GetProcAddress(hKernel32, "CreateRemoteThread"); std::cout << "[*] GetProcAddress 拿到的地址: 0x" << std::hex << (uintptr_t)check << std::dec << std::endl; if (addr == (void*)check) { std::cout << "[+] 两个地址一致,验证成功!" << std::endl; } } else { std::cout << "[-] 未找到函数" << std::endl; } } #endif #ifndef _WIN64 int main() { std::cout << "此程序仅支持 64 位系统" << std::endl; return 0; } #endif(2)基于偏移量
#include <windows.h> #include <iostream> #include <iomanip> #include <string> // ROR13 哈希函数 DWORD Ror13Hash(const char* str) { DWORD hash = 0; while (*str) { // 循环右移 13 位 hash = (hash >> 13) | (hash << (32 - 13)); // 加上当前字符 hash += (unsigned char)*str; str++; } return hash; } void* FindFuncByHash(HMODULE hModule, DWORD targetHash) { BYTE* base = (BYTE*)hModule; // 第一步:读 e_lfanew,跳到 NT 头 DWORD e_lfanew = *(DWORD*)(base + 0x3C); BYTE* ntHeaders = base + e_lfanew; // 第二步:从 DataDirectory[0] 拿到导出表 RVA DWORD exportRVA = *(DWORD*)(ntHeaders + 0x88); // 第三步:跳到导出表 BYTE* exportDir = base + exportRVA; // 第四步:从导出表里读出四个关键字段 DWORD numberOfNames = *(DWORD*)(exportDir + 0x18); // 名字总数 DWORD addressOfFunctions = *(DWORD*)(exportDir + 0x1C); // 地址数组 DWORD addressOfNames = *(DWORD*)(exportDir + 0x20); // 名字数组 DWORD addressOfNameOrdinals = *(DWORD*)(exportDir + 0x24); // 序号数组 // 第五步:把偏移量转成真正的指针 DWORD* nameArray = (DWORD*)(base + addressOfNames); WORD* ordinalArray = (WORD*)(base + addressOfNameOrdinals); DWORD* funcArray = (DWORD*)(base + addressOfFunctions); // 第六步:遍历名字数组,算哈希,比对 for (DWORD i = 0; i < numberOfNames; i++) { char* funcName = (char*)(base + nameArray[i]); if (Ror13Hash(funcName) == targetHash) { WORD ordinal = ordinalArray[i]; DWORD funcRVA = funcArray[ordinal]; return base + funcRVA; } } return nullptr; } int main() { // 预计算 CreateRemoteThread 的哈希值 DWORD targetHash = Ror13Hash("CreateRemoteThread"); std::cout << "[*] 目标哈希值: 0x" << std::hex << std::setw(8) << std::setfill('0') << targetHash << std::dec << std::endl; // 拿 kernel32.dll 基址 HMODULE hKernel32 = GetModuleHandleA("kernel32.dll"); std::cout << "[*] kernel32.dll 基址: 0x" << std::hex << (uintptr_t)hKernel32 << std::dec << std::endl; // 用哈希查找函数 void* addr = FindFuncByHash(hKernel32, targetHash); if (addr) { std::cout << "[+] 找到 CreateRemoteThread 地址: 0x" << std::hex << (uintptr_t)addr << std::dec << std::endl; // 用 GetProcAddress 对比验证 FARPROC check = GetProcAddress(hKernel32, "CreateRemoteThread"); std::cout << "[*] GetProcAddress 拿到的地址: 0x" << std::hex << (uintptr_t)check << std::dec << std::endl; if (addr == (void*)check) { std::cout << "[+] 两个地址一致,验证成功!" << std::endl; } } else { std::cout << "[-] 未找到函数" << std::endl; } system("pause"); }那么本章节就到此为止了,我们下章见!