WARMUP — Offensive Tooling Development (Analysis, Formats & IOC Hygiene)
From zero to principal-level. Binary formats built field by field. IOC taxonomy from the Pyramid of Pain. OPSEC hygiene mapped to detection. Every indicator paired with the log that catches it.
Table of Contents
- Chapter 1 — Why Tooling Literacy Is the Engagement's Foundation
- Chapter 2 — The PE Format, Field by Field
- Chapter 3 — The ELF Format, Field by Field
- Chapter 4 — imphash and the Import Table as a Detection Surface
- Chapter 5 — IOC Taxonomy: Host, Network, and Behavioral
- Chapter 6 — The Pyramid of Pain Applied to Tooling
- Chapter 7 — OPSEC Hygiene: What a Tool Leaves Behind and Why
- Chapter 8 — Detection Engineering for Tooling
- Chapter 9 — Tooling Languages and Their Binary Characteristics
- Chapter 10 — Misconceptions
- Lab Walkthrough
- Success Criteria
- Common Mistakes and OPSEC
- Interview Q&A
- References
Chapter 1 — Why Tooling Literacy Is the Engagement's Foundation
What "tooling literacy" means
A red team engagement uses tools to implement techniques. The tool's job is to produce the technique's observable footprint — and that footprint is what the engagement produces for the client to detect. Tooling literacy is the ability to read and reason about a tool's binary structure, configuration, and operational footprint — without necessarily knowing or disclosing its source code or how to develop weaponized variants.
This has three sub-skills:
- Triage — given a binary (or a metadata summary of one), classify it: what type of tooling, what language, what capability indicators, what family.
- IOC inventory — enumerate the indicators that tool produces on host (imphash, strings, named pipes, PDB paths), on the network (JA3, user-agent, URI patterns), and in behavior (API call sequences, parent-child relationships).
- OPSEC assessment — score how loud the tool is, which indicators are default-on and commonly known to blue teams, and which mitigations would reduce the signal.
All three are bidirectional: the red team uses them to assess whether a tool is fit for engagement use; the blue team uses them to triage and classify tooling found in an incident.
Why it is Phase 02
Phase 01 produced the technique list. The techniques map to tools. Before Phase 03–05 execute those techniques, the consultant must know what artifact those tools leave. The OPSEC linter output from Phase 02 shapes which tools are used, how they are configured, and what detections the engagement will validate. The triage output feeds the evidence manifest and the detection-gap map.
Chapter 2 — The PE Format, Field by Field
2.1 Overview
A Portable Executable (PE) is the binary format for Windows executables, DLLs, drivers, and related file types. The format is documented in the Microsoft PE/COFF specification.
The on-disk layout from offset 0:
Offset Size Structure
──────────────────────────────────────────────────────────────
0x00 64 IMAGE_DOS_HEADER (begins "MZ" = 0x4D5A)
0x3C 4 e_lfanew → offset of IMAGE_NT_HEADERS
─────────────────── (followed by DOS stub, then at e_lfanew:)
+0x00 4 Signature = "PE\0\0" = 0x50450000
+0x04 20 IMAGE_FILE_HEADER
+0x00 2 Machine (0x8664 = AMD64, 0x14C = i386, 0xAA64 = ARM64)
+0x02 2 NumberOfSections
+0x04 4 TimeDateStamp ← compilation timestamp (Unix epoch)
+0x08 4 PointerToSymbolTable (0 in non-COFF)
+0x0C 4 NumberOfSymbols
+0x10 2 SizeOfOptionalHeader
+0x12 2 Characteristics (IMAGE_FILE_DLL bit 0x2000)
+0x18 240 IMAGE_OPTIONAL_HEADER (PE32+)
+0x00 2 Magic (0x20B = PE32+, 0x10B = PE32)
+0x02 1 MajorLinkerVersion
+0x03 1 MinorLinkerVersion
+0x10 8 AddressOfEntryPoint (RVA)
+0x1C 8 ImageBase (preferred load address)
+0x28 4 SizeOfImage
+0x3C 2 Subsystem
1=native, 2=GUI, 3=CUI, 14=EFI app
+0x5C 8 DataDirectory[0] = Export Directory
+0x64 8 DataDirectory[1] = Import Directory ← key for imphash
+0x68 8 DataDirectory[2] = Resource Directory
+0x98 8 DataDirectory[5] = Base Reloc
+0xA0 8 DataDirectory[6] = Debug Directory ← PDB path here
─────────────────── Section table (NumberOfSections entries of 40 bytes each)
.text code (characteristics: r-x)
.data mutable globals (rw-)
.rdata read-only data: strings, import hints, vtable (r--)
.rsrc resources (icons, version info, manifests)
.reloc base relocation table
.pdata exception unwind info (x64)
2.2 The Import Directory Table (IDT) and Import Address Table (IAT)
The IDT is DataDirectory[1]: a null-terminated array of IMAGE_IMPORT_DESCRIPTOR structures,
one per imported DLL. Each descriptor points to:
- A DLL name (ASCII string).
- An INT (Import Name Table): the functions this binary imports from that DLL, by name or ordinal.
- An IAT (Import Address Table): at load time, the loader resolves each import and writes the function's virtual address into the IAT. The code calls through the IAT, not the INT.
At runtime in memory, the IAT is the live jump table: CALL QWORD PTR [IAT+offset]. Process
injection overwrites an IAT entry to redirect a function call to attacker-controlled code — which
is why ETW-TI tracks NtWriteVirtualMemory targeting another process's address space.
2.3 The Debug Directory and PDB Path
DataDirectory[6] points to one or more IMAGE_DEBUG_DIRECTORY structures. The TYPE_CODEVIEW
entry contains (for a PDB-debug binary) the path to the Program Database file:
RSDS signature + PDB GUID + Age + NUL-terminated PDB path
Default paths reveal:
- Operator's username:
C:\Users\Alice\Documents\repos\my-implant\x64\Release\my-implant.pdb - Build system path:
C:\Jenkins\workspace\payload-build\Release\payload.pdb - The project name in the path itself
Detection: string-extracting strings or FLOSS on a PE sample; or dumpbin /headers /
pefile Python library parsing entry.DIRECTORY_ENTRY_DEBUG. A PDB path present in a tool binary
is a default-on IOC that blue teams look for in DFIR.
2.4 The Export Directory Table
DataDirectory[0] points to an IMAGE_EXPORT_DIRECTORY. Populated for DLLs and sometimes EXEs
(rare). Contains:
- DLL name — the canonical name (may differ from the on-disk filename; this mismatch is an IOC).
- AddressOfFunctions — array of RVAs to exported code/data.
- AddressOfNames — array of RVAs to function-name strings.
- AddressOfNameOrdinals — ordinal table mapping names to function index.
A reflective DLL — one that loads itself into memory without a file — exports a well-known entry
point name (e.g. ReflectiveDllInjection, ReflectiveLoader). That export name is visible in the
in-memory PE scan done by EDR / memory forensics (pe-sieve, Volatility's malfind).
Chapter 3 — The ELF Format, Field by Field
3.1 Overview
ELF (Executable and Linkable Format) is the binary format for Linux/Unix executables, shared libraries, and object files. The structure:
Offset Size Field
──────────────────────────────────────────────────────────────
0x00 4 e_ident magic: \x7fELF
0x04 1 EI_CLASS: 1=32-bit, 2=64-bit
0x05 1 EI_DATA: 1=LE, 2=BE
0x10 2 e_type: ET_EXEC=2, ET_DYN=3, ET_REL=1
0x12 2 e_machine: EM_X86_64=0x3E, EM_AARCH64=0xB7
0x18 8 e_entry: virtual entry point
0x20 8 e_phoff: offset to program headers (segments)
0x28 8 e_shoff: offset to section headers
0x38 2 e_phnum: number of program headers
0x3A 2 e_shnum: number of section headers
e_type matters for triage:
ET_EXEC— position-dependent executable (fixed load address).ET_DYN— position-independent; can be a PIE executable or a shared library (.so). AET_DYNfile withe_entry != 0is typically a PIE binary (harder to exploit statically), or a shared library with an entry point (unusual, worth noting in triage).
3.2 Program headers (segments) vs. section headers
Program headers describe segments — the runtime view used by the kernel/dynamic linker:
| Type | Meaning |
|---|---|
PT_LOAD | Loadable segment (code r-x, data rw-) |
PT_DYNAMIC | Points to the .dynamic section |
PT_INTERP | Path to the dynamic linker (/lib64/ld-linux-x86-64.so.2) |
PT_GNU_STACK | Stack permissions flag |
PT_GNU_RELRO | Read-only after relocation (hardening) |
Section headers describe sections — the linker/debugger view (stripped in production binaries but still present and parseable if the file has not been stripped):
| Section | Content |
|---|---|
.text | Code |
.data | Initialized mutable data |
.rodata | Read-only data (strings, constants) |
.bss | Uninitialized data (zero-filled at load) |
.dynamic | Dynamic linking metadata |
.dynsym | Dynamic symbol table (external symbol names + versions) |
.rel.plt / .rela.plt | Relocations for PLT (dynamic function calls) |
3.3 Dynamic linking (the ELF equivalent of the PE import table)
The .dynamic section contains a list of tags that tell the dynamic linker (ld.so) what to do.
Key tags:
DT_NEEDED— each one names a required shared library (equivalent to PE's per-DLL IDT entry).DT_SYMTAB→.dynsym— the dynamic symbol table (function/data names imported from libraries).DT_JMPREL→.rela.plt— relocation entries for PLT slots.
The PLT/GOT (Procedure Linkage Table / Global Offset Table) is the ELF equivalent of the PE IAT:
- First call to a dynamic function goes through the PLT stub → the dynamic linker resolves it → writes the real address into the GOT entry.
- Subsequent calls go through PLT → GOT → directly to the function.
GOT overwrite is a classical Linux exploitation technique (overwrite a GOT entry to hijack a function call), equivalent to IAT hooking on Windows.
Chapter 4 — imphash and the Import Table as a Detection Surface
4.1 What imphash is
imphash is the MD5 hash of the normalized PE import table, computed as:
- For each DLL in the IDT (in IDT order):
For each function (in IDT order within that DLL):
Append
<dll_lowercase_no_extension>.<function_lowercase>to a list. - Join the list with commas into a single string.
- MD5(string).
This was defined in Mandiant's 2014 blog post "Tracking Malware with Import Hashing" (Carhart). The motivation: two binaries compiled from the same source with the same linker settings import the same functions in the same order, producing the same imphash — even if the content differs. It groups tool families by their import signature.
Python (stdlib only):
import hashlib
def imphash(imports):
"""
imports: list of (dll_name, func_name) in IDT order
Returns MD5 hex of the normalized comma-joined import list.
"""
parts = []
for dll, func in imports:
dll_norm = dll.lower()
if dll_norm.endswith('.dll'):
dll_norm = dll_norm[:-4]
parts.append(f"{dll_norm}.{func.lower()}")
raw = ','.join(parts)
return hashlib.md5(raw.encode()).hexdigest()
4.2 Pyramid of Pain placement
imphash sits at the Hash layer of Bianco's Pyramid of Pain — one step above raw file hash. The adversary cost to change it: add one dummy import, recompile, done. It is useful for:
- Grouping samples into families (all Cobalt Strike beacons compiled from the same config have the same imphash in a given version).
- Rapid triage in DFIR: "this binary's imphash matches the known Cobalt Strike PE family."
- Historical attribution: tracking when a threat actor changed their build environment.
It is not useful as a durable detection because the recompile or dummy-import evasion is trivial. The durable detection is at the TTP layer: the API call sequence the tool uses (regardless of which DLL imported which function first), observable via ETW-TI at the kernel boundary.
Chapter 5 — IOC Taxonomy: Host, Network, and Behavioral
5.1 Host-based IOCs
| IOC | Source | Detection |
|---|---|---|
| File hash (MD5/SHA256) | File on disk or memory dump | AV/EDR file scan; Sysmon EID 1 Hashes field |
| imphash | PE import table | pe-sieve, capa, EDR PE scanner |
| PDB path | PE debug directory | strings, FLOSS, pefile parse |
| Compilation timestamp | TimeDateStamp in IMAGE_FILE_HEADER | pefile parse; DFIR timeline |
| Default named pipe | Tool IPC channel (Cobalt Strike default: \.\pipe\MSSE-<rand>) | Sysmon EID 17 (pipe created), 18 (pipe connected) |
| Default mutex name | Mutual exclusion object; tools use them to prevent double-run | Sysmon EID 17 (CreateMutant via kernel callback) |
| Export function name | In-memory PE reflective entry point | pe-sieve, Volatility malfind |
| Strings | Hardcoded C2 URLs, user-agents, commands | strings, FLOSS (unpack obfuscated strings) |
5.2 Network-based IOCs
| IOC | Source | Detection |
|---|---|---|
| JA3/JA4 fingerprint | TLS ClientHello byte pattern (cipher suites, extensions, elliptic curves) | Network detection; Zeek; Suricata JA3 module |
| User-agent string | HTTP header in beacon check-in | Proxy logs; Suricata http.user_agent rule |
| URI pattern | Beacon check-in path (Cobalt Strike default: /api/) | Proxy logs; Suricata URI match |
| Jitter/timing pattern | Beacon sleep interval regularity | NetFlow / Zeek long-connection detection |
| SNI / certificate fields | TLS SNI hostname; self-signed cert subject fields | Network detection; JA4 |
| Destination IP/domain | C2 address | Threat-intel feed; DNS sink-hole |
5.3 Behavioral IOCs
| IOC | Source | Detection |
|---|---|---|
| Parent-child relationship | Process creation tree | Sysmon EID 1 ParentImage field |
| API call sequence | Sequence of NT* syscalls (alloc → write → protect → execute) | ETW-TI provider; user-mode hook sequence |
| Privilege use | Security EID 4673 (sensitive privilege used) | SIEM correlation on privilege + process name |
| Handle open pattern | Sysmon EID 10: GrantedAccess on lsass.exe | Cross-process handle open rule |
| Network connection from unexpected process | cmd.exe or wscript.exe connecting outbound | Sysmon EID 3 filtering by process image |
| DLL loaded from unexpected path | Sysmon EID 7: ImagePath in user-writable location | Image-load rule |
Chapter 6 — The Pyramid of Pain Applied to Tooling
David Bianco's Pyramid of Pain ranks indicators by the cost to the adversary of changing them:
╔══════════════════════════════╗
║ TTPs (Techniques) ║ hardest — must redesign the attack
╠══════════════════════════════╣
║ Tools ║ rebuild the tool; weeks
╠══════════════════════════════╣
║ Network / host ║
║ artifacts ║ hours–days (reconfig)
╠══════════════════════════════╣
║ Domain names ║ register a new one; easy
╠══════════════════════════════╣
║ IP addresses ║ change infrastructure
╠══════════════════════════════╣
║ Hash values (file, imphash) ║ trivial — recompile
╚══════════════════════════════╝
Applied to tooling triage:
- A detection on a file hash is bypassed by recompiling. Flag this detection as brittle.
- A detection on imphash is bypassed by adding one dummy import. Marginally less brittle.
- A detection on a PDB path string is bypassed by stripping the binary or changing the path. Cheap.
- A detection on a named-pipe name is bypassed by changing the pipe configuration. Moderate cost.
- A detection on the API call sequence (alloc → write → protect → create-thread) observed via ETW-TI is not bypassed by any of the above. The adversary must redesign the injection primitive. This is where you want to build.
In an interview: when asked "what detection would you build for this tool?", the correct answer starts with the TTP-level behavioral detection, names the sensor (ETW-TI, kernel callback, Sysmon EID 10), and explains why it survives a recompile. Then you also note the lower-tier IOCs (imphash, named pipe) that are fast to check but brittle.
Chapter 7 — OPSEC Hygiene: What a Tool Leaves Behind and Why
7.1 Default artifacts — what ships out of the box
Every tool compiles and runs with defaults. Default artifacts are those that are:
- Well-documented by the security community (known-bad imphash families, Cobalt Strike default pipes).
- Matched by public detection rules (Sigma rules, Suricata rules, YARA).
- Triggerable by a single IOC — the analyst does not need to correlate multiple signals.
| Category | Default artifact | Detection |
|---|---|---|
| Compilation | PDB path with developer username or build server path | strings on binary |
| Compilation | Compile timestamp (TimeDateStamp) not zeroed | dumpbin /headers |
| C2 profile | Default Cobalt Strike named pipe (\.\pipe\MSSE-<4-hex>) | Sysmon EID 17/18 |
| C2 profile | Default user-agent (Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0;)) | Proxy logs |
| C2 profile | Default sleep/jitter (60s/0%) | NetFlow periodic-beacon detection |
| Staging | Well-known imphash for Cobalt Strike / Metasploit beacon family | PE scanner |
| Network | Self-signed cert with default Cobalt Strike subject fields | JA3 or cert inspection |
7.2 The OPSEC linter checklist
An OPSEC linter compares a tool's metadata against this checklist and assigns a noise score. Each item has a weight (impact) and a detection description:
[HIGH impact] default_named_pipe — default C2 pipe name matches known-bad pattern
[HIGH impact] known_imphash — imphash matches a known beacon family in the classification DB
[HIGH impact] default_user_agent — user-agent string matches a known-default pattern
[MEDIUM impact] pdb_path_present — PDB path not stripped from debug directory
[MEDIUM impact] compile_timestamp_present — TimeDateStamp not zeroed
[MEDIUM impact] default_sleep_nojitter — sleep interval is default; jitter not configured
[LOW impact] default_beacon_uri — beacon check-in URI matches a well-known pattern
The output is a ranked list of findings (highest impact first), each with its detection
description: "default Cobalt Strike pipe \.\pipe\MSSE-* — detected by Sysmon EID 17/18 with a
Sigma rule matching the pipe-name pattern."
7.3 What mitigations reduce the signal
Each artifact has a mitigation (not a bypass — a reduction):
| Artifact | Mitigation |
|---|---|
| Default named pipe | Configure a custom pipe name in the C2 malleable profile |
| Default user-agent | Set a realistic user-agent matching the target environment |
| PDB path | Strip the binary or configure the linker to not emit debug info |
| Compile timestamp | Zero the TimeDateStamp field post-compilation |
| Known imphash | Reconfigure imports (add/remove a function); recompile |
| Default beacon URI | Configure a realistic URI pattern in the malleable profile |
None of these mitigations is evasion — they reduce specific low-tier IOC surface while leaving behavioral (TTP-level) detection intact. A JA3 fingerprint changes when the TLS cipher-suite order changes; the behavior of the beacon (periodic outbound connection from an unusual process) does not.
Chapter 8 — Detection Engineering for Tooling
Sysmon rules for tooling indicators
Named pipe creation (Cobalt Strike default pattern):
title: Default C2 Named Pipe Creation
logsource: {product: windows, category: pipe_created}
detection:
selection:
PipeName|contains: 'MSSE-'
condition: selection
tags: [attack.command_and_control, attack.t1071]
DLL loaded from user-writable directory (reflective/sideload):
title: DLL Loaded from User-Writable Path
logsource: {product: windows, category: image_load}
detection:
selection:
ImageLoaded|startswith:
- 'C:\Users\'
- 'C:\ProgramData\'
- 'C:\Temp\'
Signed: 'false'
condition: selection
tags: [attack.defense_evasion, attack.t1574.001]
Process with unexpected outbound network connection:
title: Unexpected Outbound Connection from Scripting Host
logsource: {product: windows, category: network_connection}
detection:
selection:
Image|endswith:
- '\cmd.exe'
- '\wscript.exe'
- '\cscript.exe'
- '\mshta.exe'
Initiated: 'true'
condition: selection
tags: [attack.command_and_control, attack.t1071.001]
Suricata rule for default C2 user-agent
alert http $HOME_NET any -> $EXTERNAL_NET any (
msg:"Default Cobalt Strike User-Agent";
flow:established,to_server;
http.user_agent; content:"MSIE 9.0";
content:"Trident/5.0";
sid:9000001; rev:1;
)
Chapter 9 — Tooling Languages and Their Binary Characteristics
The JD explicitly names: Python, C#/.NET, C/C++, Rust, Nim, Go, PowerShell.
| Language | Binary type | Key characteristics for triage |
|---|---|---|
| Python | .py script or pyc-bundled PE | _PyObject_New in imports; PyInstaller adds a _MEIPASS2 artifact |
| C#/.NET | PE with CLR metadata | mscoree.dll!_CorExeMain in import table; .text contains MSIL bytecode; DataDirectory[14] = CLR header; clrjit.dll loaded at runtime |
| C/C++ | Native PE/ELF | Direct Win32 imports or Linux libc imports; MSVC linker version in optional header; __libc_start_main in ELF dynsym |
| Rust | Native PE/ELF | No C runtime; imports kernel32.dll + ntdll.dll directly on Windows; ELF imports libpthread.so; panics leave rust_begin_unwind in export/symbol table |
| Nim | Native PE/ELF cross-compiled | NimMain symbol in export table or ELF dynsym; links against libgcc / mingw on Windows; very small import table on Windows builds — quiet from an imphash standpoint |
| Go | Native PE/ELF | Large binary size; main.main entry; runtime.Goroutine, runtime.newproc in ELF dynsym or PE export; c:\go\src\ in strings if not stripped |
| PowerShell | Script (text) or AMSI-visible buffer | Loaded into powershell.exe or pwsh.exe; AMSI scans the decoded buffer; Sysmon EID 1 CommandLine or ScriptBlock EID 4104 |
Nim deserves special attention because it is explicitly named in the JD. Nim compiles to native
code via GCC/CLANG cross-compilation. On Windows, a Nim binary built with nim c --cpu:amd64 --os: windows produces a PE that:
- Has very few imports (often just
kernel32.dll+ntdll.dll+ws2_32.dll). - Lacks MSVC runtime artifacts (
msvcrt.dllis not imported). - Contains
NimMainin its export table if it is a DLL. - Is not flagged by name-based AV signatures that look for "known tool strings."
The low-import-count PE is quiet from an imphash perspective but is detectable at the behavioral level by the same API sequence any injection tool produces.
Chapter 10 — Misconceptions
"Static analysis catches everything." Static analysis catches structural IOCs: imphash, PDB path, strings, exported function names. Packed or obfuscated binaries hide all of these. The PE header fields (subsystem, size of optional header) are still readable, but the import table may be empty or fake. Static analysis is the first pass; behavioral (dynamic) analysis is the complement.
"Stripping a binary makes it clean." Stripping removes the section header names and debug symbols. It does not remove the import table (IDT), the export table, or the PE header fields. imphash, named-pipe defaults, and behavioral indicators survive stripping.
"imphash is the primary detection for a tool." It is one of the weakest detections. It changes with a recompile. For engagement-grade tooling triage, focus on the behavioral IOCs (API call sequence, parent-child tree, ETW-TI events) that survive any recompilation.
"A quiet imphash means the tool is safe to use." A Nim-built PE with almost no imports has a
very distinctive imphash because it is so empty — a PE that only imports kernel32.dll + ntdll.dll from a process that should have a richer import table is suspicious. Quiet is not
invisible.
"OPSEC hygiene is just about renaming the binary." Renaming a binary changes the file hash and on-disk name. It does not change the imphash, the PDB path, the named-pipe name, the user-agent, the JA3 fingerprint, or the behavioral API sequence. Each of those requires a separate targeted mitigation.
"A known-bad imphash match is a confirmed malicious binary." imphash family clustering groups binaries by their build configuration. Multiple legitimate programs share import table patterns. A match narrows the scope for investigation; it is not a conviction. Correlate with other IOCs.
Lab Walkthrough
Lab 01 — PE/ELF Format Analyzer
What it does. Ingests a synthetic binary metadata dict (mimicking what pefile / pyelftools
would return) and:
- Extracts the file type, architecture, subsystem, compilation timestamp.
- Parses the import list and computes the imphash.
- Extracts the PDB path (if present) from the debug directory.
- Lists exports.
- Returns a structured analysis with OPSEC indicator flags.
Key data structure:
pe_metadata = {
"magic": "MZ",
"machine": "x86-64",
"timestamp": 1710000000, # Unix epoch of compilation
"subsystem": 3, # CUI
"imports": [
("kernel32.dll", "CreateProcessW"),
("kernel32.dll", "VirtualAllocEx"),
("ntdll.dll", "NtWriteVirtualMemory"),
("ws2_32.dll", "WSAStartup"),
],
"exports": [],
"pdb_path": "C:\\Users\\operator\\implant\\x64\\Release\\implant.pdb",
"sections": [".text", ".data", ".rdata", ".reloc"],
}
Solution logic:
extract_file_type(meta)— "PE32+" (magic "MZ" + machine "x86-64").compute_imphash(imports)— normalize each(dll, func)todll_no_ext.func_lower, join, MD5.extract_pdb_path(meta)— returnmeta.get("pdb_path").opsec_flags(meta)— check: PDB path present (pdb_pathnot None/empty), timestamp not zeroed, export table namedReflectiveDll, known-bad imphash in a small catalog.analyze(meta)— returns{file_type, imphash, exports, pdb_path, opsec_flags}.
Running:
cd phase-02-offensive-tooling-development/lab-01-pe-format-analyzer
LAB_MODULE=solution python3 -m pytest -q # 14 tests
Lab 02 — Tooling IOC / OPSEC Linter
What it does. Ingests a tooling inventory — a list of tool dicts, each with configuration metadata — and runs each tool against the OPSEC checklist. Returns a ranked list of OPSEC findings with detection descriptions and a per-tool noise score.
Key data structure:
tool_inventory = [
{
"name": "beacon-alpha",
"type": "c2_agent",
"named_pipe": "MSSE-1234-server", # default Cobalt Strike pattern
"user_agent": "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0;)",
"beacon_uri": "/api/",
"sleep_seconds": 60,
"jitter_percent": 0,
"imphash": "a8c7b3d2e1f0a9b8c7d6e5f4a3b2c1d0", # not in known-bad catalog
"pdb_path_stripped": False,
"pdb_path": "C:\\build\\payload.pdb",
}
]
Solution logic:
check_named_pipe(tool)— comparenamed_pipeagainst known-bad patterns (MSSE-,postex_,dce_4,status_,msagent_). ReturnHIGHfinding with Sysmon EID 17 detection description if matched.check_user_agent(tool)— compare against known-default agent strings.HIGHfinding.check_imphash(tool, known_bad_db)— lookup in known-bad imphash catalog.HIGHfinding.check_pdb(tool)— ifpdb_path_stripped == Falseandpdb_pathnon-empty.MEDIUMfinding.check_sleep_jitter(tool)— ifjitter_percent == 0andsleep_secondsis a common default (60, 3600).MEDIUMfinding.check_beacon_uri(tool)— known-default patterns (/api/,/submit.php,/g.pixel).LOW.lint(tool_inventory, known_bad_db)— runs all checks; weights: HIGH=3, MEDIUM=2, LOW=1; sorts findings by weight desc; returns{tool_name, noise_score, findings: [...]}.highest_risk_tool(linted_results)— tool with highest noise_score.
Running:
cd phase-02-offensive-tooling-development/lab-02-tooling-ioc-opsec-linter
LAB_MODULE=solution python3 -m pytest -q # 15 tests
Success Criteria
After completing this phase, without notes you can:
-
Draw the PE layout from offset 0: DOS header (
e_lfanew) → NT headers (signature + file header + optional header) → section table → sections. -
Name the fields in
IMAGE_FILE_HEADERand what each means for triage. -
Compute imphash from a list of
(dll, function)tuples (normalize, join, MD5). - Explain the Pyramid of Pain and place imphash, named-pipe name, API sequence, and TTP each at the correct tier.
-
Explain the difference between
ET_EXECandET_DYNin an ELF and what it implies. - Name three host-based, three network-based, and two behavioral IOCs for a typical C2 agent.
- Name three default OPSEC indicators that ship with common C2 frameworks and their detections.
- Write a Sysmon Sigma rule for a named-pipe creation IOC.
-
Pass both labs (14 + 15 tests green with
LAB_MODULE=solution).
Common Mistakes and OPSEC
Reporting imphash without noting it is bottom-of-pyramid. A report that says "we identified this tool by its imphash" without also providing the behavioral detection leaves the client with a detection that fails on the first recompile.
Treating PDB-path presence as the most important finding. PDB paths are easy to find and easy to strip. Prioritize named-pipe and user-agent defaults — they require profile reconfiguration to fix, not just a linker flag.
Missing the ELF .dynamic section for Linux tooling. PE analysis is better-tooled, so analysts
often stop at ldd for ELF libraries. The .dynamic section is the DT_NEEDED list — equivalent
to the PE IDT — and provides the same family-classification value.
Confusing Nim and Go by binary size. Nim-compiled binaries targeting Windows are often small (GCC static libc); Go binaries are characteristically large (statically linked Go runtime). Both have minimal imports on Windows; size and symbol table contents distinguish them.
Equating a clean OPSEC linter score with a safe tool. The linter checks known default indicators. A tool that is not in any known-bad catalog and has no default named pipes can still be detected via behavioral IOCs (ETW-TI, kernel callbacks) — those are not within the linter's scope. Document this limitation.
Interview Q&A
Q1: Walk me through the PE format from the DOS header to the import table. What fields matter most for triage?
Answer:
A PE file begins at offset 0 with the IMAGE_DOS_HEADER, which opens with the two-byte MZ magic
(0x4D5A). The field at offset 0x3C is e_lfanew, a 4-byte offset pointing to the
IMAGE_NT_HEADERS structure. Between the DOS header and that offset is the DOS stub (the
"This program cannot be run in DOS mode" message that most PE tools skip).
At e_lfanew is the NT headers, structured as: a 4-byte PE signature ("PE\0\0"), then the
20-byte IMAGE_FILE_HEADER:
Machine(2 bytes):0x8664= AMD64,0x14C= i386. This tells you the target architecture.NumberOfSections: how many section table entries follow the optional header.TimeDateStamp(4 bytes): Unix epoch of when the linker produced the binary. A default-on IOC unless zeroed.
Then the IMAGE_OPTIONAL_HEADER (variable size based on PE32 vs PE32+):
Magic:0x20B= PE32+ (64-bit),0x10B= PE32 (32-bit).AddressOfEntryPoint: RVA of the first instruction. Unusual value (e.g., points into.data) is a red flag.Subsystem: 2 = Windows GUI, 3 = console, 14 = EFI. A console tool with subsystem 2 is suspicious.DataDirectory[1]— the Import Directory: RVA and size of the IDT.DataDirectory[6]— the Debug Directory: where the PDB path lives.
At e_lfanew + 4 + 20 + SizeOfOptionalHeader is the section table — an array of
IMAGE_SECTION_HEADER structures. Each has the section name (.text, .data, etc.), virtual
address, virtual size, and file offset.
For triage, the highest-signal fields are: Machine (tells you platform), TimeDateStamp
(timestamps the build), Subsystem (GUI vs console anomaly), DataDirectory[1] (import table →
imphash → family classification), and DataDirectory[6] (PDB path → operator identity). The
section table gives the code/data layout and flags like missing .reloc (unusual for DLLs) or
an unusually large .rsrc (possible resource-hiding).
Q2: What is imphash, how is it computed, and where does it sit on the Pyramid of Pain? When is it useful and when is it not?
Answer:
imphash (import hash) is the MD5 of the PE's normalized import list. The normalization: for each
(DLL, function) pair in Import Directory Table order, lowercase the DLL name, strip the .dll
extension, lowercase the function name, concatenate as dll.function. Join all pairs with commas,
MD5 the result.
It was introduced by Mandiant in 2014 to group malware families: binaries compiled from the same source with the same linker and library versions produce identical import tables (same function order, same DLL names), so they produce the same imphash. The imphash groups them into a family fingerprint without requiring identical file content.
On the Pyramid of Pain, imphash sits at the hash layer — one step above raw file hash. The adversary cost to change it: add one dummy import function to the source code, recompile. Five minutes. It is therefore not a durable detection — it is a classification and triage tool.
It is useful when: you have a sample with an unknown imphash and you want to cluster it against your database of known-bad families ("this PE's imphash matches the Cobalt Strike 4.x beacon family"); or in DFIR when you want to establish that two binaries from different intrusions share the same build configuration.
It is not useful as a production detection rule in a SIEM: the rule would be bypassed within one
recompile cycle. The durable detection for the same tool is at the TTP layer: the API call
sequence (VirtualAllocEx → WriteProcessMemory → CreateRemoteThread) observed via ETW-TI,
which survives any recompile.
Q3: Explain the difference between the Import Directory Table and the Import Address Table in a PE. Why does the IAT matter for detecting process injection?
Answer:
Both the IDT and IAT are data structures in the PE's import machinery, but they serve different purposes at different times.
The Import Directory Table (IDT) is the static, compile-time structure: an array of
IMAGE_IMPORT_DESCRIPTOR entries, one per imported DLL. Each entry contains:
- The DLL name (RVA to ASCII string).
- An INT (Import Name Table) pointer: RVA to an array of IMAGE_THUNK_DATA, each pointing to a function name or ordinal.
- An IAT pointer: same structure, but this is what the loader overwrites at load time.
The Import Address Table (IAT) starts as a copy of the INT on disk. At load time, the Windows
loader resolves each import: it finds the DLL in memory, locates the exported function, and writes
the actual virtual address into the IAT slot. After load, the IAT entries are live function pointers
that the compiled code calls through (CALL QWORD PTR [rip + offset_to_IAT_slot]).
Why the IAT matters for injection detection:
Classic IAT hooking (and classic injection) involves writing a new address into an IAT slot — redirecting a function call to attacker code. EDR user-mode hooks do the inverse: they overwrite IAT-adjacent entries (or the function's preamble) to intercept calls. The IAT is the live jump table in a running process's address space.
More relevantly for modern injection detection: ETW-TI monitors NtWriteVirtualMemory calls
targeting another process's address space. If code writes to the remote process's IAT region, that
write is observable — it appears as an ALLOCATE/WRITE/PROTECT sequence on the remote process
memory region that overlaps the IAT's virtual address range. This is a specific behavioral IOC:
cross-process write to an address within the target PE's mapped image = likely hook or injection.
Q4: What OPSEC indicators does a compiled tool leave by default? Name three and their detection.
Answer:
1. PDB path in the debug directory. When compiled with debug information enabled (the default
in MSVC and many build systems), the PE contains a DEBUG data directory entry of type
IMAGE_DEBUG_TYPE_CODEVIEW that includes the absolute path to the .pdb file on the build system.
This path typically contains the developer's username, the project directory, and the binary name.
Detection: strings or FLOSS on the binary; pefile Python library's DIRECTORY_ENTRY_DEBUG
parse; many AV/EDR products extract this path during file scanning. Any binary submitted to
VirusTotal has its PDB path indexed.
2. Default named pipe name for C2 agents. C2 frameworks ship with default internal IPC pipe
names that are documented by the security community: Cobalt Strike defaults include the pattern
\pipe\MSSE-<4hex>-server and \pipe\postex_ssh_<4hex>.
Detection: Sysmon EID 17 (PipeEvent: Pipe Created) logs the pipe name when a named pipe is
created. A Sigma rule matching PipeName|contains: "MSSE-" or "postex_" fires on the pipe
creation, before any connection uses it.
3. Compilation timestamp (TimeDateStamp) not zeroed. The IMAGE_FILE_HEADER.TimeDateStamp
is set to the Unix epoch at link time. It is not zeroed by default. In a DFIR timeline, this field
timestamps the build event and, if genuine, tells you when the binary was compiled relative to
when the attack began (a freshly compiled binary minutes before the attack is a high-confidence
custom build; a years-old timestamp suggests a re-used tool).
Detection: extracted during any PE triage (dumpbin /headers, pefile); DFIR platforms record it
in their file metadata. It is not alertable in real time but is a critical timeline artifact.
Q5: How does the ELF .dynamic section compare to the PE import table for tooling classification?
Answer:
They serve the same purpose — specifying runtime library dependencies — but the ELF mechanism is more general and the classification differs in detail.
In a PE, the IDT is a flat array of (DLL, function list) pairs. The Windows loader walks the
IDT at process creation, finds each DLL, and resolves each function by name or ordinal. The IDT is
required for any binary that uses external functions.
In an ELF, the .dynamic section is a list of tagged entries. The relevant ones:
DT_NEEDEDentries: one per required shared library, each naming the library (e.g.libc.so.6,libpthread.so.0). These are the equivalent of the PE's DLL name list.DT_SYMTAB→.dynsym: the dynamic symbol table, listing function names with their version requirements. This is equivalent to the per-DLL function import list.DT_JMPREL→.rela.plt: relocation entries for PLT stubs — the lookup table that gets filled in at first call (equivalent to IAT at runtime).
For classification, the ELF dynamic symbol table (.dynsym) is the richest source: it lists the
exact library function names the binary uses. A Linux implant that imports mmap, mprotect,
memfd_create, and dlopen but nothing from higher-level libraries (no printf, no network stack
functions) is exhibiting a shellcode-loader-like import profile. A Nim-compiled binary on Linux will
have a characteristic set of libgcc and runtime symbols mixed with minimal libc use.
The limitation: a statically linked binary (common in Go, sometimes Rust, sometimes custom loaders)
has no .dynamic section and no .dynsym — all code is inlined. Classification falls back to
symbol table strings (if not stripped) or strings / FLOSS output.
Q6: You find a binary with no import table entries. What are the likely explanations, and how do you triage further?
Answer:
A PE with an empty or absent IDT (DataDirectory[1] size = 0) is unusual and indicates one of several scenarios, ranging from benign to high-fidelity IOC:
1. Statically linked. The binary was compiled with all dependencies inlined (e.g., a Rust or Go
binary targeting Windows compiled with the default static runtime). It does not need imports because
all code is in the binary itself. These are large in file size. Triage: check the binary size; look
for Go runtime strings (runtime.main, runtime.Goroutine) or Rust panic symbols
(rust_begin_unwind).
2. Manually mapped / packed / encrypted. The original import table has been removed and the
binary resolves imports at runtime via a custom loader (calls GetProcAddress / LoadLibraryA
manually). This is a common packing technique. Triage: look for GetProcAddress and LoadLibraryA
(or their hashed equivalents) in the strings; look for unusually large entropy sections (packed/
encrypted); the entry point may be in a .data or unnamed section (non-standard). Use DIE
(Detect-It-Easy) or PEid to identify common packers.
3. Reflective loader. A reflective DLL is a DLL that has been modified so its ReflectiveLoader
export can load it from memory. The in-memory version often has a minimal or reconstructed import
table; only the loader stubs for GetProcAddress / LoadLibraryA appear. Triage: check the export
table for ReflectiveDll, ReflectiveLoader, or a single numeric export ordinal. Scan the raw
bytes for the MZ/PE header sequence at non-zero offsets within the file (shellcode blob with
embedded PE).
Triage further:
- Run
strings/FLOSSto extract readable strings. - Check entropy per section;
> 7.5suggests encryption or compression. - Examine the entry point's bytecode with a disassembler (
Ghidra,Binary Ninja) — look for a GetProcAddress-resolution loop pattern. - If you find an embedded PE (search bytes for
4D5A/5045patterns), extract and analyze it.
Q7: A tool has a known-bad imphash. The attacker recompiles it with one dummy import. Does your detection survive? What would you build instead?
Answer:
No, the imphash-based detection does not survive the recompile. Adding one import function changes the ordered import list; the MD5 changes; the imphash does not match the known-bad hash. The detection fails on the first rebuild.
This is the Pyramid of Pain lesson applied directly: imphash sits at the hash layer. The adversary cost is a code change of one line and a recompile. Your detection was worth zero minutes of friction.
What to build instead — three layers, each more durable:
Layer 1: Named artifact detection (host). If the tool creates a specific named pipe, mutex, or temporary file path, detect on that name. Pipe names require profile reconfiguration to change; mutexes require source code changes. Sysmon EID 17 for pipe creation, keyed on the name pattern (not the imphash), survives recompilation.
Layer 2: Behavioral / API call sequence (host).
The tool implements a technique. The technique requires a specific API call sequence. For a remote
process injector: VirtualAllocEx in process A targeting process B's PID, followed by
WriteProcessMemory, followed by NtCreateRemoteThread. This sequence is observable via ETW-TI at
the kernel boundary, regardless of which DLL imported which function or what the imphash is. Build
this detection in your EDR's ETW-TI consumer or via Sysmon EID 8 (CreateRemoteThread) + EID 10
(ProcessAccess with PROCESS_VM_WRITE mask).
Layer 3: Network behavioral detection. The tool communicates with C2. JA3/JA4 fingerprints the TLS ClientHello; changing the cipher-suite list requires a code or config change, not just a recompile. The beacon timing pattern (periodic egress from an unusual process) requires architectural change. Build these detections at the network layer: JA3 match + beacon-interval analysis in Zeek or a SIEM rule on periodic NetFlow.
Q8: Walk me through how you would produce the tooling triage artifact for a real engagement.
Answer:
On a real engagement (authorized lab only), the tooling triage artifact is produced in four steps.
Step 1 — Inventory. List every tool in the engagement toolkit. For each tool, record: binary name and path, language/runtime, version, MD5/SHA256 of the binary, and the C2 configuration in use (profile name, named pipes, user-agent, beacon interval, jitter).
Step 2 — Static analysis. For each binary, run:
pefile(Python) ordumpbin /headerson Windows binaries: extractMachine,TimeDateStamp,Subsystem, import list, export list, PDB path. Compute imphash.file+objdump -p/readelf -don Linux binaries: extracte_type,e_machine,DT_NEEDEDlist,.dynsymsymbols.strings/FLOSS: extract readable strings for hardcoded paths, URIs, version strings.- Check each imphash against the classification database.
Feed the resulting dict into the PE/ELF format analyzer (Lab 01). The analyzer output is the per-binary triage report.
Step 3 — OPSEC linter. Feed the tool's configuration metadata (named pipes, user-agent, URI, jitter, imphash, PDB-stripped flag) into the OPSEC linter (Lab 02). The linter output is a ranked OPSEC findings list for each tool.
Step 4 — Detection map and deliverable. For each tool, produce the detection-layer map:
| IOC | Layer | Pyramid level | Detection | Durability |
|---|---|---|---|---|
| File hash | Host | Hash | AV/EDR scan | Fails on recompile |
| imphash | Host | Hash | PE scanner family DB | Fails on dummy import |
| Named pipe | Host | Artifact | Sysmon EID 17 + Sigma | Requires profile change |
| User-agent | Network | Artifact | Proxy rule | Requires profile change |
| API sequence | Host (ETW-TI) | TTP | EDR behavioral rule | Requires redesign |
Hand the client the detection map as an appendix to the engagement report. They implement the highest-durability detections they are missing. The hash-level detections are noted as supplementary.
References
Binary format:
- Microsoft PE/COFF specification —
learn.microsoft.com/windows/win32/debug/pe-format - ELF-64 Object File Format — Tool Interface Standard,
refspecs.linuxbase.org/elf/elf.pdf pefilePython library — Ero Carrera — GitHubpyelftoolsPython library — Eli Bendersky — GitHub
Detection and classification:
- Mandiant: "Tracking Malware with Import Hashing" — Carhart (2014) — original imphash definition
- David Bianco — "The Pyramid of Pain" (blog.opensecurityresearch.com)
- mandiant/capa — capability extraction and ATT&CK mapping from static PE analysis (GitHub)
- FLOSS (FireEye Labs Obfuscated String Solver) — extracts obfuscated strings from binaries
Detection rules:
- SigmaHQ/sigma — Sysmon rules for named-pipe creation, image-load from unusual paths, network connection from scripting hosts
- Elastic Detection Rules — rules for PE anomalies and tooling behavioral IOCs
Tooling languages:
- Nim language documentation (
nim-lang.org) — cross-compilation, Windows targets - Go toolchain documentation (
go.dev) — static linking, runtime characteristics - Rust on Windows —
rustuptargetx86_64-pc-windows-gnu/msvc— import table differences
OPSEC and C2 profiles:
- Cobalt Strike malleable C2 profile documentation (Raphael Mudge / Fortra)
threatexpress/malleable-c2-profiles— community C2 profiles and their OPSEC notes (GitHub)