Author: nomnomheapnom
NomSleep is a Windows only sleep obfuscation PoC. While the process sits idle, the whole PE image in memory gets encrypted in place with three stacked layers of RC4. So the binary spends almost all of its lifetime as ciphertext, and only flips back into readable form for the short stretch where a timer callback has to actually run.
There is no dedicated sacrificial thread doing the work. The code captures a
snapshot of the register state from one of the timer queue's worker threads
via CreateTimerQueueTimer, then feeds that same thread a sequence of
NtContinue descriptions. Every step in the chain, the memory protection
change, the RC4 keystream application, the sleep itself and the event signal
at the end, runs from kernel continuation records instead of normal call
sites inside the module text. That is what keeps the flow hard to trace, the
execution never comes back through a regular address in the image.
- Threat model
- Architecture
- Stage chain
- Key derivation
- Mathematical formulation
- Obfuscated API resolution
- Memory protection lifecycle
- Threading model
- Build
- Self-test
- Operational caveats
The goal is to cut down the static and dynamic artefacts that normally stick out when a payload is just sitting there sleeping.
| Artefact | Normal posture | NomSleep posture |
|---|---|---|
| Import Address Table | NtContinue, SystemFunction032, RtlGenRandom visible by name |
Resolved at runtime so no IMAGE_IMPORT_BY_NAME records survive |
| Routine name strings | Present verbatim in .rdata |
XOR encoded with 0x5A, plaintext only ever lives on the stack briefly |
| Sections in memory | Always mapped with their allocation markers | Encrypted for the whole sleep period, write permission revoked at restore |
| Execution flow | Calls leave return addresses and unwind tables behind | All actions happen through captured contexts, no conventional call frames |
| Key material | Static key recoverable from one dump | 96 bytes of derived entropy, zeroed on every cycle no matter which path |
There is no scattered gadget based ROP preamble and no reusable gadget
database. The chain is just an array of CONTEXT records on the caller's
stack, consumed by the single native entry point NtContinue. The timer
queue supplies the worker thread and the primitives do the rest.
Single image, in place, nothing copied around. No mapping tricks, no section duplication, no hollowing.
main thread timer thread
───────────── ───────────────────────────
NomSleepObf( SleepTime )
│
├─ ResolveApiObf( Ntdll, NtContinue )
├─ ResolveApiObf( Advapi32, SystemFunction032/036 )
├─ CryptoInit RtlGenRandom + timing seed
├─ GetImageInfo base, size, entry protect
├─ ImageEnumerateRegions per section protection snapshot
├─ CreateTimerQueueTimer( RtlCaptureContext ) ─────► capture ctx
├─ WaitForSingleObject( 40 ms ) ◄────────────────────── context
│
├─ build CONTEXT records, all on the stack
│
├─ CreateTimerQueueTimer( NtContinue, Stage[i], due_i )
│ due_i = STAGE_BASE_MS + jitter, then monotonic spacing
│
└─ WaitForSingleObject( hEvent, INFINITE ) ◄── fired by last stage
Each stage is its own CONTEXT. The schedule is randomised and the sleep is
jittered, so the time between any two observable protection flips is never
the same across cycles.
src/sleep/obfuscate.c realises the cycle as a linear, dependency ordered
list of continuation records. A stage is a tuple
stage_k = ( f_k, a1_k, a2_k, a3_k, a4_k, saved_context )
where f_k goes into Rip, a1..a4 go into Rcx, Rdx, R8, R9 per the
Windows x64 calling convention, and the rest rides on a copy of the timer
thread's real register state taken by RtlCaptureContext with
ContextFlags set to CONTEXT_FULL first. Each stage is dispatched by its
own CreateTimerQueueTimer( NtContinue, &stage_k, due_k ), with due times
increasing strictly so ordering holds even when consecutive stages land on
different pool threads. The StageSet helper also drops rsp by 8 so the
continuation sees the caller return slot it expects.
| Phase | Index | Routine (f_k) |
Arguments | Purpose |
|---|---|---|---|---|
| Trip wire | 0 |
VirtualProtect |
(ImageBase, ImageSize, PAGE_READWRITE, &OldProtect) |
Make every region writable so the cipher can touch .text |
| Encrypt | 1..3 |
SystemFunction032 |
(Image, Length) -> Layer[0..2] |
RC4 keystream A, B then C applied in place |
| Dwell | 4 |
WaitForSingleObject |
(NtCurrentProcess(), jittered_ms) |
The actual rest |
| Decrypt | 5..7 |
SystemFunction032 |
(Image, Length) -> Layer[2..0] |
RC4 keystream C, B then A, reversed |
| Decoy | 8 |
Sleep |
(0) |
No op call that never blocks, just trace noise |
| Restore | 9.. |
VirtualProtect |
(Region[i].Address, Region[i].Size, Region[i].Protect) |
Every region back to its exact original protection |
| Release | R |
SetEvent |
(hEvent) |
Deterministic end of chain, releases the waiting caller |
The decrypt phase runs in the strict inverse order C, B, A. RC4 is a stream
cipher so a single pass with one key both encrypts and decrypts, but applying
three keys in sequence has to be unwound in reverse. The three 32 byte keys
all come out of one 96 byte entropy block, so between keystream stages
there is no repeated pattern in the segment content or the key schedule.
src/crypto/keygen.c is deliberately not a Rand() & 0xFF loop. The keying
material has to survive someone who only reads the C, so it collects entropy
from independent sources and avalanches them together.
Entropy sources (8 x DWORD64, mixed in a 12 word state):
[0] QueryPerformanceCounter high resolution clock
[1] GetTickCount64 coarse uptime clock
[2] &Ctx stack address (ASLR per call)
[3] GetModuleHandleA( NULL ) image base (ASLR per process)
[4] __builtin_ia32_rdtsc() TSC, low entropy but non deterministic
[5] GetCurrentThreadId() thread identity
[6] &Entropy another stack sample
[7] RtlGenRandom( 32 bytes ) the OS CSPRNG, folded as 4 qwords
The 12 qwords are pushed through the MixBlocks avalanche mixer:
- 12 rounds, each round touching every word.
- Per word rotation of
(Round*7 + i*13) mod 63 + 1bits, so every input bit reaches every output word. - Multiply by odd 64 bit constants, then an
x ^= x >> 32high/low fold.
The first half of the mixing is interrupted with an XOR against the golden
ratio constant 0x9E3779B97F4A7C15, then mixed again for a full second pass.
The output structure therefore comes from the constants as much as from the
entropy, and the derived 96 bytes have no obvious linear relationship to the
inputs.
The output is partitioned like this:
Material[96] = State[0..11] flattened
Layer[0] = Material[0..31] (RC4 key A)
Layer[1] = Material[32..63] (RC4 key B)
Layer[2] = Material[64..95] (RC4 key C)
Seed = State[0] ^ State[5] ^ GetTickCount64() ^ &Ctx
Seed is used only for schedule and sleep jitter. CryptoErase wipes the
whole CRYPTO_CONTEXT through a volatile pointer so the zeroing cannot be
optimised away.
Everything here is written out explicitly so the design can be reviewed as a
fixed point in time. Notation: ^ is XOR, << and >> are shifts, ROTL(x,r)
is a circular left rotate of x by r bits, and multiplication is modulo
2^64.
Every name byte is XORed with the constant K = 0x5A:
e_i = p_i ^ K p_i = e_i ^ K
XOR with a constant is its own inverse, so decode is identical to encode. The plaintext is rebuilt only into a short lived stack buffer.
With Q from QueryPerformanceCounter, T from GetTickCount64, the stack and
module addresses falling out of ASLR, t from rdtsc, the thread id, and a
32 byte random draw R from RtlGenRandom:
E = ( Q, T, &Ctx, base, t, tid, &Entropy, R[0] << 56 )
S[0..7] = E
S[8..11] = the four qwords of the 32 byte random draw
One round, for N = 12 words, using the constants
c0 = 0x9E3779B97F4A7C15, c1 = 0xC2B2AE3D27D4EB4F,
c2 = 0x165667B19E3779F9, c3 = 0x85EBCA77C2B2AE63:
for word i, neighbour j = (i + 1) mod N:
u = S[i] ^ ( S[j] + ROTL( S[j], 23 ) )
v = u * c[ i mod 4 ]
w = v ^ ( v >> 32 )
a = ( 7*round + 13*i ) mod 63 + 1
S[i] = ROTL( w, a ) ^ c[ (round + i) mod 4 ]
The full schedule runs the block twice with a mid point interrupt:
MixBlocks( State, 12, 12 )
State[3] ^= c0
MixBlocks( State, 12, 12 )
The rotation amount is never zero, so nothing can fail to diffuse.
G = State flattened to 96 bytes
K_A = G[0..31]
K_B = G[32..63]
K_C = G[64..95]
seed = S[0] ^ S[5] ^ GetTickCount64() ^ &Ctx
SystemFunction032 implements the classic RC4 stream cipher. Key scheduling
builds the 256 byte box from the key, then the PRGA emits a keystream and the
data is XORed with it byte by byte in place.
C_i = P_i ^ Ks_i
One stage of encryption is its own inverse for a fixed key, which is exactly why the three key passes must be released in reverse.
A 64 bit linear congruential generator supplies the per cycle randomness:
x_n+1 = A * x_n + C mod 2^64
A = 6364136223846793005
C = 1442695040888963407
The output used is the high 32 bits of the next value. J(M) is the LCG
output taken modulo M. Stage due times are
d_0 = STAGE_BASE_MS + J(STAGE_JITTER_MS)
d_k = d_(k-1) + STAGE_SPACING_MS + J(STAGE_JITTER_MS)
with STAGE_BASE_MS = 70, STAGE_SPACING_MS = 85, STAGE_JITTER_MS = 35.
The dwell uses a randomised percentage
p = 2 + J( 10 - 2 + 1 ) in [ 2, 10 ] %
T_sleep = T + ( T * p ) / 100
where T is the SleepTime argument.
F = ( P_0, P_1, ... ) snapshot of every region protection
while encrypted: all regions forced to PAGE_READWRITE
after restore: each region set back to its own P_i from the snapshot
Protection is therefore a reversible per region function, not a one way flattening.
src/api/resolve.c, src/api/obfstrings.c and src/api/xobf.c strip every
useful string out of the binary.
| Decoded name | Encoded byte array (XOR 0x5A) |
|---|---|
Ntdll |
14 2E 3E 36 36 |
Advapi32 |
1B 3E 2C 3B 2A 33 69 68 |
NtContinue |
14 2E 19 35 34 2E 33 34 2F 3F |
SystemFunction032 |
09 23 29 2E 3F 37 1C 2F 34 39 2E 33 35 34 6A 69 68 |
SystemFunction036 |
09 23 29 2E 3F 37 1C 2F 34 39 2E 33 35 34 6A 69 6C |
Sleep |
09 36 3F 3F 2A |
XorDecode rebuilds the plaintext into a stack buffer only at the point of
use. ResolveApiObf then goes through the normal GetModuleHandleA,
LoadLibraryA, GetProcAddress chain. The plaintext lives for the lifetime
of one call frame and is not retained anywhere.
The most fragile part of in place sleeping is protection flattening. If you
flip the whole image to PAGE_READWRITE and later restore only the code
region, then .data and .rdata end up read only, and the process corrupts
itself on the first write. NomSleep tracks per region protections instead:
GetImageInfo(src/pe/image.c) returns the base,SizeOfImageand the entry point region protection for the coarse stage.ImageEnumerateRegionswalks[Base, Base + SizeOfImage)withVirtualQuery, clamps each region to the image bounds and records{Address, Size, Protect}for up toIMAGE_MAX_REGIONS(12).- The restore stage replays one
VirtualProtectper snapshot, so every section returns to its exact original protection instead of one uniform value.
That is the closure of the chain:
RW --> encrypt A,B,C --> sleep --> decrypt C,B,A --> Sleep(0) --> restore per region
and it is why the process stays fully functional across repeated
NomSleepObf() calls.
Two threads cooperate.
- Caller. Runs
NomSleepObf, does all resolution and setup, then blocks onWaitForSingleObject( hEvent, INFINITE ). - Timer worker. A pool owned thread from the timer queue. It first runs
RtlCaptureContextto snapshot its own register state, then gets dispatched through eachNtContinuerecord in due time order.
Completion is signalled by the final stage calling SetEvent, not by the
caller's wait timing out. That makes the end of chain race free. On any early
failure the caller jumps straight to Cleanup, which always does
CryptoErase, DeleteTimerQueue and handle teardown. No timer leaks, no key
survives.
The toolchain used during development is zig cc targeting
x86_64-windows-gnu (zig 0.16.0, no MinGW needed). A normal MinGW-W64 cross
compiler builds the same tree untouched.
make x64 build, bin/NomSleep.x64.exe
make both x64 + x86
make x86 32 bit build
make debug x64 with -DSLEEP_DEBUG logging
make test build and run the self test under wine
make clean remove bin/
The makefile picks whichever cross compiler it finds, zig first, MinGW fallback. Explicit zig invocation, which is what CI uses:
find src -type f -name '*.c' -print0 | xargs -0 zig cc \
-target x86_64-windows-gnu -Os \
-fno-asynchronous-unwind-tables -ffunction-sections -fdata-sections \
-Wl,--gc-sections -s -Iinclude -o bin/NomSleep.x64.exe
make test builds a headless harness (tests/harness.c) that exercises every
hardened component under wine:
- XorDecode round trip, every obfuscation table decodes to its exact plaintext.
- Key derivation. Two consecutive
CryptoInitcalls give differentMaterial, layer offsets and lengths are correct,CryptoErasewipes the whole context includingSeed. - Triple layer RC4. An 8 KiB pattern is encrypted A,B,C, confirmed changed,
then decrypted C,B,A and confirmed byte identical, through the resolved
SystemFunction032rather than a hard link. - Protection lifecycle.
ImageEnumerateRegionsreturns at least one region, the image is flipped toPAGE_READWRITEand restored per region, then the re enumeration count, sizes and protections are asserted equal.
The failure count maps to the process exit code, 0 is all pass.
- Scheduling constants.
CAPTURE_GRACE_MS = 40,STAGE_BASE_MS = 70,STAGE_SPACING_MS = 85andSTAGE_JITTER_MS = 35are tuned for a quiet single core pool under wine. A busy machine may need wider spacing so due times never collapse on each other. - Wine fidelity. The
NtContinuedispatch is fragile on the Linux wine abstraction layer. Final validation has to happen on genuine Windows hardware before any kind of deployment. - Whole image encryption. The transform assumes the image can be flipped
to
PAGE_READWRITEin one call. Images with exotic mixed protection such as packed sections or guarded pages are out of scope for a PoC. - Jitter range. The 2% to 10% sleep jitter keeps the idle time statistically irregular without dragging the schedule into long tail latencies.
- Do not lift this into an implant, a loader or anything survivable without first owning each of these constants and the dispatch contract on your target environment. This is an educational PoC.
Author: nomnomheapnom
Published as a research artefact. It documents a mechanism. Nothing in here is advice to deploy malicious software.