Secure memory cleanup
TL;DR
You decrypted your shellcode, used your encryption key, fetched a C2 address. All three are still sitting in process memory until the GC eventually overwrites them — minutes to never. A memory dump in that window exposes everything.
| You want to wipe… | Use | Cost |
|---|---|---|
A []byte slice (key, plaintext, decrypted blob) | SecureZero | One pass; compiler-resistant (volatile) |
A VirtualAlloc-backed region (shellcode RWX after exec) | WipeAndFree | Zero + VirtualFree(MEM_RELEASE) |
| Everything created inside a function scope | DoSecret | Defer-style: returned secrets get zeroed automatically when callback exits |
What this DOES achieve:
- Sensitive bytes are zeroed BEFORE control returns to the caller — no GC race, no compiler dead-store-elimination cleverness.
- For VirtualAlloc'd shellcode regions: zeroed THEN unmapped, so even a kernel-level scanner sees no commit.
DoSecretmakes the wipe defer-safe — caller can't forget.
What this does NOT achieve:
- Doesn't wipe the Go heap copy —
string<->[]byteconversions allocate. If the secret ever lived as a string, some pre-conversion copy may still be on the heap until GC. Avoid string conversions for secrets. - Doesn't wipe register / stack residue — values that spilled to the stack during arithmetic stay until the stack frame is overwritten by something else. Acceptable for most threat models; not for nation-state forensic.
- Crash-dump still wins if it happens BETWEEN secret creation and wipe — keep the window short.
- Linux: doesn't unmap if you didn't
VirtualAlloc—WipeAndFreeis Windows-shaped. UseSecureZerothenmmap.Munmapmanually for the Linux equivalent.
Primer
After your shellcode runs, its decrypted bytes, encryption keys, and C2 addresses sit in process memory. If the process is dumped — by an analyst, EDR memory scanner, or LSASS-style live snapshot — that data is exposed.
Naïve approaches fail:
for i := range buf { buf[i] = 0 }— Go's optimizer happily removes the writes if it sees you don't read the buffer afterwards.copy(buf, make([]byte, len(buf)))— same problem.
Go's clear builtin is treated as an intrinsic the compiler must NOT
optimize away. SecureZero wraps it. WipeAndFree adds the
VirtualProtect → write zeros → VirtualFree sequence required when the
memory came from windows.VirtualAlloc. DoSecret is the experimental
Go 1.26 runtimesecret mode: register/stack/heap erasure on function
return.
How it works
flowchart LR
subgraph SecureZero
BUF["[]byte"] --> CLEAR["clear(buf)<br>(intrinsic, not elidable)"]
CLEAR --> ZEROED["all bytes 0x00"]
end
subgraph WipeAndFree
VA["VirtualAlloc'd region"] --> PROT["VirtualProtect → RW"]
PROT --> WRITE["zero loop"]
WRITE --> FREE["VirtualFree(MEM_RELEASE)"]
end
subgraph DoSecret
FN["func() { … }"] --> RUN["call inside runtime.Secret guard"]
RUN --> ERASE["registers + stack + heap temps zeroed"]
ERASE --> RET["return to caller"]
end
SecureZero is the everyday tool. WipeAndFree is for the post-shellcode
RWX page. DoSecret is the new hotness — wrap any sensitive computation
unconditionally; without runtimesecret it's a no-op call.
API → godoc
pkg.go.dev/github.com/oioio-space/maldev/cleanup/memory is the authoritative
reference for every exported symbol. This page teaches the
concepts; the godoc is the specification.
Examples
Simple
key := crypto.RandomKey(32)
defer memory.SecureZero(key)
// use key …
Composed (with crypto)
plaintext := decrypt(payload, key)
defer memory.SecureZero(plaintext)
defer memory.SecureZero(key)
// run shellcode …
Advanced (post-injection cleanup)
addr, _ := windows.VirtualAlloc(0, size,
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_EXECUTE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), shellcode)
runShellcode(addr)
_ = memory.WipeAndFree(addr, uint32(size))
Complex (DoSecret for key derivation)
var derived []byte
memory.DoSecret(func() {
tmp := pbkdf2(password, salt, 100_000, 32)
derived = make([]byte, len(tmp))
copy(derived, tmp)
memory.SecureZero(tmp) // belt + braces while DoSecret-experiment is non-default
})
// derived is the only surviving copy; password / pbkdf2 internals erased on Go 1.26+
OPSEC & Detection
| Artefact | Where defenders look |
|---|---|
VirtualProtect(RWX → RW) then VirtualFree(MEM_RELEASE) | EDR call-stack inspection — pattern is benign on its own |
| Process memory scanner finding zeroed pages where shellcode used to be | Periodic memory scanning (hard for blue at scale) |
Crash dump captured BEFORE WipeAndFree runs | Out of scope for this primitive — guard with defer early |
D3FEND counter: D3-PMA (Process Memory Analysis) — defeated by timely cleanup; remains effective when defender captures dump before cleanup runs.
MITRE ATT&CK
| T-ID | Name | Sub-coverage |
|---|---|---|
| T1070 | Indicator Removal | in-memory variant |
Limitations
- Cannot cover what's already on disk. If a paged-out region was
swapped to
pagefile.sys,SecureZerodoesn't reach the swap copy. Mitigation:windows.VirtualLockthe region, thenVirtualUnlock+ zero before free. DoSecretregister erasure requiresGOEXPERIMENT=runtimesecret- Go 1.26+ + linux/amd64 or arm64. Without these, it's a plain call.
- Compiler tail-call elision can leak registers across
DoSecretscope on certain architectures — confirm withgo tool objdumpfor high-stakes uses. - Crash dumps captured before the
deferruns include the secrets in plain text.
See also
cleanup/wipe— same intent, on disk.- Go 1.26 release notes — runtimesecret experiment (link valid once 1.26 ships).
- OWASP — Memory Management Cheat Sheet.