如何逆向 Windows EXE/DLL:一个完整实测示例
本页目录
逆向一个 Windows EXE 或 DLL,顺序是由外到内:先确认它是原生代码还是 .NET,再读导出表和导入表,然后看资源,最后才去反汇编、反编译这些表指向的函数。下面每一步都在我们为本文编译的一个小 DLL 上实际跑过,结果都能和源码对上,而且全程不需要 Windows 机器。
实测环境:Apple silicon 上的 macOS,用 Zig 0.17.0 交叉编译 Windows 程序,LLVM 22.1.8 提供 llvm-readobj 和 llvm-objdump,另外用到第三方开源分析工具 REA 6.7.0(Node 22.22.3)。只分析自己编译的程序、开源发行版,或你有权检查的文件。
编译练习用的 DLL 和 EXE
这个 DLL 有两个导出函数,调用三个 Win32 文件 API,并带一个版本资源和一个字符串表。
greet.c:
#include <windows.h>
#include <string.h>
__declspec(dllexport) unsigned greet_checksum(const char *name) {
unsigned h = 2166136261u; /* FNV-1a */
for (; *name; name++) { h ^= (unsigned char)*name; h *= 16777619u; }
return h;
}
__declspec(dllexport) int greet_write_log(const char *path, const char *name) {
HANDLE f = CreateFileA(path, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL, NULL);
if (f == INVALID_HANDLE_VALUE) return 0;
DWORD written = 0;
const char *prefix = "greet-log: hello, ";
WriteFile(f, prefix, (DWORD)strlen(prefix), &written, NULL);
WriteFile(f, name, (DWORD)strlen(name), &written, NULL);
CloseHandle(f);
return 1;
}
greet.rc:
#include <winver.h>
1 VERSIONINFO
FILEVERSION 1,2,0,0
PRODUCTVERSION 1,2,0,0
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904B0"
BEGIN
VALUE "FileDescription", "Greet demo library"
VALUE "FileVersion", "1.2.0.0"
VALUE "ProductName", "REA practice target"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
STRINGTABLE
BEGIN
101 "Greeting log written"
END
main.c:
#include <stdio.h>
__declspec(dllimport) unsigned greet_checksum(const char *name);
__declspec(dllimport) int greet_write_log(const char *path, const char *name);
int main(int argc, char **argv) {
const char *who = argc > 1 ? argv[1] : "world";
printf("checksum=%08x\n", greet_checksum(who));
return greet_write_log("greet.log", who) ? 0 : 1;
}
Zig 自带兼容 MinGW 的工具链和资源编译器,每个文件一条命令:
zig cc -target x86_64-windows-gnu -O1 -shared -o greet.dll greet.c greet.rc -Wl,--out-implib,libgreet.a
zig cc -target x86_64-windows-gnu -O1 -o hello.exe main.c -L. -lgreet
Linux 上用 x86_64-w64-mingw32-windres 加 x86_64-w64-mingw32-gcc 效果一样,Windows 上用 rc 和 cl /LD。我们得到的 greet.dll 有 219,648 字节(SHA-256 05daa87e…bdbb2c3),其中大部分是 C 运行时代码,真正属于我们的只有那两个函数。
1. 原生还是 .NET?
扩展名说明不了任何问题。.NET 程序集要用 ILSpy 这类元数据工具来读(参见查看 .NET DLL 的目标框架),反汇编器帮不上忙。判断依据是 PE 里有没有 CLI 数据目录。REA 的托管解析器只读文件头:
rea inspect-managed-artifact "$PWD/greet.dll" --json
"pe": {"machine_hex":"0x8664","architecture":"x86_64","optional_header":"pe32-plus","section_count":8,"cli":null},
"classification": {"status":"not-managed", ...,
"evidence":[{"code":"cli-directory-absent","detail":"The PE has no CLI data directory; managed metadata and CIL are unavailable."}]}
结论是原生 x86-64,走原生分析路线。NativeAOT 编译的程序也是原生代码,怎么区分见这个 EXE 是 .NET 还是原生?。
2. 导出表和导入表
对 DLL 来说,导出表是它打算让别人调用的入口,导入表则说明它能让系统做什么。llvm-readobj(Windows 上用 dumpbin /exports /imports)可以一次打印出来:
llvm-readobj --file-headers --coff-exports --coff-imports greet.dll
Machine: IMAGE_FILE_MACHINE_AMD64 (0x8664)
ImageBase: 0x180000000
Export {
Ordinal: 1
Name: greet_checksum
RVA: 0x1000
}
Export {
Ordinal: 2
Name: greet_write_log
RVA: 0x1040
}
Import {
Name: KERNEL32.dll
ImportAddressTableRVA: 0x32588
Symbol: CloseHandle (0)
Symbol: CreateFileA (0)
Symbol: DeleteCriticalSection (0)
... 8 more ...
Symbol: WriteFile (0)
}
第一个意外是导入数量:源码只调用了三个 Win32 函数,DLL 却从五个 DLL 导入了 25 个函数,除了 KERNEL32.dll,还有四个 api-ms-win-crt-* UCRT 转发库,里面是 _initterm、calloc、fwrite、strncmp 等。这些都来自 C 运行时的启动代码。真实目标里同样会有这类噪音,所以要找的是运行时本身用不到的导入,比如这里的 CreateFileA 和 WriteFile。
EXE 的导入表则是链接的另一端:greet.dll,导入 greet_checksum 和 greet_write_log。
3. 资源
资源里有版本信息、图标、manifest、对话框和字符串表,往往是了解一个二进制“自称是什么”的最快途径。REA 的 inspect-pe-resources 不需要 Ghidra,也不需要 Windows,直接解析资源树:
rea inspect-pe-resources "$PWD/greet.dll" --json
它找到两项资源,覆盖情况为 coverage: {"status":"complete","examined_entries":6,"resources":2}:
| 类型 ID | 名称 ID | 语言 | 载荷文件偏移 | 字节数 | 含义 |
|---|---|---|---|---|---|
| 6 | 7 | 1033 | 218696 | 72 | RT_STRING 块 |
| 16 | 1 | 1033 | 218272 | 420 | RT_VERSION |
字符串表的名称 ID 是 7 而不是 101,因为字符串表按 16 条一块存放:第 101 条在第 101 / 16 + 1 = 7 块的第 101 % 16 = 5 个位置。把这段载荷按 UTF-16LE 解码,前面是五个空位,接着是长度 20 和 Greeting log written。
除了图标组,REA 不解码载荷内容,所以按它给出的位置自己读字节:
xxd -s 218272 -l 64 greet.dll
000354a0: a401 3400 0000 5600 5300 5f00 5600 4500 ..4...V.S._.V.E.
000354b0: 5200 5300 4900 4f00 4e00 5f00 4900 4e00 R.S.I.O.N._.I.N.
000354c0: 4600 4f00 0000 0000 bd04 effe 0000 0100 F.O.............
000354d0: 0200 0100 0000 0000 0200 0100 0000 0000 ................
bd04 effe 是 VS_FIXEDFILEINFO 的签名 0xFEEF04BD。再往后两个 DWORD 是 FileVersionMS = 0x00010002 和 FileVersionLS = 0,也就是 1.2.0.0。420 字节里再往后,是 UTF-16 的 FileDescription = Greet demo library 和 ProductName = REA practice target。
另外两个实测行为:默认预算是 64 MiB(--max-file-bytes)和 16,384 个目录项(--max-entries)。用 --max-entries 1 时命令以 resource_constraint 失败,并说明 “no partial inventory was returned”,所以失败不等于资源树为空。没有 .rc 的 hello.exe 则返回 complete、0 项资源。
在另一个用 Zig 编译、带图标、RCDATA 和版本资源的 EXE 上,REA 6.8.0 返回四项:RT_ICON(82 字节)、RT_RCDATA(27)、RT_GROUP_ICON(20)和 RT_VERSION(344)。ICO 文件被拆成了图像和组目录,icon_groups 把组关联到了 RT_ICON 那一项。pefile 2024.8.26 给出的四个偏移、大小和 SHA-256 与 REA 完全一致,读版本号用它最省事:
import pefile
p = pefile.PE('demo.exe')
for v in p.VS_FIXEDFILEINFO:
print('version', v.FileVersionMS >> 16, v.FileVersionMS & 65535,
v.FileVersionLS >> 16, v.FileVersionLS & 65535)
4. 读导出函数背后的代码
导出 RVA 加上映像基址就是虚拟地址:0x180001000 和 0x180001040。从这里开始反汇编:
llvm-objdump -d --no-show-raw-insn --start-address=0x180001000 --stop-address=0x1800010c0 greet.dll
0000000180001000 <greet_checksum>:
18000100f: movl $0x811c9dc5, %eax # imm = 0x811C9DC5
180001020: movzbl %dl, %edx
180001023: xorl %eax, %edx
180001025: imull $0x1000193, %edx, %eax # imm = 0x1000193
18000102b: movzbl (%rcx), %edx
...
0000000180001040 <greet_write_log>:
18000105b: movl $0x80, 0x28(%rsp)
180001063: movl $0x2, 0x20(%rsp)
18000106d: movl $0x40000000, %edx # imm = 0x40000000
180001078: callq *0x31512(%rip) # 0x180032590
...
180001097: leaq 0x27f62(%rip), %rdx # 0x180029000
18000109e: movq 0x3153b(%rip), %r14 # 0x1800325e0
1800010ac: movl $0x12, %r8d
1800010b5: callq *%r14
这几行能读出:
0x811c9dc5和0x1000193是 FNV-1a 的初始值和质数。搜索这类常量,是认出哈希或加密函数最快的办法。0x180001078处的间接调用读取0x180032590。KERNEL32 的导入地址表(IAT)从 RVA0x32588开始,这正是第二个 8 字节槽位,也就是CreateFileA。参数也对得上:0x40000000是GENERIC_WRITE,2是CREATE_ALWAYS,0x80是FILE_ATTRIBUTE_NORMAL。这个构建直接通过 IAT 调用,没有本地 thunk。0x1800325e0距表头 0x58 字节,从零数是第 11 个槽位:WriteFile。0x180029000是.rdata的起点,llvm-strings在那里找到greet-log: hello,。作为长度传入的0x12(18)就是这个字符串的strlen,编译时已经算好,所以二进制里找不到strlen调用。
从导出函数到 IAT 槽位再到导入函数,这条链回答了分析任何 DLL 的核心问题:哪个导出函数能调用到哪个系统 API。
5. 用 Ghidra 和 REA 反编译
二进制一大,就需要伪代码而不是裸反汇编。Ghidra 免费,任何系统上都能读 x86 和 x86-64 PE。REA 封装了无头 Ghidra,你自己或 AI agent(通过 MCP)可以问同样的问题:rea analyze、rea function、rea decompile、rea xrefs、rea search,都加 --provider ghidra。配置方法见 REA + Ghidra 分析原生二进制。
这次实测机器上没有安装 Ghidra(rea doctor --provider ghidra 报告 GHIDRA_INSTALL_DIR is not set),所以本文没有 Ghidra 输出。上面的常量和 IAT 槽位都直接来自机器码,可以拿来核对任何反编译器的输出或 agent 的结论。让 agent 拿出证据的提示词:
Use REA with provider ghidra on /absolute/path/to/greet.dll.
List each export and the imported Win32 APIs it can reach, with the call
address for each. Decompile greet_checksum and name the algorithm only if
the constants support it. List anything you could not resolve.
REA 6.7.0 有两个限制和这里相关。在 Windows 主机上,它的 Ghidra 支持是实验性的只读模式,只接受本地 NTFS 路径上的原生 x86/x86-64 PE,所以在 Linux 或 macOS 上跑更省事。它也没有 PDB 设置,放在二进制旁边的 .pdb 不会被加载,能直接拿到的只有导出名。
想找更大的开源练习目标,可以用 7-Zip 官方的 Windows 版本:LGPL 授权,有源码可以核对结论。