NThread is a thread hijacking library for x64 Windows that lets you seize control of existing threads — without injecting shellcode, allocating remote memory, or using CreateRemoteThread.
Built on top of @cheatron/native. A TypeScript port of the original C/C++ NThread project.
Important
64-bit Windows only. Requires Wine to develop/test on Linux.
Instead of injecting new code into a process, NThread reuses tiny instruction sequences (gadgets) already present in loaded modules:
| Gadget | Pattern | Purpose |
|---|---|---|
| Sleep | jmp . (EB FE) |
Parks the thread in an infinite loop |
| Pivot | push reg; ret (e.g. 53 C3) |
Redirects RIP by pushing a register onto the stack and returning to it |
1. Suspend target thread
2. Capture current register state (RIP, RSP, regKey, ...)
3. Set RIP → [push reg; ret] ← the pivot
4. Set REG → [jmp .] ← the sleep destination
5. Set RSP → safe scratch area (current RSP - 8192, 16-byte aligned)
6. Apply context & resume
7. Poll (suspend → read RIP → resume) until RIP == sleep address
8. Thread is now parked at 'jmp .'
No remote allocation, no shellcode, no hooks — just register writes.
- No code injection — reuses gadgets already in loaded DLLs (
ntdll,kernel32,kernelbase,msvcrt) - No
WriteProcessMemory— memory operations are performed by hijacking the target thread to call its ownmsvcrtfunctions - Auto-discovery — scans modules automatically to find sleep and pushret gadgets via
Module.scan() - Reversible — saves full register context before hijacking; restores on demand
- CRT bridge — resolves
msvcrt.dllexports to callmalloc,calloc,memset,fwrite, etc. from inside the target thread - Write optimization — read-only memory regions (
romem) track known contents, lettingwriteMemoryskip unchanged bytes automatically - TypeScript-native — fully typed, composable architecture built on
@cheatron/native
bun add @cheatron/nthreadimport { NThread } from '@cheatron/nthread';
import * as Native from '@cheatron/native';
// Create an NThread orchestrator (gadgets auto-discovered)
const nthread = new NThread();
// Hijack an existing thread by TID — returns [ProxyThread, CapturedThread]
const [proxy, captured] = await nthread.inject(tid);
// Thread is now parked at the sleep address.
// Access the CapturedThread for register-level control:
console.log(`RIP: ${captured.getRIP()}`);
console.log(`TID: ${captured.tid}`);
// Call a function inside the target thread (x64 calling convention)
const result = await proxy.call(crt.malloc, 1024);
// Write memory via hijacked memset calls
const data = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]);
await proxy.writeMemory(targetAddr, data);
// Cleanup — restores original context and releases the thread
await proxy.close();Lightweight orchestrator that manages the hijack lifecycle. Does not extend Native.Thread — thread state is owned by CapturedThread.
new NThread(
processId?: number, // Optional: for diagnostics and logging
sleepAddress?: NativePointer, // Optional: explicit 'jmp .' gadget
pushretAddress?: NativePointer, // Optional: explicit 'push reg; ret' gadget
regKey?: GeneralPurposeRegs, // Optional: register preference (default: auto)
)| Method | Description |
|---|---|
inject(thread) |
Hijack a thread (by TID or Thread object), returns [ProxyThread, CapturedThread] |
call(thread, target, args, timeout) |
Execute a function call on a specific captured thread |
writeMemory(thread, dest, source) |
Write data via decomposed memset calls. Auto-detects overlap with read-only memory regions and routes through safe write |
writeMemoryWithPointer(thread, dest, source, size) |
Write from a NativePointer source — reads locally then decomposes to memset. No romem check |
writeMemorySafe(thread, dest, source, lastDest) |
Safe write — skips bytes that haven't changed |
Extends Native.Thread. Holds all low-level state for a single captured thread: context cache, suspend tracking, register manipulation.
new CapturedThread(
thread: Native.Thread | number, // Thread object or TID
regKey: GeneralPurposeRegs, // Register for the pushret pivot
sleepAddress: NativePointer, // Sleep gadget address
processId?: number, // Optional: for diagnostics
)| Method | Description |
|---|---|
getContext() |
Returns cached thread context (does not re-fetch from hardware) |
setContext(ctx) |
Updates context cache only (use applyContext() to write to hardware) |
fetchContext() |
Reads current hardware context into cache |
applyContext() |
Writes cached context to the thread hardware registers |
calcStackBegin() |
Returns a 16-byte aligned scratch RSP (currentRSP - 8192) |
getRIP() / setRIP(addr) |
Convenience RIP getter/setter |
getRSP() / setRSP(addr) |
Convenience RSP getter/setter |
getTargetReg() / setTargetReg(val) |
Get/set the pivot register value |
wait(timeoutMs) |
Polls until RIP reaches sleepAddress |
suspend() / resume() |
Balanced suspend/resume — tracked via suspendCount |
release() |
Restores saved context (suspend → set → apply → resume) without closing the handle |
close() |
Calls release() (safe for dead threads), drains remaining suspends, then closes the handle |
Provides an extensible interface for interacting with a captured thread. Each operation can be independently replaced via setter methods. Does not hold a reference to the underlying CapturedThread — the thread is returned separately from inject().
new ProxyThread(
close: CloseFn, // Required: close/cleanup strategy
process?: Native.Process, // Optional: enables default read/write via process memory
)| Method | Description |
|---|---|
readMemory(address, size) |
Read memory (delegates to configurable function) |
writeMemory(address, data, size?) |
Write memory (delegates to configurable function) |
call(address, ...args) |
Call a function (delegates to configurable function) |
close() |
Restores thread to original state and releases it |
setReader(fn) |
Replace the read memory implementation |
setWriter(fn) |
Replace the write memory implementation |
setCaller(fn) |
Replace the call implementation |
setCloser(fn) |
Replace the close/cleanup implementation |
All delegate functions receive the ProxyThread instance as their first argument:
type ReadMemoryFn = (proxy: ProxyThread, address, size) => Promise<Buffer>;
type WriteMemoryFn = (proxy: ProxyThread, address, data, size?) => Promise<number>;
type CallFn = (proxy: ProxyThread, address, ...args) => Promise<NativePointer>;
type CloseFn = (proxy: ProxyThread) => Promise<void>;Manages the global pools of discovered gadgets. Auto-discovery runs once lazily on first use.
// Manual registration (if you pre-know the addresses)
registerSleepAddress(ptr: NativePointer): void
registerPushretAddress(ptr: NativePointer, regKey: GeneralPurposeRegs): void
// Auto-discovery (scans ntdll, kernel32, kernelbase, msvcrt)
autoDiscoverAddresses(): void
// Random selection (triggers auto-discovery if pools are empty)
getRandomSleepAddress(): NativePointer | undefined
getRandomPushretAddress(regKey?): { address: NativePointer, regKey } | undefinedRegister priority for auto-selection: Rbx → Rbp → Rdi → Rsi (non-volatile, least likely to be clobbered at randomsuspension point).
Most memory manipulation tools use WriteProcessMemory / ReadProcessMemory to read or write into a target process. NThread takes a different approach: it makes the target thread call its own CRT functions.
Because msvcrt.dll is loaded in virtually every Windows process, its exports (malloc, memset, fwrite, ...) exist at known addresses. By:
- Parking the thread at the sleep gadget
- Setting up registers as function arguments (
RCX,RDX,R8,R9per x64 calling convention) - Setting
RIPto the target CRT function (e.g.msvcrt!memset) - Resuming the thread and waiting for it to return back to the sleep gadget
...you can execute arbitrary CRT calls inside the target process without ever calling WriteProcessMemory, VirtualAllocEx, or CreateRemoteThread.
import { crt } from '@cheatron/nthread';
// crt.malloc, crt.calloc, crt.free, crt.fopen, crt.fwrite,
// crt.fread, crt.fclose, crt.fflush, crt.memset
// All are NativePointer — set as RIP on the hijacked thread,
// pass arguments via register context (RCX, RDX, R8, R9)NThread provides several mechanisms for reading from and writing to a target process, all without ReadProcessMemory or WriteProcessMemory.
The simplest write strategy. Calls the target thread's own msvcrt!memset:
RCX = dest (pointer in target process)
RDX = byte value
R8 = length
RIP = msvcrt!memset
→ resume → wait for return
For arbitrary data (not just a single byte fill), ntu_write_with_memset decomposes the source buffer into runs of equal bytes and issues one memset call per run. This is efficient for zero-initialization or structured data with repeated byte patterns.
By setting RIP to msvcrt!malloc / msvcrt!free and passing size/pointer via RCX, the target thread allocates and frees memory in its own heap:
RCX = size
RIP = msvcrt!malloc
→ resume → wait for return → RAX = pointer in target heap
The returned address lives in the target's address space and is a valid pointer for subsequent memset writes or tunnel I/O.
For reading arbitrary data back from the target — or writing larger/structured payloads — NThread uses a file-system channel: two temporary files that both sides open simultaneously from opposite ends.
┌────────────────────────────────────┐
Attacker │ Temp file A │ Target thread
(write side) │attacker fwrite → ... → target fread│ (read side / hijacked)
└────────────────────────────────────┘
┌────────────────────────────────────┐
Attacker │ Temp file B │ Target thread
(read side) │target fwrite → ... → attacker fread│ (write side / hijacked)
└────────────────────────────────────┘
Write channel (attacker → target):
- Attacker writes payload to local side of temp file A and flushes
- Hijacked thread calls
msvcrt!_wfopen+msvcrt!freadto pull the data into its own memory
Read channel (target → attacker):
- Hijacked thread calls
msvcrt!fwriteto stream data from target memory into temp file B - Attacker reads temp file B natively
Both channels use the same CRT bridge — the target thread calls _wfopen, fread/fwrite, and fflush from inside via register-driven hijacks, with no ReadProcessMemory/WriteProcessMemory at any point.
Temp file paths are automatically rotated after a configurable transfer threshold (max_transfer) to avoid seeking back to position 0.
ntmem wraps a target memory region as a triple-buffer:
| Field | Location | Description |
|---|---|---|
remote |
Target process heap | Actual allocation in the target (malloc-allocated) |
local |
Attacker process | Working copy — modifications are made here |
local_cpy |
Attacker process | Snapshot of last pushed state — used for dirty-range detection |
push (local → remote): Detects which byte range changed between local and local_cpy, then automatically selects the most efficient strategy:
- If the tunnel is available and the diff range is ≥ 3 bytes → tunnel write
- Otherwise →
memsetwrite
pull (remote → local): Calls ntu_fwrite on the target side (hijacked thread writes target memory into the read channel), then attacker reads it locally.
Note:
nttunnelandntmemare part of the original C architecture. The TypeScript port currently implements the CRT bridge,memset-based writes, and a partialntmemequivalent via the read-only memory system (romem). Tunnel-based read/write is planned for a future iteration.
When the hijacking side allocates and writes memory in the target, it always knows the exact contents of that region. romem exploits this by keeping a local snapshot (the local Buffer) that mirrors the remote state, so writeMemory can automatically skip unchanged bytes.
import {
createReadOnlyMemory,
registerReadOnlyMemory,
unregisterReadOnlyMemory,
findOverlappingRegion,
} from '@cheatron/nthread';
// Allocate a zero-initialized region in the target via calloc
const romem = await createReadOnlyMemory(proxy, 256);
// romem.remote → NativePointer in target heap
// romem.local → Buffer (all zeroes, mirrors calloc output)
// writeMemory now auto-detects the overlap and uses safe write:
const data = Buffer.alloc(256);
data.writeUInt32LE(0xDEADBEEF, 0);
await proxy.writeMemory(romem.remote, data);
// Only the first 4 bytes are actually written — the rest are skipped
// because romem.local says they're already zero.
// Manual registration for externally allocated regions:
const manual = registerReadOnlyMemory(somePointer, someBuffer);
// Unregister when no longer needed (does NOT free remote memory):
unregisterReadOnlyMemory(romem);When NThread.writeMemory() is called, it checks the global romem registry:
- If the destination overlaps a registered region, the write is split into up to 3 parts:
- Before overlap → standard decomposed
memset - Overlap →
writeMemorySafeBuffer(skips bytes matching the snapshot) - After overlap → standard decomposed
memset
- Before overlap → standard decomposed
- After the safe write, the snapshot (
romem.local) is updated to reflect the new contents. - If no overlap is found, the write proceeds as a standard decomposed
memset.
# Install dependencies
bun install
# Run tests (requires Wine on Linux)
wine /path/to/bun.exe test
# Build
bun run build| Feature | C (NThread) |
TypeScript (@cheatron/nthread) |
|---|---|---|
| Gadget discovery | Manual / test-provided | Auto via Module.scan() |
| Thread API | Raw Win32 (SuspendThread, GetThreadContext) |
@cheatron/native Thread class |
| CRT resolution | Compile-time linking | Runtime via getProcAddress on msvcrt.dll |
| Module scanning | Not included | @cheatron/native Scanner + Pattern |
| Memory write (simple) | ntu_write_with_memset (runs of equal bytes) |
writeMemory — decomposes buffer into runs of equal bytes, one memset call per run |
| Memory write (safe) | ntu_write_with_memset_dest (skip unchanged) |
writeMemorySafe / writeMemorySafeBuffer — compares against snapshot, skips matching bytes |
| Memory write (pointer) | N/A | writeMemoryWithPointer — reads from NativePointer source, then decomposed memset |
| Read-only memory | Part of ntmem |
romem — (remote, local) pair, auto-integrated into writeMemory for skip optimization |
| Memory read/write (large) | nttunnel — two temp files, target calls fread/fwrite |
Planned — not yet ported |
| Memory region abstraction | ntmem — (remote, local, local_cpy) triple, dirty-range push/pull |
Partially ported via romem |
MIT