← Pieces

The Story of sleepmask

sleepmask is a 1,376-byte position-independent x64 shellcode experiment that resolves syscall numbers at runtime and waits without calling NtDelayExecution on its main path.

25 min readreverse-engineering / malware / assembly / windows / shellcode / unicorn

sleepmask waits without calling NtDelayExecution on its main path.

I wrote it as a 1,376-byte position-independent x64 shellcode blob. It has no imports or relocations and does not assume where it will be mapped. At entry, it walks the process’s PEB, finds ntdll.dll, and reads syscall numbers from the mapped export stubs. Its main path waits for 750 milliseconds across three 250-millisecond cycles without issuing an NtDelayExecution syscall. Each cycle changes the page protection twice, temporarily replaces the NtDelayExecution stub with a twelve-byte jump to a user-mode clock loop, then restores the original bytes and protection. Those six NtProtectVirtualMemory calls remain visible to telemetry. The wait avoids entering the NtDelayExecution syscall path; that does not make the activity invisible to other monitoring.

That is the core mechanism. The rest of this post covers the related syscall research, the progression from emulator probes to the twelve-layer test matrix, and the implementation details—the PEB walk, export parsing, thunk scan, and wait mask—while separating what was tested on each substrate.

What sleepmask is

In one sentence: sleepmask is a position-independent, import-free x64 shellcode experiment that locates ntdll.dll through the PEB, resolves two exports from the PE export table, reads syscall numbers from the mapped stubs, and temporarily redirects NtDelayExecution to a user-mode wait loop that polls KUSER_SHARED_DATA.

The constraints are what make it interesting, not an afterthought:

  • PIC. Data references use displacements from a base register resolved at entry (call/pop), so the blob can be mapped at different addresses without absolute references to its own data.
  • Import-free. It resolves ntdll and its exports from the process’s structures at runtime instead of using an import table. This avoids loader fixups for the blob’s imports; it does not remove the layout assumptions described below.
  • No hard-coded syscall numbers. The numbers are read from the thunk prologue of the mapped function. The current blob read 0x3D/0x2B under the emulator; its single-cycle predecessor read 0x34/0x50 on the Windows 11 test machine. Neither set is in the source.

The project builds three output binaries from the same src/ tree:

artifactbytesrole
build/sleepmask.bin1376the masked-sleep payload (this post)
build/beacon_windows.bin1564the Windows beacon: PEB walk + NtCreateFile/NtWriteFile/NtClose
build/beacon_linux.bin / beacon_macos.bin122 eachthe Linux / macOS beacons (direct write/exit)

One scope note matters for every claim below: this is a build-and-test artifact, not a deployed implant. It has run on a real Linux host, in one in-process Windows 11 run, and under Unicorn for the Windows and macOS entry-path tests. The matrix below identifies which claims were exercised on which substrate. These results demonstrate the tested mechanism; they do not establish stealth or safety in other environments.

The class it belongs to

Direct syscalls call the Windows system-call interface without using the usual user-mode API path. A normal Sleep() call may pass through kernel32!Sleep, kernelbase, and ntdll!NtDelayExecution; user-mode monitoring can instrument those APIs or their call sites. A direct syscall avoids those wrapper calls, but it still crosses the system-call boundary and can be observed by kernel-level or other telemetry. In this project, the blob reads a syscall number from an ntdll export stub and executes the syscall instruction from its own code.

The related work that informed this project includes:

  • Hell’s Gate (smelly__vx, am0nsec, 2016) describes resolving syscall numbers dynamically and issuing the trap from caller code. (1)
  • SysWhispers2 (jthuraisamy) and j00ru/windows-syscalls are the generators that turn a build number into a header of resolved syscall stubs. (2) (3)
  • ikermit/11Syscalls is the specific technique sleepmask generalizes: read the number out of the B8 … 0F 05 thunk instead of a table. (4)
  • The transition mechanics — the LSTAR/STAR MSRs, the mov r10, rcx preamble, and the Windows x64 calling convention — are documented in the references below. (8) (9)

The maintenance problem is that syscall numbers can vary across Windows builds. A generated syscalls.h can become stale as the mapping changes. One common approach is to maintain build-specific tables (11); sleepmask instead reads the number from the mapped function stub at runtime. That removes the embedded table, but it relies on the stub containing one of the byte patterns its scanner recognizes.

MITRE ATT&CK describes direct interaction with lower-level operating-system APIs under T1106 (Native API) and lists real-world examples. (5) Defensive research also examines syscall behavior and sequences; that is why the harness records the calls it emulates. (6) (7)

sleepmask sits at the intersection of that research and a specific question: can a small import-free program perform a timed wait without entering NtDelayExecution, and how can that behavior be tested?

The backstory: from a probe to 1,376 bytes

I began by asking what a PIC, import-free blob could do on each of the three targets. The answer depended on the execution environment. The stages below are in development order; the commit history interleaves some of them, so this is a progression of capabilities rather than a strict chronology:

StageMilestoneWhat changed or was learned
1Emulator probesSmall Unicorn probes established how the harness must configure gs:0x60, when MEM_READ hooks fire, and how QEMU’s linux-user target handles 0F 05.
2PEB walkThe blob learned to find ntdll through PEB → Ldr → InLoadOrderModuleList. Testing corrected an LDR layout guess and a BaseDllName.Length read that used a qword instead of a word.
3Export parsing and syscall-number scanThe extractor moved from a fixed look-back from 0F 05 to a forward scan for supported number-loading encodings. The scan handles the observed layouts, not every possible stub.
4ELF, PE32+, and Mach-O appendersHost-format appenders exercise position independence and check the blob’s result before the host program continues.
5Beacon and ABI fixesWindows beacon tests exposed argument-placement and stack-setup mistakes. The tested frame reads argument 0 from R10, argument 4 from [RSP+0x28], and requires RSP ≡ 8 (mod 16) at the trap.
6Mask and restoreA restore bug treated saved thunk bytes as a pointer. The corrected path writes the saved value directly.
7Three-cycle payloadA counter extends the single cycle to three 250 ms waits, producing the current 1,376-byte build.
8Validation matrixTwelve layers cover assembly, Linux host execution, Windows and macOS emulation, and raw-blob harness cases. Windows and macOS checks run under Unicorn; the separate live Windows result is described below.

The backstory matters because each layer addresses a specific failure from an earlier version. The harness keeps those failure conditions observable.

In plain language

Before the bytes, the shape. Imagine the kernel’s syscalls are a restaurant’s phone lines.

The ordinary way a program sleeps is like calling a restaurant’s main number and asking for the kitchen. That call may pass through kernel32!Sleep, kernelbase, and ntdll!NtDelayExecution; user-mode monitoring can instrument those API entry points.

A direct syscall is like dialing an extension without going through the restaurant’s main number. It bypasses those user-mode wrappers, but the call still crosses the system-call boundary, where other monitoring may observe it. The challenge is that extension numbers can vary across Windows builds, so a fixed cheat sheet can become stale.

Instead of carrying a table, sleepmask walks the loader list to find ntdll, then reads the number from the mapped stub for the function it needs. That makes the number specific to the loaded image, subject to the byte patterns the scanner recognizes.

For the wait, sleepmask takes a different path: it temporarily redirects the mapped NtDelayExecution stub to a loop that polls a shared time value in user space. It restores the original stub bytes and page protection afterward. The main path therefore does not enter NtDelayExecution; the protection changes and the rest of the process remain observable.

In short: find the export, read its number, redirect the wait stub, poll the clock, and restore the original state. The tested main path repeats the wait three times.

The next sections describe the data structures and control flow behind those steps.

Granular: the PEB walk

The blob has no imports, so it cannot ask the loader for ntdll. It has to find it in the process’s own memory, starting from the one thing every x64 Windows process has: the PEB, reachable from the GS segment at gs:0x60.

The lookup follows one pointer chain, then scans the loader’s circular module list:

GS:0x60 → PEB → PEB->Ldr → InLoadOrderModuleList → compare BaseDllName → take DllBase

The code below compares each entry’s UTF-16 base name. Its offsets are x64 layout assumptions, not a Windows ABI guarantee.

; PEB -> Ldr -> walk InLoadOrder for ntdll.dll
mov rax, [gs:0x60]            ; rax = PEB
mov rax, [rax + 0x18]         ; rax = PEB->Ldr (PEB_LDR_DATA)
lea r14, [rax + 0x10]         ; r14 = &head of InLoadOrderModuleList
mov r13, [r14]                ; r13 = first entry
.peb_walk:
    mov rsi, [r13 + 0x60]     ; BaseDllName.Buffer (UTF-16)
    movzx rdx, word [r13 + 0x58] ; BaseDllName.Length (bytes)
    shr rdx, 1                ; -> char count
    lea r8, [r12 + (s_ntdll_u16 - sym_base)]
    mov r9, 9                 ; len("ntdll.dll")
    call cmp_u16_ci
    test rax, rax
    jz  .peb_next
    mov rax, [r13 + 0x30]     ; DllBase — that's our ntdll
    jmp .peb_next
.peb_next:
    mov r13, [r13]            ; Flink — advance
    cmp r13, r14
    je  .peb_got
    jmp .peb_walk

The offsets below are the values used by the tested x64 layout:

fieldoffsetnote
PEB (via GS)gs:0x60process env block
PEB->Ldr+0x18PEB_LDR_DATA*
InLoadOrderModuleList (head)Ldr+0x10LIST_ENTRY, circular
entry.Flink (walk pointer)+0x00next node
entry.BaseDllName.Length+0x58bytes; >>1 = chars
entry.BaseDllName.Buffer+0x60UTF-16LE
entry.DllBase+0x30module image base

These are layout assumptions, not a contract. The layout can change, and the lookup may then fail. The implementation is intended to take its not-found path when ntdll is not resolved; that behavior should be treated as tested only on the builds and harness cases described here.

Once the image base is in hand, the PE32+ export directory can be located from the optional header. e_lfanew at +0x3C points to the PE\0\0 signature; the optional header follows the file header; and, for PE32+, DataDirectory[0] is at optional-header offset 0x70. Its RVA points to the IMAGE_EXPORT_DIRECTORY. (13) (14)

Export-directory fieldOffsetMeaning
NumberOfNames+0x18Number of entries in the name-pointer and ordinal tables
AddressOfFunctions+0x1CRVA of the export address table
AddressOfNames+0x20RVA of the name-pointer table
AddressOfNameOrdinals+0x24RVA of the ordinal table

The table addresses are RVAs, so add the image base to each RVA to get its address in memory. To resolve a name, search the name-pointer table, read the corresponding ordinal, and use that ordinal to index the export address table. The implementation compares names as strings and resolves the functions it needs; it does not use a hash table. A name that is not found produces a zero result in find_export.

Granular: the number is in the thunk

This is the central implementation detail, so it gets a closer look.

In the x64 ntdll.dll image used for these examples, NtDelayExecution has a 24-byte stub rather than the legacy eight-byte form. Its syscall number follows a B8 opcode at offset +3:

4C 8B D1                 mov r10, rcx
B8 3D 00 00 00           mov eax, 0x3D        ; <- the number, at offset +3
F6 04 25 08 03 FE 7F 01  test byte [0x7FFE0308], 1
75 03                     jne +3
0F 05                     syscall
C3                        ret
CD 2E                     int 2Eh              ; the jne target
C3                        ret

That test/jne is a gate the kernel sets: the byte at 0x7FFE0308 in KUSER_SHARED_DATA steers the stub to either the syscall path or the legacy int 2Eh one. Either way the blob is indifferent — the number it needs is the imm32 of the mov eax, ... at offset +3, which sits before any of it.

The two observed layouts show why the extractor scans forward from the start of the stub:

Observed layoutNumber-loading instructionFixed look-back behavior
Legacy exampleB8 imm32 at offset +0Five bytes before syscall reaches the number-loading opcode.
Tested 24-byte exampleB8 imm32 at offset +3, after mov r10, rcxFive bytes before syscall lands inside the test instruction’s immediate.

These are examples from the tested layouts. The forward scan finds B8 at +3 in the second example; it does not establish support for every possible stub.

The first extractor I tried anchored on the trap: find 0F 05, walk back five bytes, and read the B8 and its imm32. That works for the legacy example (B8 imm32 0F 05 C3), but not for the stub shown here. Five bytes before its syscall is inside the test instruction, not at B8, so that extractor returns the wrong value.

The current extractor does not anchor on syscall. It scans the leading bytes for either of two supported encodings: mov eax, imm32 (B8) or mov rax, imm32 (48 C7 C0). This is tolerant of the two layouts described here, not fully layout-agnostic; an unrecognized stub can still fail or produce a false match.

; sysnr_from: rsi = function -> eax = nr (32-bit)
;   forward-scan offsets 0..23 for the first nr-mov:
;     B8 <nr:4>       mov eax,nr   — legacy + modern ntdll thunks
;     48 C7 C0 <nr:4> mov rax,nr   — the alternate encoder
;   the modern thunk is 4C 8B D1 B8 <nr:4> ..., so the B8 sits at +3,
;   behind the `mov r10, rcx` preamble. First match wins.
sysnr_from:
    xor r10, r10
.snr_scan:
    cmp byte [rsi + r10], 0x48
    je  .snr_rax
    cmp byte [rsi + r10], 0xB8
    je  .snr_eax
.snr_adv:
    inc r10
    cmp r10, 24
    jb  .snr_scan
    xor eax, eax
    ret
.snr_rax:
    cmp byte [rsi + r10 + 1], 0xC7
    jne .snr_adv
    cmp byte [rsi + r10 + 2], 0xC0
    jne .snr_adv
    mov eax, [rsi + r10 + 3]
    ret
.snr_eax:
    mov eax, [rsi + r10 + 1]
    ret

There is a boundary caveat in this excerpt: it checks opcode positions through offset 23, then reads a four-byte immediate after a match. A match near the end of that range makes the read extend past byte 23. The code therefore does not guarantee that a complete instruction fits within a 24-byte prefix.

It is a forward scan, not an anchored one. The scanner looks for the number-loading instruction without searching for 0F 05; in the example, it finds B8 at offset +3. Under the harness, the observed values were 0x3D and 0x2B; on the Windows test machine they were 0x34 and 0x50. Those observations show different values on the tested images, not compatibility with every Windows build.

Granular: the mask

Now the blob has NtDelayExecution’s address. The mapped page must be writable before the stub can be changed, so the blob calls NtProtectVirtualMemory to change the protection, then restores the original protection after the wait.

One masking cycle is a save–patch–wait–restore sequence. The twelve-byte jump replaces the first twelve bytes of the example stub, and the main path repeats this cycle three times.

StepOperationResult
1Save the first 12 bytes of NtDelayExecution.The original thunk prologue is available for restoration.
2Make the mapped page writable and replace the entry with mov rax, STUB; jmp rax.Calls to the mapped entry reach the blob’s wait loop.
3Poll the shared time value until the wait interval elapses.The wait happens in user space and does not enter NtDelayExecution on the tested path.
4Restore the saved bytes and original page protection.The mapped thunk returns to its prior state.

The page-protection calls remain observable.

; save the 12 bytes we're about to clobber (r10 = NtDelayExecution)
mov rax, [r10]                ; first 8 bytes
mov [saved_bytes], rax
mov eax, [r10 + 8]            ; next 4 bytes
mov [saved_bytes + 8], eax
; patch over the first 12 bytes: 48 B8 <stub:8> FF E0 = mov rax,<stub>; jmp rax
mov byte [r9], 0x48
mov byte [r9 + 1], 0xB8
lea  rax, [r12 + (.stub - sym_base)]
mov  [r9 + 2], rax
mov byte [r9 + 10], 0xFF
mov byte [r9 + 11], 0xE0

48 B8 imm64 FF E0 is mov rax, STUB; jmp rax — a twelve-byte absolute jump that fits exactly over the original thunk’s first twelve bytes. When the blob then call [NtDelayExecution], execution lands on that jump and falls into .stub, which does the waiting in user space:

.stub:
    ; Materialize the shared-page address, then read the time value.
    ; The project uses this two-instruction form in its tested flat binary.
    ; KUSER_SHARED_DATA is mapped read-only at 0x7FFE0000; SystemTime
    ; is represented in 100-ns units since 1601 at offset +0x14.
    mov eax, 0x7FFE0014
    mov rax, [rax]
    mov [data_t0], rax
.st_wait:
    mov eax, 0x7FFE0014
    mov rax, [rax]
    sub rax, [data_t0]
    cmp rax, [timeout_val]
    jae .st_done
    jmp .st_wait
.st_done:
    mov r8, [saved_bytes]     ; first 8 saved bytes — a VALUE, not a pointer
    mov r9, [saved_ntdelay]
    mov [r9], r8              ; restore by writing the value directly
    mov eax, [saved_bytes + 8]
    mov [r9 + 8], eax
    ret

Three details about the wait stub are worth calling out:

It reads time from KUSER_SHARED_DATA, not through an export. The shared page is mapped read-only in the tested x64 process, and its SystemTime field represents time in 100-nanosecond units since 1601. (10) (15) The loop polls that value until the timeout elapses, restores the saved bytes, and returns. On the main path, control jumps around the syscall instruction in the mapped stub, so NtDelayExecution is not entered. This describes the tested path; it does not imply that other monitoring cannot observe the patch or wait.

The address load is explicit. The stub first places 0x7FFE0014 in EAX, which zero-extends into RAX, then loads from [RAX]. This is the form used in the project’s tested flat binary. The earlier claim that the alternate absolute-memory encoding is mis-decoded by Intel and AMD processors was too broad and has been removed; that instruction form is valid x64, and any assembler-specific issue would need a reproducible example.

There is a sharp edge in .st_done that only shows up when you run the bytes. The saved prologue is stored as a value in saved_bytes. A first draft did mov rax, [r8] after loading r8 with that value — treating the saved bytes as a pointer and dereferencing eight bytes of 4C 8B D1 B8 … as an address. Unicorn faults on it with UC_ERR_READ_UNMAPPED, and you spend a session wondering why a restore touches a page that was never mapped. The fix is to stop dereferencing: mov [r9], r8 writes the value directly. One less indirection, and the trap is gone.

One caveat: SystemTime is a wall-clock value, not a monotonic interval timer. Time-service or manual clock adjustments can affect elapsed-time calculations; daylight-saving changes affect local-time display, not the UTC system clock. For reliable interval measurement, Windows documentation recommends a performance counter such as QPC. (16)

Granular: the direct syscall ABI

The mask is the headline, but the blob also depends on the tested argument and stack conventions for its NtProtectVirtualMemory calls.

The harness models argument reads as follows. The stub copies the first argument from RCX to R10 before syscall; the instruction then uses RCX for the user return address.

argsourcethe trap
arg0R10not RCX — syscall overwrites RCX with the old RIP before the kernel reads it
arg1RDX—
arg2R8—
arg3R9—
arg4[RSP+0x28]in the stack argument area, after the 32-byte home area

And the trap must run at a specific stack alignment:

RSP≡8(mod16)\mathrm{RSP} \equiv 8 \pmod{16}

The project treats RSP ≡ 8 (mod 16) at the syscall instruction as its tested frame contract. In the ordinary Windows x64 calling convention, a call pushes an eight-byte return address; the caller also reserves home space and any stack arguments. The fifth parameter is beyond that home space at [RSP+0x28] when measured from the function-entry stack pointer. This alignment description is specific to the project’s syscall frame and harness; it should not be read as a universal rule for every syscall implementation.

The harness tests four entry residues because the blob can be entered from different stack states. Before each direct syscall, the implementation realigns the stack:

RSP′=(RSP&(−16))−8⟺mov r13, rspand rsp, -16sub rsp, 8\mathrm{RSP}' = \left(\mathrm{RSP} \mathbin{\&} \left(-16\right)\right) - 8 \qquad\Longleftrightarrow\qquad \begin{array}{l} \texttt{mov r13, rsp}\\ \texttt{and rsp, -16}\\ \texttt{sub rsp, 8} \end{array}

That rounds RSP down to a 16-byte boundary, then subtracts eight. The implementation restores the saved caller value before returning. The harness verifies the stated residue for its four test entry classes.

The three-cycle loop

The current build performs three cycles. The data section opens with beacon_cycles: dq 3, and the NtProtectVirtualMemory call that changes the thunk page’s protection is the top of the loop. After the stub restores the thunk and the second NtProtect call returns, the blob decrements the counter and jumps back while it is non-zero:

; loop tail, after the restore NtProtectVirtualMemory returns
mov rax, [r12 + (beacon_cycles - sym_base)]
dec rax
mov [r12 + (beacon_cycles - sym_base)], rax
jnz .beacon_top

So the main-path trace is three cycles of (RWX in, restore out) = six NtProtectVirtualMemory syscalls, and the shared clock advances 3×250 ms=750 ms3 \times 250\ \text{ms} = 750\ \text{ms}. The fallback path never reaches the loop tail: if the first NtProtect fails, the blob takes a direct NtDelayExecution instead, and it leaves beacon_cycles at 3 — which is itself the forensic signature of the fallback. A reader of the data section can tell which path ran without watching a single syscall.

Verification: the twelve-layer matrix

A 1,376-byte blob that rewrites a mapped ntdll thunk is exactly the kind of thing that can “work on my model.” The validation table below lists each layer in execution order; Linux host execution runs on a real host, while the Windows and macOS entry-path cases run under Unicorn. The separate Windows hardware result uses a single-cycle predecessor. The script exits zero only if every layer passes:

#layersubstratewhat it proves
1buildnasmthe blob assembles to 1376 B
2linuxreal ELF, executeda real kernel + ld.so map and run it
3linux-coupledappend_elf, on metalPIE + -no-pie, host keeps its exit code
4windowsPE32+ + Unicorn entrythe loader path, PEB walk, export parse
5windows-coupledappend_pe + Unicornreal and decoy syscall numbers
6windows-dllPE32+ DLL + Unicorn, 2 basesthe DLL form, two image bases
7windows-shellcodebeacon @ arbitrary RWX addrreal + decoy nr, no loader at all
8windows-realpayload @ arbitrary RWX addrthe sleepmask payload itself, call-entered
9macosMach-O + Unicorn xnuthe x86_64 XNU path
10macos-coupledappend_macho + Unicornbase and base+slide (PIC)
11harnessraw blob, PEB walkthe four RSP entry classes
12harness-fbraw blob, fallbackNtProtect fails → direct NtDelay

The Windows and macOS emulator layers that exercise syscall resolution run with both the expected and decoy numbers in their fake ntdll stubs. This checks that the test payload reads the number from the stub rather than relying on the expected value. It does not establish compatibility with arbitrary Windows images.

The harness (tests/run_harness.py) runs the blob under Unicorn (12). It is designed to catch two classes of error: incorrect argument placement and dependence on one entry stack frame.

The harness uses a CODE hook to trap 0F 05 and model the call: it reads arg0 from R10, arg1 from RDX, arg2 from R8, arg3 from R9, and arg4 from [RSP+0x28], while requiring RSP ≡ 8 (mod 16). For NtProtectVirtualMemory (0x2B), it checks the process handle is -1 (the current-process pseudo-handle), verifies that the data pointers are inside the blob, writes the current protection to *OldProtect, and applies NewProtect to a shadow copy so the restore call has a state to restore. It records the syscall number, sets RAX to STATUS_SUCCESS, and advances RIP past the instruction. The clock is also simulated: a MEM_READ hook on the KUSER address (0x7FFE0014) advances the value by a 10 ms test tick on each 8-byte read. This checks that the loop eventually exits; it does not model the real duration or consistency of concurrent clock updates. The harness configures gs:0x60 by zeroing the GS base and placing the PEB pointer at [0x60].

And the harness does not assume how the blob was entered. It runs the blob at all four entry classes, RSP % 16 ∈ {0, 4, 8, 12}, and each one must pass. That is the layer that would have caught a blob that only worked when call-entered.

The output, for the current 1,376-byte blob, main mode:

$ bash build.sh
assembled build/sleepmask.bin (1376 bytes)

$ micromamba run -n mdev python tests/run_harness.py
blob size:   1376 bytes
mode:        main (masked NtProtect)
--- entry RSP0=0x610000 (rsp % 16 = 0) ---
syscalls:    0x2B 0x2B 0x2B 0x2B 0x2B 0x2B
prot calls:  0x40 0x20 0x40 0x20 0x40 0x20
final prot:  0x20 (0x20 = original PAGE_EXECUTE_READ)
done_flag:   1 (at blob offset 1294 / 0x50E)
beacon_cycles: 0 (at blob offset 1150 / 0x47E)
sys_time:    7800000 (100ns units; timeout 2500000 = 250 ms)
ntdelay[12]: 4c 8b d1 b8 3d 00 00 00 f6 04 25 08
PASS

The identical block repeats for rsp % 16 = 4, 8, and 12. Read the first line of the trace: six NtProtectVirtualMemory syscalls, zero NtDelayExecution syscalls. The sys_time of 7,800,000 (100-ns units) is the KUSER clock having been advanced by the MEM_READ hook across three cycles — a 10 ms seed plus the poll steps per cycle, the third stub poll crossing the 250 ms timeout. The original twelve thunk bytes are restored byte-for-byte (4c 8b d1 b8 3d 00 00 00 f6 04 25 08, the head of the modern thunk), and done_flag is set. A sleep that cost no sleep syscall.

The fallback layer (harness-fb) forces the first NtProtect to fail and expects the direct NtDelayExecution path: the trace is 0x2B 0x3D, beacon_cycles remains 3, and done_flag is set. Both paths are represented in the harness.

On real hardware

The harness is a model, so I also ran a single-cycle predecessor in one in-process test on a real x64 Windows 11 machine. The standalone .exe probes on that machine returned Access is denied (error 5); this article does not establish why they were blocked or claim that the in-process test bypasses a security control.

One precision matters: the real-hardware transcript below is from the preceding single-cycle, 1,333-byte build, not the current 1,376-byte three-cycle blob. The single-cycle version was run on Windows hardware. The harness exercises the three-cycle multiplier across four entry frames. The per-cycle mechanism is the same, but the 750 ms total and six-NtProtect count come from the harness, not from the Windows hardware run.

The forensic readback, straight from the walk’s own data section:

[powershell] sleepmask done_flag = 1  -> masked NtDelayExecution, slept, restored the original bytes
[powershell] forensic readback (field = value):
  ntdll_base      = 00007FFFC6540000   (PEB walk resolved ntdll)
  data_nr_delay   = 0000000000000034   (NtDelayExecution nr, from export prologue)
  data_nr_protect = 0000000000000050   (NtProtectVirtualMemory nr)
  saved_ntdelay   = 00007FFFC66A09F0   (original NtDelayExecution bytes @)
  saved_ntprotect = 00007FFFC66A0D70   (original NtProtectVirtualMemory bytes @)
  saved_old_prot  = 0000000000000040   (original protection)
  data_t0         = 01DD412C049781C8   (stub clock snapshot, 100ns since 1601)
  delay_status    = 0000000000000000   (NTSTATUS)
  prot_status     = 0000000000000000   (NTSTATUS)
  saved_bytes     = 4C 8B D1 B8 34 00 00 00 F6 04 25 08   (the 12 original thunk bytes)

Every field is populated, and two of them are the proof that the numbers are read at runtime and not baked in. data_nr_delay = 0x34 (52) and data_nr_protect = 0x50 (80) are the live syscall numbers on that box — different from the harness’s 0x3D/0x2B. The PEB walk found ntdll at its real ASLR’d base (0x7FFFC6540000). The thunk’s first twelve bytes (4C 8B D1 B8 34 …, note the live 0x34 where the harness saw 0x3D) were saved, patched, the stub spun on the KUSER clock, the bytes were restored, and done_flag is 1. Both syscalls returned STATUS_SUCCESS.

Two details worth keeping. First, saved_old_prot = 0x40 (PAGE_EXECUTE_READWRITE), not the expected 0x20 (PAGE_EXECUTE_READ) — this particular ntdll image’s text pages were already RWX, so the NtProtectVirtualMemory flip was effectively a no-op. The code handles both identically: it saves whatever the kernel reports and restores it on the way out. Second, data_t0 = 0x01DD412C049781C8 is a real 100-ns-since-1601 timestamp, not a zero or a harness seed — the KUSER page was read from the actual shared mapping.

What this proves, and what it doesn’t

The tests demonstrate the per-cycle mechanism on two substrates: the Unicorn harness (four entry RSP classes, emulated kernel, current 1,376-byte blob) and one real x64 Windows 11 process (mapped ntdll, ASLR, live syscall numbers, shared time page, single-cycle predecessor). The three-cycle loop is tested in the harness, not on that Windows machine. These results show that the tested build can redirect the wait and restore the recorded bytes and protection. They do not establish that the operation is invisible to monitoring.

It does not prove the wait is invisible to a hypervisor. SystemTime is a wall-clock value, not a monotonic interval timer. It does not validate the macOS path on Apple hardware; that path was tested under Unicorn. Nor does it prove stealth: the NtProtectVirtualMemory calls are observable, and the harness records six on the main path. The narrow claim is that the tested main path does not enter NtDelayExecution; it polls a shared time value instead. Runtime number-reading may adapt across builds that use the recognized encodings, but it is not guaranteed to work with every future ntdll.

Where this is heading

The number-in-the-thunk is more adaptable than a fixed table, but it still depends on assumptions: the PEB layout, the mapped stub bytes, and the scanner’s recognized encodings. The current scan covers a fixed prefix of the stub. It can fail if the encoding changes, and its boundary handling needs review before the code is described as a bounded 24-byte scan.

The open research question is how defensive tools should interpret syscall sequences and page-protection changes around waits. The main path here avoids NtDelayExecution, but it still makes NtProtectVirtualMemory calls to modify and restore the mapped stub. That is why the harness records the full sequence rather than only the count.

References

  1. smelly_vx (RtlMateusz) & am0nsec, _EDR Evasion Techniques Using Syscalls (Hell’s Gate / HADESS). hadess.io
  2. jthuraisamy, SysWhispers2 — direct system call stubs. github.com/jthuraisamy/SysWhispers2
  3. j00ru, windows-syscalls. github.com/j00ru/windows-syscalls
  4. ikermit, 11Syscalls — extracting the number from the thunk. github.com/ikermit/11Syscalls
  5. MITRE ATT&CK, T1106: Native API (Chimera / Dumpert). attack.mitre.org
  6. Palo Alto Networks, A Deep Dive into Malicious Direct Syscall Detection. paloaltonetworks.com
  7. Outflank, Red Team Tactics: Combining Direct System Calls and SRDI to Bypass AV/EDR. outflank.nl
  8. RedOps, Direct Syscalls: A Journey from High to Low. redops.at
  9. 0xbekoo, Direct system calls (malware-dev notes). 0xbekoo.github.io
  10. KUSER_SHARED_DATA, nt doc. ntdoc.m417z.com
  11. hfiref0x, SyscallTables. github.com/hfiref0x/SyscallTables
  12. Unicorn Engine. github.com/unicorn-engine/unicorn
  13. Microsoft, PE file format. learn.microsoft.com
  14. Microsoft, IMAGE_EXPORT_DIRECTORY. learn.microsoft.com
  15. Microsoft, KUSER_SHARED_DATA (NTDDK). learn.microsoft.com
  16. Microsoft, GetSystemTimePreciseAsFileTime. learn.microsoft.com