Leçon 6.5 · Analyse dynamique· 45 min
Dumping Memory and Extracting Payloads
Capture process and system memory, find the region that holds the real payload, and turn a memory-layout PE back into a file your tools can parse.
Cette leçon n’est disponible qu’en anglais pour le moment.
Objectifs
- Explain why decrypted payloads, runtime configuration and injected code often exist only in memory
- Choose between a full process dump, a single-region dump and a module dump, and produce each with standard tools
- Locate suspicious regions from memory type, protection, MZ headers and thread start addresses
- Convert a PE saved in memory layout back to file layout and rebuild its imports
- Hash, document and store dumps as the live malicious samples they are
The earlier lessons in this module watched a sample from the outside: which processes it started, which files and keys it touched, which APIs it called. That tells you what the sample did, not what it is once running — and for a packed or crypted binary, the running form is what you wanted to analyse.
Memory dumping closes that gap: let the sample decrypt itself, then copy the result out before it disappears. This lesson covers what to dump, how to find the region that matters, and the format problem that trips up almost everyone: a PE saved from memory is not laid out like a PE file.
Why dump memory at all
Detecting Packers and Entropy ended with a warning: static triage of a packed file mostly describes the stub. Three kinds of evidence are routinely available in memory and nowhere else.
| What exists only at runtime | Why the file does not show it | What you gain from a dump |
|---|---|---|
| The unpacked or decrypted payload | The file holds a compressed or encrypted blob plus a stub (packer, runtime crypter) | Real code for the disassembler, real strings, real imports |
| Configuration in cleartext | C2 addresses, campaign IDs and keys are stored encoded and decoded just before use (XOR string encryption) | IOCs you can hand to the SOC without reversing the decoder first |
| Code the file never contained | Second stages downloaded at runtime, shellcode, payloads injected into another process (process hollowing, reflective DLL injection) | The only copy of that stage you will ever get |
Memory is not a perfect witness either: a sample can decrypt a string, use it and wipe it, or keep its payload encrypted while it sleeps. Too early and you catch the stub; too late and the process has exited. Dump more than once — after the first network activity, after a milestone in your behavioural monitoring log, or at a breakpoint you chose in the debugger.
What to dump
There are three granularities, and each suits a different question.
A whole process
A full dump (MiniDumpWithFullMemory) contains every committed page plus
the module list, threads and handles: large, but complete, and you can open it
later in WinDbg or carve regions from it. A minidump holds thread stacks,
module lists and little else — usually not the private allocations where
unpacked code lives. For malware work, default to full.
| Tool | How | Notes |
|---|---|---|
| ProcDump (Sysinternals) | procdump -accepteula -ma <pid> out.dmp | -ma is full; without it you get a minidump. It can also wait for a trigger, such as the process exiting (-t) |
| Task Manager | Details → right-click the process → Create dump file | Full dump written to your %LOCALAPPDATA%\Temp folder; nothing to install |
| System Informer | Right-click the process → Create dump file… | Same idea, with a choice of dump type |
Tip: Dump when the process is idle in a known state, not while it is mid-write. If you are in x64dbg, pausing the process first gives you a consistent snapshot; if it is free-running, take two dumps a few seconds apart and compare.
A single region
When you know which allocation you want, System Informer's Memory tab →
right-click → Save… writes exactly that region; in x64dbg, use Memory Map →
right-click → Dump Memory to File. The result is small and ready for
strings, YARA or a disassembler, but it is raw bytes with no context, so record
its base address and protection alongside it.
A loaded module
When the payload is a PE — an unpacked executable, a manually mapped DLL — you want it back as a working PE file. Saving its region gets you the bytes; the layout section below explains why they will not parse until you fix them.
Finding the interesting region
Processes, Threads and DLLs ended with the cross-check that drives this lesson: look for executable memory that no module explains. In System Informer's Memory tab, ask three questions.
- What type is it? Image (sections mapped by the loader), Mapped
(file views, shared sections) or Private (
VirtualAlloc, heaps, stacks). Legitimate code lives in Image regions. - What is its protection?
RXorRWXon a Private region means someone allocated memory and made it executable — the defining move of unpackers, injectors and shellcode loaders.RWXis the louder signal; a careful loader writes withRW, then flips toRXwithVirtualProtect. See memory protection. - What does it start with?
MZat offset 0 with a validPE\0\0signature ate_lfanewis a PE the loader never loaded. Some loaders erase headers after mapping, so a missingMZproves nothing.
Then check threads: a start address, or a stack frame, inside a Private executable region marks it as live code rather than a leftover buffer. Combine the signals:
| Signal | Weak alone because | Strong when combined with |
|---|---|---|
| Private + executable | JIT runtimes (.NET, browsers, scripting engines) do it legitimately | The process is not a JIT host |
MZ in a Private region | Some software keeps PE copies as data | The region is executable, or a thread starts inside it |
| Unbacked thread start | Thread-pool callbacks sometimes resolve oddly without symbols | The address falls in a Private RX/RWX region |
| An Image region whose bytes differ from the file on disk | Hot-patching and relocations cause small differences | Large differences in .text — the image was hollowed or overwritten |
The last row matters because hollowing and module overwriting put foreign code inside a region labelled Image, backed by an innocent file. Only comparing the in-memory bytes to the file catches them — which the scanners below automate.
The layout problem
PE Sections and Memory Layout showed the loader
copying each section from PointerToRawData to VirtualAddress. Save that
memory and you get the memory layout — sections at their RVAs, page-aligned
gaps, a file SizeOfImage bytes long — while the section table inside still
carries file offsets. Every tool that trusts the header reads the wrong bytes:
zeros at the entry point, empty imports, sections in PE-bear whose bytes do not
match.
On top of layout, a dump differs from the original file in content:
- Relocations have been applied. If ASLR loaded the image away from its
preferred base, every absolute pointer listed in the
relocation table has changed, and the in-memory
header's
ImageBasefield holds the actual base. - The IAT is filled in. Each IAT slot now holds the runtime address of an API in some DLL rather than a pointer to a name. A packer that resolved imports itself may have left no import directory at all.
- Globals hold runtime state — including, usefully, decoded strings.
- Uninitialised sections contain data.
.bssor a packer's empty section (UPX0) now has bytes, and they need file space.
Two ways to fix the layout
| Approach | What changes | Resulting file | Used by |
|---|---|---|---|
| Raw = virtual (pe-sieve calls this realigned) | Rewrite each section header so PointerToRawData = VirtualAddress and SizeOfRawData = the aligned virtual size | Same size as the dump; bytes untouched | PE-bear (edit the section headers), pe-sieve |
| Unmap to file layout (pe-sieve: unmapped) | Copy each section back to consecutive FileAlignment offsets and rewrite the headers to match | Compact, close to a normal file | pe-sieve, most unpackers |
The first moves no bytes, so it is safer when you distrust the section table. The second looks normal to every tool but keeps only what the headers describe.
Imports are a separate repair
Layout fixes make the file parse; they do not restore a destroyed import
table. Rebuilding one means finding the IAT in memory, resolving each address to
module!function and writing a new import directory. Scylla (standalone or
as the x64dbg plugin) does this against a live process: set the
OEP, IAT Autosearch, Get Imports, Dump, then Fix
Dump; PE-bear is where you inspect and hand-edit the result. Choosing the right
moment and OEP is the subject of the later module on manual unpacking — here,
remember that "the dump parses" and "the dump has imports" are separate
milestones.
Scanners that do this for you
Three free tools automate the checks above.
- pe-sieve scans one process (
pe-sieve64.exe /pid <pid>). It compares each loaded module with its file on disk, looks for PE images and shellcode in private memory (/shellc), detects hooks and patched code, and dumps whatever it flags into aprocess_<pid>folder with a JSON report./dmodeselects the dump layout — virtual, unmapped or realigned — and/impasks it to rebuild imports. - hollows_hunter runs pe-sieve's engine across every process (or those matching a name), which is the quickest way to ask "is anything on this VM injected?" after detonation.
- Moneta takes the memory-map view: it enumerates regions and reports anomalies such as private executable memory, modified image regions and threads starting in unbacked memory.
pe-sieve thinks in PE files and produces loadable dumps; Moneta thinks in regions. Both will still flag JIT memory in a browser — judgement stays yours.
Whole-system memory
When a sample injects into several processes, or you are handed an image from
an incident, capture the whole machine. In a lab, snapshot the suspended VM
(VMware writes a .vmem file you can analyse directly); on real hosts,
responders use tools such as WinPmem or DumpIt. Volatility 3 reads such
images:
vol -f mem.raw windows.pslist # processes, PIDs, parents, times
vol -f mem.raw windows.dlllist --pid 4312 # modules the loader knows about
vol -f mem.raw windows.malfind --pid 4312 # private executable regions, with hexdumpmalfind applies the heuristic you used by hand — private executable memory,
with a disassembly preview — and shares its false positives. Memory forensics is
a discipline of its own; treat this as a pointer.
Documenting dumps
A dump is only evidence if you can say where it came from. Record, per file:
| Field | Example |
|---|---|
| SHA-256 of the dump | computed immediately after saving |
| Source | process name, PID, parent PID, image path |
| Region | base address, size, type (Private/Image/Mapped), protection |
| Moment | wall-clock time and the trigger ("after first DNS query", "at breakpoint on VirtualProtect") |
| Tool and mode | procdump -ma, System Informer Save, pe-sieve /dmode 3 |
| Post-processing | "unmapped with script, imports rebuilt with Scylla" — and the SHA-256 of each derived file |
Hash the raw dump and every fixed version. None will match each other or the original sample — layout, relocations, the IAT and runtime data all change bytes — so a dump's hash is a record-keeping identifier, not a detection IOC. For detection, use YARA rules on stable code or decoded strings (see Writing Your First YARA Rules) and the extracted configuration values.
Warning: A dump of an unpacked payload is live malicious code, often more dangerous than the original because the protective layer is gone. Handle it exactly like a sample: keep it inside the lab, store it in a password-protected archive (the convention is the password
infected), give it a non-executable extension such as.binor.dmp, and never copy it to your host. Full process dumps may also contain credentials, tokens and personal data from the VM — another reason to keep lab VMs free of real accounts, as set up in Building a Safe Analysis Lab.
Lab: simulate a loader, then repair the dump
The layout problem is pure arithmetic, so you can reproduce it with Python
before touching a VM: build a harmless program that decodes a "config" string at
runtime, map it the way the loader does, save that image as a dumper would, and
repair it two ways. You need mingw-w64 and a virtual environment with pefile
and capstone.
-
Set up and build the sample:
c // labcfg.c — harmless: decodes a "config" string at runtime and prints it #include <stdio.h> static unsigned char cfg[] = { /* "mode=lab;host=127.0.0.1" ^ 0x5A */ 0x37,0x35,0x3e,0x3f,0x67,0x36,0x3b,0x38,0x61,0x32,0x35,0x29,0x2e, 0x67,0x6b,0x68,0x6d,0x74,0x6a,0x74,0x6a,0x74,0x6b,0x00 }; int main(void) { for (unsigned i = 0; cfg[i]; i++) cfg[i] ^= 0x5A; /* cleartext exists only in memory */ printf("config: %s\n", (char *)cfg); return 0; }bash python3 -m venv venv && ./venv/bin/pip install pefile capstone x86_64-w64-mingw32-gcc -O1 -s -o labcfg.exe labcfg.c shasum -a 256 labcfg.exe -
Save the script as
mapdump.py.map_imageis the loader: it copies the headers, places each section at its RVA inside aSizeOfImagebuffer, applies the 64-bit base relocations for an ASLR-style base, writes that base into the header, and then does whatmaindoes — decode the config. The twounmap_functions are the repairs from the table above.python # mapdump.py — simulate a loader, save a "memory dump", then realign it import hashlib, struct, sys import pefile from capstone import Cs, CS_ARCH_X86, CS_MODE_64 def align(x, a): return (x + a - 1) // a * a def sha(buf): return hashlib.sha256(buf).hexdigest()[:16] def name(s): return s.Name.rstrip(b"\x00").decode(errors="replace") def map_image(data, new_base): """Lay the file out by RVA, as the Windows loader does, and rebase it.""" pe = pefile.PE(data=data) oh = pe.OPTIONAL_HEADER mem = bytearray(oh.SizeOfImage) mem[:oh.SizeOfHeaders] = data[:oh.SizeOfHeaders] for s in pe.sections: n = min(s.SizeOfRawData, align(s.Misc_VirtualSize, oh.SectionAlignment)) raw = data[s.PointerToRawData:s.PointerToRawData + n] mem[s.VirtualAddress:s.VirtualAddress + len(raw)] = raw # rest stays 0 # apply base relocations (DIR64) for the new base, like ASLR does delta = new_base - oh.ImageBase for block in getattr(pe, "DIRECTORY_ENTRY_BASERELOC", []): for e in block.entries: if e.type == pefile.RELOCATION_TYPE["IMAGE_REL_BASED_DIR64"]: v, = struct.unpack_from("<Q", mem, e.rva) struct.pack_into("<Q", mem, e.rva, v + delta) # the loader writes the real base into the in-memory header struct.pack_into("<Q", mem, oh.get_field_absolute_offset("ImageBase"), new_base) # simulate the program running: main() XOR-decodes its config in .data i = mem.find(bytes([0x37, 0x35, 0x3e, 0x3f, 0x67])) while mem[i]: mem[i] ^= 0x5A i += 1 return bytes(mem) def unmap_raw_equals_virtual(mem): """Fix 1: keep memory layout, rewrite headers so raw == virtual.""" pe = pefile.PE(data=mem) for s in pe.sections: s.PointerToRawData = s.VirtualAddress s.SizeOfRawData = align(s.Misc_VirtualSize, pe.OPTIONAL_HEADER.SectionAlignment) return pe.write() def unmap_to_file_layout(mem): """Fix 2: copy each section back to a FileAlignment offset.""" pe = pefile.PE(data=mem) fa = pe.OPTIONAL_HEADER.FileAlignment out = bytearray(mem[:pe.OPTIONAL_HEADER.SizeOfHeaders]) for s in pe.sections: body = mem[s.VirtualAddress:s.VirtualAddress + s.Misc_VirtualSize] s.PointerToRawData = len(out) if body else 0 s.SizeOfRawData = align(len(body), fa) out += body.ljust(s.SizeOfRawData, b"\x00") hdr = pe.write()[:pe.OPTIONAL_HEADER.SizeOfHeaders] return hdr + bytes(out[len(hdr):]) def report(label, buf): pe = pefile.PE(data=buf) oh = pe.OPTIONAL_HEADER print(f"== {label}: {len(buf)} bytes, sha256 {sha(buf)}…, ImageBase {oh.ImageBase:#x}") print(f" {'section':8} {'VA':>7} {'VSize':>7} {'RawPtr':>7} {'RawSize':>7}") for s in pe.sections[:4]: print(f" {name(s):8} {s.VirtualAddress:#7x} {s.Misc_VirtualSize:#7x} " f"{s.PointerToRawData:#7x} {s.SizeOfRawData:#7x}") ep = oh.AddressOfEntryPoint off = pe.get_offset_from_rva(ep) code = buf[off:off + 12] ins = next(Cs(CS_ARCH_X86, CS_MODE_64).disasm(code, oh.ImageBase + ep), None) print(f" entry RVA {ep:#x} -> file offset {off:#x}: {code[:8].hex(' ')}" f" => {ins.mnemonic + ' ' + ins.op_str if ins else '(not code)'}") data = next(s for s in pe.sections if name(s) == ".data") blob = buf[data.PointerToRawData:data.PointerToRawData + 0x40] if b"mode" in blob: txt = blob[blob.find(b"mode"):].split(b"\0")[0].decode() else: txt = "no cleartext; first bytes " + blob[:8].hex(" ") print(f" .data via PointerToRawData {data.PointerToRawData:#x}: {txt}") imps = [e.dll.decode() for e in getattr(pe, "DIRECTORY_ENTRY_IMPORT", [])] print(f" imports parsed: {len(imps)} DLLs {imps[:2]}") print() def section_diff(a, b): pa, pb = pefile.PE(data=a), pefile.PE(data=b) print("per-section comparison, original vs fix 2 (first VirtualSize bytes):") for sa, sb in zip(pa.sections, pb.sections): n = sa.Misc_VirtualSize x = a[sa.PointerToRawData:sa.PointerToRawData + n] if sa.SizeOfRawData else bytes(n) y = b[sb.PointerToRawData:sb.PointerToRawData + n] d = sum(1 for i, j in zip(x, y) if i != j) print(f" {name(sa):8} {sha(x)} {sha(y)} {d:4} bytes differ") orig = open(sys.argv[1], "rb").read() mem = map_image(orig, new_base=0x7FF6A1B20000) open("labcfg.mem.bin", "wb").write(mem) report("original file", orig) report("memory image (as dumped)", mem) fixed1 = unmap_raw_equals_virtual(mem) open("labcfg.fix1.exe", "wb").write(fixed1) report("fix 1: raw = virtual", fixed1) fixed2 = unmap_to_file_layout(mem) open("labcfg.fix2.exe", "wb").write(fixed2) report("fix 2: unmapped to file layout", fixed2) section_diff(orig, fixed2) -
Run it:
bash ./venv/bin/python mapdump.py labcfg.exeFirst, the original file and the raw memory image. Only the first four sections are printed; hashes are truncated to 16 hex digits:
text == original file: 16384 bytes, sha256 12710c38b3a06375…, ImageBase 0x140000000 section VA VSize RawPtr RawSize .text 0x1000 0x1a90 0x400 0x1c00 .data 0x3000 0xc0 0x2000 0x200 .rdata 0x4000 0x9e8 0x2200 0xa00 .pdata 0x5000 0x228 0x2c00 0x400 entry RVA 0x1440 -> file offset 0x840: 48 83 ec 28 48 8b 05 d5 => sub rsp, 0x28 .data via PointerToRawData 0x2000: no cleartext; first bytes 37 35 3e 3f 67 36 3b 38 imports parsed: 9 DLLs ['KERNEL32.dll', 'api-ms-win-crt-environment-l1-1-0.dll'] == memory image (as dumped): 45056 bytes, sha256 4fef6962a593fae3…, ImageBase 0x7ff6a1b20000 section VA VSize RawPtr RawSize .text 0x1000 0x1a90 0x400 0x1c00 .data 0x3000 0xc0 0x2000 0x200 .rdata 0x4000 0x9e8 0x2200 0xa00 .pdata 0x5000 0x228 0x2c00 0x400 entry RVA 0x1440 -> file offset 0x840: 00 00 00 00 00 00 00 00 => add byte ptr [rax], al .data via PointerToRawData 0x2000: no cleartext; first bytes e8 c3 09 00 00 eb aa 66 imports parsed: 0 DLLs []The file's config is still encoded (
37 35 3e 3f…). The memory image is0xb000bytes — exactlySizeOfImage— and its section table is unchanged, so every lookup goes wrong: the "entry point" is header padding that capstone dutifully decodes asadd byte ptr [rax], al, ".data" at0x2000is really the middle of.text(e8 …is acall), and pefile finds no imports at all. The decoded config is in this file — at offset0x3000— but nothing that trusts the header will find it. -
Now the two repairs:
text == fix 1: raw = virtual: 45056 bytes, sha256 d8081e62a23a5915…, ImageBase 0x7ff6a1b20000 section VA VSize RawPtr RawSize .text 0x1000 0x1a90 0x1000 0x2000 .data 0x3000 0xc0 0x3000 0x1000 .rdata 0x4000 0x9e8 0x4000 0x1000 .pdata 0x5000 0x228 0x5000 0x1000 entry RVA 0x1440 -> file offset 0x1440: 48 83 ec 28 48 8b 05 d5 => sub rsp, 0x28 .data via PointerToRawData 0x3000: mode=lab;host=127.0.0.1 imports parsed: 9 DLLs ['KERNEL32.dll', 'api-ms-win-crt-environment-l1-1-0.dll'] == fix 2: unmapped to file layout: 16896 bytes, sha256 ed3989ff97eb4461…, ImageBase 0x7ff6a1b20000 section VA VSize RawPtr RawSize .text 0x1000 0x1a90 0x400 0x1c00 .data 0x3000 0xc0 0x2000 0x200 .rdata 0x4000 0x9e8 0x2200 0xa00 .pdata 0x5000 0x228 0x2c00 0x400 entry RVA 0x1440 -> file offset 0x840: 48 83 ec 28 48 8b 05 d5 => sub rsp, 0x28 .data via PointerToRawData 0x2000: mode=lab;host=127.0.0.1 imports parsed: 9 DLLs ['KERNEL32.dll', 'api-ms-win-crt-environment-l1-1-0.dll']Both parse and both show the decoded configuration. Fix 1 moved no bytes: RVA and file offset are now equal (
0x1440 → 0x1440). Fix 2 is a compact file, 512 bytes larger than the original because.bss, empty on disk, now gets a0x200-byte raw slot for its in-memory contents. -
Compare content, not just layout:
text per-section comparison, original vs fix 2 (first VirtualSize bytes): .text 526bd1659438b078 526bd1659438b078 0 bytes differ .data ca4fbacadbb6d96f 706bc7a61588e747 43 bytes differ .rdata 0df4dd7330cf2676 2b837950fb2de061 136 bytes differ .pdata a33ab3e879dff4f7 a33ab3e879dff4f7 0 bytes differ ... .idata 238a2d1f456486e9 238a2d1f456486e9 0 bytes differ .reloc 9ca899cf3c7ac52c 9ca899cf3c7ac52c 0 bytes differThe code is byte-identical.
.datadiffers by the 23 decoded config bytes plus relocated pointers;.rdatadiffers only by relocated pointers. Our simulated loader did not bind imports, so.idatais unchanged here — on Windows, the IAT slots in it would hold real API addresses and differ too. Four files, four hashes, one program. -
In your Windows VM, repeat the workflow on the real thing. Copy
labcfg.exein, but because it exits immediately, add agetchar();beforereturn 0;and rebuild so it stays alive. Then:- Run it, note the PID, and take a full dump with
procdump -accepteula -ma <pid> labcfg_full.dmp. Take a minidump without-maand compare the sizes. - In System Informer, open the process → Memory. Find the regions that
belong to
labcfg.exe(type Image) and the RW one at image base +0x3000: that is.data. Right-click → Save…, then search the saved file formode=lab— the decoded config, which a static look at the file could not show you. - Run
pe-sieve64.exe /pid <pid>and read the summary andscan_report.json. An unmodified process should report nothing suspicious and produce no dumps. When pe-sieve does flag a module,/dmodepicks the layout of what it writes — compare an unmapped and a realigned dump from a later sample with yourfix2andfix1files. - Hash every file you produced and fill in the documentation table above for each.
- Run it, note the PID, and take a full dump with
Questions to answer: Why did the entry point in the raw memory image decode as a valid instruction at all, and what does that tell you about trusting a disassembler's output? Which of the two repairs would you choose if you suspected the section table had been tampered with, and why? If a sample's import directory had been wiped after loading, which of the lab's checks would fail even after fixing the layout? Why is the dump's SHA-256 useless as a detection indicator, and what would you give the SOC instead?
Key takeaways
- Dump memory to recover what never touches disk: unpacked payloads, decoded configuration and injected or downloaded stages. When you dump matters as much as how.
- Take full process dumps (
procdump -ma, Task Manager) for completeness, region saves (System Informer, x64dbg) for precision, and module dumps when you need a working PE. - Find the region that matters by type, protection,
MZheaders and thread start addresses; Private executable memory and unbacked threads are the core signals, and JIT hosts are the core false positive. - A PE saved from memory is in memory layout while its headers describe file
layout; fix it by setting raw = virtual or by unmapping to
FileAlignment, then rebuild imports separately with Scylla and verify in PE-bear. - pe-sieve, hollows_hunter and Moneta automate these checks; Volatility 3's
pslist,dlllistandmalfindapply them to whole-system images. - Hash and document every dump and every derived file, and handle dumps as live samples — they are often more dangerous than the file you started with.