Skip to content

Lesson 6.5 · Dynamic Analysis· 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.

Objectives

  • 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 runtimeWhy the file does not show itWhat you gain from a dump
The unpacked or decrypted payloadThe file holds a compressed or encrypted blob plus a stub (packer, runtime crypter)Real code for the disassembler, real strings, real imports
Configuration in cleartextC2 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 containedSecond 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.

ToolHowNotes
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 ManagerDetails → right-click the process → Create dump fileFull dump written to your %LOCALAPPDATA%\Temp folder; nothing to install
System InformerRight-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.

  1. 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.
  2. What is its protection? RX or RWX on a Private region means someone allocated memory and made it executable — the defining move of unpackers, injectors and shellcode loaders. RWX is the louder signal; a careful loader writes with RW, then flips to RX with VirtualProtect. See memory protection.
  3. What does it start with? MZ at offset 0 with a valid PE\0\0 signature at e_lfanew is a PE the loader never loaded. Some loaders erase headers after mapping, so a missing MZ proves 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:

SignalWeak alone becauseStrong when combined with
Private + executableJIT runtimes (.NET, browsers, scripting engines) do it legitimatelyThe process is not a JIT host
MZ in a Private regionSome software keeps PE copies as dataThe region is executable, or a thread starts inside it
Unbacked thread startThread-pool callbacks sometimes resolve oddly without symbolsThe address falls in a Private RX/RWX region
An Image region whose bytes differ from the file on diskHot-patching and relocations cause small differencesLarge 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 ImageBase field 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. .bss or a packer's empty section (UPX0) now has bytes, and they need file space.

Two ways to fix the layout

ApproachWhat changesResulting fileUsed by
Raw = virtual (pe-sieve calls this realigned)Rewrite each section header so PointerToRawData = VirtualAddress and SizeOfRawData = the aligned virtual sizeSame size as the dump; bytes untouchedPE-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 matchCompact, close to a normal filepe-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 a process_<pid> folder with a JSON report. /dmode selects the dump layout — virtual, unmapped or realigned — and /imp asks 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:

bash
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 hexdump

malfind 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:

FieldExample
SHA-256 of the dumpcomputed immediately after saving
Sourceprocess name, PID, parent PID, image path
Regionbase address, size, type (Private/Image/Mapped), protection
Momentwall-clock time and the trigger ("after first DNS query", "at breakpoint on VirtualProtect")
Tool and modeprocdump -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 .bin or .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.

  1. 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
  2. Save the script as mapdump.py. map_image is the loader: it copies the headers, places each section at its RVA inside a SizeOfImage buffer, applies the 64-bit base relocations for an ASLR-style base, writes that base into the header, and then does what main does — decode the config. The two unmap_ 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)
  3. Run it:

    bash
    ./venv/bin/python mapdump.py labcfg.exe

    First, 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 is 0xb000 bytes — exactly SizeOfImage — and its section table is unchanged, so every lookup goes wrong: the "entry point" is header padding that capstone dutifully decodes as add byte ptr [rax], al, ".data" at 0x2000 is really the middle of .text (e8 … is a call), and pefile finds no imports at all. The decoded config is in this file — at offset 0x3000 — but nothing that trusts the header will find it.

  4. 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 a 0x200-byte raw slot for its in-memory contents.

  5. 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 differ

    The code is byte-identical. .data differs by the 23 decoded config bytes plus relocated pointers; .rdata differs only by relocated pointers. Our simulated loader did not bind imports, so .idata is unchanged here — on Windows, the IAT slots in it would hold real API addresses and differ too. Four files, four hashes, one program.

  6. In your Windows VM, repeat the workflow on the real thing. Copy labcfg.exe in, but because it exits immediately, add a getchar(); before return 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 -ma and 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 for mode=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 and scan_report.json. An unmodified process should report nothing suspicious and produce no dumps. When pe-sieve does flag a module, /dmode picks the layout of what it writes — compare an unmapped and a realigned dump from a later sample with your fix2 and fix1 files.
    • Hash every file you produced and fill in the documentation table above for each.

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, MZ headers 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, dlllist and malfind apply 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.