How to Reverse Engineer a Windows EXE or DLL: A Worked Example
On this page
To reverse engineer a Windows EXE or DLL, work from the outside in. First find out whether it is native code or .NET, then read its export and import tables, then its resources, and only then disassemble or decompile the functions those tables point to. Every step below runs on a small DLL we compiled for this page, so each result can be checked against the source. None of it needs a Windows machine.
Our setup: macOS on Apple silicon, Zig 0.17.0 as the Windows cross-compiler, LLVM 22.1.8 (llvm-readobj, llvm-objdump), and REA 6.7.0, a third-party open-source analysis tool, on Node 22.22.3. Only analyse binaries you built, open-source releases, or files you are otherwise allowed to examine.
Build a practice DLL and EXE
The DLL has two exports, calls three Win32 file APIs, and carries a version resource and a string table.
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 ships a MinGW-compatible toolchain and its own resource compiler, so each binary is one command:
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
On Linux, x86_64-w64-mingw32-windres plus x86_64-w64-mingw32-gcc do the same job; on Windows, rc and cl /LD. Our greet.dll came out at 219,648 bytes (SHA-256 05daa87e…bdbb2c3). Most of that is C runtime code, not our two functions.
1. Native or .NET?
The extension tells you nothing. A .NET assembly needs a metadata reader such as ILSpy (see Check a .NET DLL’s target framework), not a disassembler. The test is whether the PE has a CLI data directory. REA’s managed parser reads only the headers:
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."}]}
It is native x86-64, so the native route applies. NativeAOT builds are native code too; Is this EXE .NET or native? covers that case.
2. Exports and imports
For a DLL, the export table lists the entry points other code is meant to call. The import table shows what it can ask the system to do. llvm-readobj (or dumpbin /exports /imports on Windows) prints both:
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)
}
The first surprise is the import count. Our source calls three Win32 functions, but the DLL imports 25 functions from five DLLs: KERNEL32.dll plus four api-ms-win-crt-* UCRT shims with _initterm, calloc, fwrite, strncmp and others. They come from the C runtime startup code. Real targets carry the same noise, so look for the imports the CRT wouldn’t need, like CreateFileA and WriteFile here.
The EXE’s import table shows the other end of the link: greet.dll, with greet_checksum and greet_write_log.
3. Resources
Resources hold the version block, icons, the manifest, dialogs and string tables. They are often the quickest way to learn what a binary says it is. REA’s inspect-pe-resources parses the resource tree without Ghidra or Windows:
rea inspect-pe-resources "$PWD/greet.dll" --json
It found two resources and reported coverage: {"status":"complete","examined_entries":6,"resources":2}:
| Type ID | Name ID | Language | Payload file offset | Bytes | Meaning |
|---|---|---|---|---|---|
| 6 | 7 | 1033 | 218696 | 72 | RT_STRING block |
| 16 | 1 | 1033 | 218272 | 420 | RT_VERSION |
The string table’s name ID is 7, not 101. String tables are stored in blocks of 16, so string 101 lives in block 101 / 16 + 1 = 7, slot 101 % 16 = 5. Decoding that payload as UTF-16LE showed five empty slots, then a length word of 20 and Greeting log written.
REA doesn’t decode payloads (icon groups aside), so read the bytes it located yourself:
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 is the VS_FIXEDFILEINFO signature 0xFEEF04BD. Two DWORDs later come FileVersionMS = 0x00010002 and FileVersionLS = 0, which is 1.2.0.0. Further into the 420 bytes, the UTF-16 strings FileDescription = Greet demo library and ProductName = REA practice target follow.
We measured two more behaviours. The default budgets are 64 MiB (--max-file-bytes) and 16,384 directory entries (--max-entries). With --max-entries 1 the command failed with resource_constraint and “no partial inventory was returned”, so a failure is not an empty resource tree. hello.exe, built without an .rc file, reported complete with 0 resources.
On a separate Zig-built EXE with an icon, an RCDATA blob and a version resource, REA 6.8.0 returned four entries: RT_ICON (82 bytes), RT_RCDATA (27), RT_GROUP_ICON (20) and RT_VERSION (344). The ICO file had been split into an image and a group directory, and icon_groups linked the group to the RT_ICON entry. pefile 2024.8.26 agreed on all four offsets, sizes and SHA-256 values, and is the easiest way to print version numbers:
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. Read the code behind an export
Add each export RVA to the image base to get its virtual address: 0x180001000 and 0x180001040. Disassemble from there:
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
What the lines tell you:
0x811c9dc5and0x1000193are the FNV-1a offset basis and prime. Searching for constants like these is the fastest way to name a hashing or crypto routine.- The indirect call at
0x180001078reads slot0x180032590. The KERNEL32 import address table starts at RVA0x32588, so this is its second 8-byte slot:CreateFileA. The arguments agree:0x40000000isGENERIC_WRITE,2isCREATE_ALWAYS,0x80isFILE_ATTRIBUTE_NORMAL. The call goes straight through the IAT; this build has no local thunk. 0x1800325e0is 0x58 bytes into the same table, slot 11 counting from zero:WriteFile.0x180029000is the start of.rdata, wherellvm-stringsfindsgreet-log: hello,. The0x12(18) passed as the length is that string’sstrlen, computed at compile time, so there is nostrlencall to find.
That chain from export to IAT slot to import answers the main question for any DLL: which exported function can reach which system API.
5. Decompile with Ghidra and REA
On a larger binary you want pseudocode rather than raw disassembly. Ghidra is free and reads x86 and x86-64 PE files on any host. REA wraps a headless Ghidra so you, or an AI agent over MCP, can ask the same questions: rea analyze, rea function, rea decompile, rea xrefs and rea search, each with --provider ghidra. Setup is in REA + Ghidra for native binaries.
Ghidra wasn’t installed for this run (rea doctor --provider ghidra reported GHIDRA_INSTALL_DIR is not set), so this page shows no Ghidra output. The constants and IAT slots above are in the machine code, which makes them the answer key for any decompiler’s output or an agent’s summary. A prompt that makes the agent show its evidence:
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.
Two REA 6.7.0 limits matter here. On a Windows host its Ghidra support is an experimental, read-only mode that accepts only native x86/x86-64 PE files on a local NTFS path, so Linux or macOS is the easier place to run it. It also has no PDB setting, so a .pdb next to the binary is ignored and only exported names arrive for free.
For a bigger open-source practice target, the official 7-Zip Windows binaries are LGPL, and you can read the source to check your conclusions.