Secure memory cleanup

← cleanup index · docs/index

TL;DR

You decrypted your shellcode, used your encryption key, fetched a C2 address. All three are still sitting in process memory until the GC eventually overwrites them — minutes to never. A memory dump in that window exposes everything.

You want to wipe…UseCost
A []byte slice (key, plaintext, decrypted blob)SecureZeroOne pass; compiler-resistant (volatile)
A VirtualAlloc-backed region (shellcode RWX after exec)WipeAndFreeZero + VirtualFree(MEM_RELEASE)
Everything created inside a function scopeDoSecretDefer-style: returned secrets get zeroed automatically when callback exits

What this DOES achieve:

  • Sensitive bytes are zeroed BEFORE control returns to the caller — no GC race, no compiler dead-store-elimination cleverness.
  • For VirtualAlloc'd shellcode regions: zeroed THEN unmapped, so even a kernel-level scanner sees no commit.
  • DoSecret makes the wipe defer-safe — caller can't forget.

What this does NOT achieve:

  • Doesn't wipe the Go heap copystring <-> []byte conversions allocate. If the secret ever lived as a string, some pre-conversion copy may still be on the heap until GC. Avoid string conversions for secrets.
  • Doesn't wipe register / stack residue — values that spilled to the stack during arithmetic stay until the stack frame is overwritten by something else. Acceptable for most threat models; not for nation-state forensic.
  • Crash-dump still wins if it happens BETWEEN secret creation and wipe — keep the window short.
  • Linux: doesn't unmap if you didn't VirtualAllocWipeAndFree is Windows-shaped. Use SecureZero then mmap.Munmap manually for the Linux equivalent.

Primer

After your shellcode runs, its decrypted bytes, encryption keys, and C2 addresses sit in process memory. If the process is dumped — by an analyst, EDR memory scanner, or LSASS-style live snapshot — that data is exposed.

Naïve approaches fail:

  • for i := range buf { buf[i] = 0 } — Go's optimizer happily removes the writes if it sees you don't read the buffer afterwards.
  • copy(buf, make([]byte, len(buf))) — same problem.

Go's clear builtin is treated as an intrinsic the compiler must NOT optimize away. SecureZero wraps it. WipeAndFree adds the VirtualProtect → write zeros → VirtualFree sequence required when the memory came from windows.VirtualAlloc. DoSecret is the experimental Go 1.26 runtimesecret mode: register/stack/heap erasure on function return.

How it works

flowchart LR
    subgraph SecureZero
        BUF["[]byte"] --> CLEAR["clear(buf)<br>(intrinsic, not elidable)"]
        CLEAR --> ZEROED["all bytes 0x00"]
    end
    subgraph WipeAndFree
        VA["VirtualAlloc'd region"] --> PROT["VirtualProtect → RW"]
        PROT --> WRITE["zero loop"]
        WRITE --> FREE["VirtualFree(MEM_RELEASE)"]
    end
    subgraph DoSecret
        FN["func() { … }"] --> RUN["call inside runtime.Secret guard"]
        RUN --> ERASE["registers + stack + heap temps zeroed"]
        ERASE --> RET["return to caller"]
    end

SecureZero is the everyday tool. WipeAndFree is for the post-shellcode RWX page. DoSecret is the new hotness — wrap any sensitive computation unconditionally; without runtimesecret it's a no-op call.

API → godoc

pkg.go.dev/github.com/oioio-space/maldev/cleanup/memory is the authoritative reference for every exported symbol. This page teaches the concepts; the godoc is the specification.

Examples

Simple

key := crypto.RandomKey(32)
defer memory.SecureZero(key)
// use key …

Composed (with crypto)

plaintext := decrypt(payload, key)
defer memory.SecureZero(plaintext)
defer memory.SecureZero(key)
// run shellcode …

Advanced (post-injection cleanup)

addr, _ := windows.VirtualAlloc(0, size,
    windows.MEM_COMMIT|windows.MEM_RESERVE,
    windows.PAGE_EXECUTE_READWRITE)
copy(unsafe.Slice((*byte)(unsafe.Pointer(addr)), size), shellcode)
runShellcode(addr)
_ = memory.WipeAndFree(addr, uint32(size))

Complex (DoSecret for key derivation)

var derived []byte
memory.DoSecret(func() {
    tmp := pbkdf2(password, salt, 100_000, 32)
    derived = make([]byte, len(tmp))
    copy(derived, tmp)
    memory.SecureZero(tmp) // belt + braces while DoSecret-experiment is non-default
})
// derived is the only surviving copy; password / pbkdf2 internals erased on Go 1.26+

OPSEC & Detection

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

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

MITRE ATT&CK

T-IDNameSub-coverage
T1070Indicator Removalin-memory variant

Limitations

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

See also