Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

NomSleep

Author: nomnomheapnom

What it does

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.

Contents

Threat model

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.

Architecture

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.

Stage chain

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.

Key derivation

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 + 1 bits, so every input bit reaches every output word.
  • Multiply by odd 64 bit constants, then an x ^= x >> 32 high/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.

Mathematical formulation

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.

1. String obfuscation

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.

2. Entropy assembly

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

3. Avalanche mixer

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.

4. Key schedule extraction

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

5. RC4 keystream

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.

6. Schedule and sleep jitter

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.

7. Memory protection closure

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.

Obfuscated API resolution

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.

Memory protection lifecycle

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, SizeOfImage and the entry point region protection for the coarse stage.
  • ImageEnumerateRegions walks [Base, Base + SizeOfImage) with VirtualQuery, clamps each region to the image bounds and records {Address, Size, Protect} for up to IMAGE_MAX_REGIONS (12).
  • The restore stage replays one VirtualProtect per 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.

Threading model

Two threads cooperate.

  1. Caller. Runs NomSleepObf, does all resolution and setup, then blocks on WaitForSingleObject( hEvent, INFINITE ).
  2. Timer worker. A pool owned thread from the timer queue. It first runs RtlCaptureContext to snapshot its own register state, then gets dispatched through each NtContinue record 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.

Build

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

Self-test

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 CryptoInit calls give different Material, layer offsets and lengths are correct, CryptoErase wipes the whole context including Seed.
  • 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 SystemFunction032 rather than a hard link.
  • Protection lifecycle. ImageEnumerateRegions returns at least one region, the image is flipped to PAGE_READWRITE and 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.

Operational caveats

  • Scheduling constants. CAPTURE_GRACE_MS = 40, STAGE_BASE_MS = 70, STAGE_SPACING_MS = 85 and STAGE_JITTER_MS = 35 are 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 NtContinue dispatch 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_READWRITE in 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.

About

NomSleep is a Windows sleep obfuscation PoC. The PE image is encrypted in place with three stacked RC4 layers while idle, restored only when a timer callback runs. works w/ NtContinue continuation records on a timer queue worker thread. XOR-obfuscated API names, per-region protection restore, avalanche-mixed keys.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages