NThread is a thread hijacking library for x64 Windows that seizes control of existing threads — without injecting shellcode, allocating remote memory, or using CreateRemoteThread.
Built on @cheatron/native. TypeScript port of the original C/C++ NThread.
Important
64-bit Windows only. Requires Wine to develop/test on Linux.
NThread reuses two 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 |
Redirects RIP to the sleep gadget |
Hijack sequence: suspend → capture context → redirect RIP through pivot → spin until RIP lands on sleep gadget. No shellcode, no remote allocation — just register writes.
suspend thread
→ save full register context
→ set RIP = pushret gadget
→ set RSP = safe scratch area (current RSP − 8192, 16-aligned)
→ set target register = sleep address
→ apply context → resume
→ poll until RIP == sleep address
→ thread is now parked and ready for commands
- No code injection — reuses gadgets in
ntdll,kernel32,kernelbase,msvcrt - No
WriteProcessMemory— memory ops use the target thread's ownmsvcrtfunctions - Auto-discovery — scans loaded modules lazily via
Module.scan() - Reversible — saves full register context before hijacking; restores on
proxy.close() - CRT bridge — resolves
msvcrtexports (malloc,calloc,memset,strlen,wcslen,fopen,fread, etc.) and calls them from inside the target thread - kernel32 bridge — resolves
kernel32exports (LoadLibraryA/W,GetModuleHandleExA/W, etc.) — all auto-bound on the proxy - String args — pass strings directly to
proxy.call(), automatically allocated and freed - Write optimization —
romemtracks known region contents and skips unchanged bytes automatically - Heap allocator —
NThreadHeappre-allocates a heap block in the target and sub-allocates from it, minimising CRT round-trips - File channel —
NThreadFilereplaces RPM/WPM with bidirectional filesystem I/O through a single temp file - Native function injection —
createNativeFunctionGeneratorallocates an executable page in the target;memmemandscaninject and call a remotememmemfor pattern scanning without any external allocator
bun add @cheatron/nthreadimport { NThread, crt } from '@cheatron/nthread';
const nthread = new NThread();
const [proxy, captured] = await nthread.inject(tid);
// Call a function inside the target thread (x64 calling convention)
const ptr = await proxy.call(crt.malloc, 1024n);
// Write memory via hijacked memset calls
await proxy.write(ptr, Buffer.from([0xDE, 0xAD, 0xBE, 0xEF]));
// Read memory back
const buf = await proxy.read(ptr, 4);
// Restore original context and release
await proxy.close();import { NThreadHeap } from '@cheatron/nthread';
const nt = new NThreadHeap();
const [proxy] = await nt.inject(tid);
// alloc/free go through the local heap — fewer CRT calls
const ptr = await proxy.alloc(256, { fill: 0 });
await proxy.write(ptr, myData);
await proxy.dealloc(ptr);
// Allocate and write a null-terminated string in one step
const strPtr = await nt.allocString(proxy, 'Hello, target!');
await proxy.close(); // frees all heap blocks atomicallyimport { NThreadFile } from '@cheatron/nthread';
const nt = new NThreadFile();
const [proxy] = await nt.inject(tid);
// read/write now go through a temp file — no RPM/WPM
const ptr = await proxy.alloc(4096, { fill: 0 });
await proxy.write(ptr, largeBuffer); // fs.writeFileSync → fread
const data = await proxy.read(ptr, 4096); // fwrite + fflush → fs.readFileSync
await proxy.close(); // closes FILE*, deletes temp file, destroys heaps, restores thread// Open a file in the target process
const stream = await nthread.fileOpen(proxy, 'C:\\data\\log.txt', 'rb');
// Read 512 bytes from the stream → local Buffer
const buf = await nthread.fileRead(proxy, stream, 512);
// Write data to a stream (string, Buffer, or NativeMemory)
await nthread.fileWrite(proxy, stream, 'hello');
// Flush and close
await nthread.fileFlush(proxy, stream);
await nthread.fileClose(proxy, stream);import * as Native from '@cheatron/native';
import { NThreadHeap } from '@cheatron/nthread';
const nt = new NThreadHeap();
const [proxy] = await nt.inject(tid);
// Allocate an executable page in the target and inject memmem into it
const gen = await nt.createNativeFunctionGenerator(proxy);
// Allocate a remote buffer and write some data
const mem = await proxy.alloc(1024, { fill: 0 });
await proxy.write(mem, myBuffer);
// Scan for a pattern — memmem is injected once, reused on every chunk
const pattern = Native.Pattern.from('DE AD BE EF');
for await (const offset of nt.scan(proxy, mem, pattern, undefined, gen)) {
console.log('Found at remote offset:', offset);
}
await proxy.dealloc(mem);
await proxy.close();
genis optional: if omitted a dedicated single-function page is allocated automatically. Pass an existing generator to share the executable page with other injected native functions.
Tracks a known-content region as a (remote, local) pair. proxy.write() auto-detects overlaps and skips unchanged bytes.
import { createReadOnlyMemory, unregisterReadOnlyMemory } from '@cheatron/nthread';
const romem = await createReadOnlyMemory(proxy, 256); // calloc in target
const data = Buffer.alloc(256);
data.writeUInt32LE(0xDEADBEEF, 0);
await proxy.write(romem.remote, data); // only the 4 changed bytes are written
unregisterReadOnlyMemory(romem);NThread — Orchestrator: inject, threadCall, writeMemory, CRT helpers
└─ NThreadHeap — Heap sub-allocator per proxy (doubles up to maxSize)
└─ NThreadFile — Filesystem-based I/O channel (single temp file)
Lightweight orchestrator — holds resolved gadget addresses and runs the hijack sequence.
new NThread(processId?, sleepAddress?, pushretAddress?, regKey?)| Method | Description |
|---|---|
inject(thread, options?) |
Hijack a thread (TID or Thread), returns [ProxyThread, CapturedThread] |
allocString(proxy, str, opts?) |
Allocate + write a null-terminated string |
writeString(proxy, dest, str) |
Write a null-terminated string to an existing address |
fileOpen(proxy, path, mode) |
fopen in the target; auto-allocates/frees string args |
fileWrite(proxy, stream, data) |
fwrite — accepts Buffer, string, or NativeMemory |
fileRead(proxy, stream, dest) |
fread — NativeMemory or byte-count → Buffer |
fileFlush(proxy, stream) |
fflush |
fileClose(proxy, stream) |
fclose |
createNativeFunctionGenerator(proxy, capacity?) |
Allocate an executable page in the target for injecting native functions |
memmem(proxy, haystack, needle, gen?) |
Call remote memmem; injects and binds it on first use |
scan(proxy, memory, pattern, chunkSize?, gen?) |
Async-iterate offsets where pattern matches in memory |
createMemory(proxy) |
Returns an NThreadMemory (AsyncMemory) backed by the proxy |
Overridable hooks (for subclasses):
threadClose(proxy, captured, suicide?)— called byproxy.close()threadAlloc(proxy, size, opts?)— called byproxy.alloc()threadDealloc(proxy, ptr)— called byproxy.dealloc()
Subclass of NThread. Pre-allocates a heap block in the target and sub-allocates from it. The block doubles on full (up to maxSize); oversized requests fall back to msvcrt!malloc.
new NThreadHeap(heapSize?, maxSize?, processId?, sleepAddress?, pushretAddress?, regKey?)
// Defaults: heapSize = 65536, maxSize = 524288All heap blocks are freed atomically on proxy.close().
Subclass of NThreadHeap. Replaces ReadProcessMemory/WriteProcessMemory (and the base class's decomposed memset writes) with a bidirectional filesystem channel.
new NThreadFile(heapSize?, maxSize?, processId?, sleepAddress?, pushretAddress?, regKey?)- Opens a single temp file in the target with
fopen(path, "w+b")duringinject() - Write (attacker → target): write locally →
fseek(0)+freadin target - Read (target → attacker):
fseek(0)+fwrite+fflushin target → read locally proxy.close()callsfclose, deletes the temp file, then destroys heaps and restores the thread
High-level interface returned by inject(). Each operation is a replaceable delegate.
| Method | Description |
|---|---|
read(address, size) |
Read memory from the target |
write(address, data, size?) |
Write memory to the target |
call(address, ...args) |
Call a function (up to 4 args: RCX, RDX, R8, R9) |
alloc(size, opts?) |
Allocate memory (AllocOptions: fill, readonly, address) |
dealloc(ptr) |
Deallocate memory (routes through delegate; subclasses may use managed heap) |
close(suicide?) |
Release thread, or terminate with exit code |
bind(name, address) |
Bind a remote function as a named method on the proxy |
Delegate setters: setReader, setWriter, setCaller, setCloser, setAllocer, setDeallocer — replace any operation with a custom function.
CRT auto-binding: All resolved msvcrt functions are bound as methods on the proxy (e.g. proxy.malloc(size), proxy.free(ptr), proxy.strlen(str)).
kernel32 auto-binding: All resolved kernel32 functions are also bound (e.g. proxy.LoadLibraryA(name), proxy.GetModuleHandleExA(flags, name, phModule)).
bind(name, address): Creates a named property on the proxy that delegates to this.call(address, ...args). Used internally for CRT/kernel32 auto-binding — also available for custom bindings.
Extends Native.Thread. Owns the hardware context cache, suspend tracking, and register manipulation.
| Method | Description |
|---|---|
fetchContext() / applyContext() |
Sync hardware ↔ cache |
getRIP() / setRIP(addr) |
RIP convenience accessors |
wait(timeoutMs?) |
Poll until RIP == sleep address |
release() |
Restore saved context without closing handle |
close() |
release() → drain suspends → close handle |
interface AllocOptions {
fill?: number; // byte value to fill allocated memory
readonly?: boolean; // allocate in the readonly zone of the heap
address?: NativePointer; // realloc mode: resize an existing allocation
}AsyncMemory implementation backed by a ProxyThread. Implements the @cheatron/native AsyncMemory interface so any code that accepts an AsyncMemory can transparently work over a hijacked thread.
Obtained via nthread.createMemory(proxy). Used internally by createNativeFunctionGenerator.
const mem = nt.createMemory(proxy);
// Read / write through the proxy
const buf = await mem.read(someNativeMemory);
await mem.write(dest, data);
// Allocate an executable page via VirtualProtect
const execMem = await mem.alloc(
4096,
Native.MemoryProtection.EXECUTE_READWRITE,
);
// Memory protection query / change
const info = await mem.query(dest);
await mem.protect(dest, 4096, Native.MemoryProtection.EXECUTE_READWRITE);Note: When
alloc()is called with anEXECUTE*protection flag,NThreadMemorycallsVirtualProtecton the allocated block automatically so that injected machine code can run in it.
Gadgets are discovered lazily on first inject(). The scanner searches ntdll, kernel32, kernelbase, and msvcrt for:
- Sleep:
EB FE(jmp .) - Pushret:
push reg; ret— register priority:Rbx → Rbp → Rdi → Rsi(least-clobbered bymsvcrtcalls)
You can also provide explicit gadget addresses in the constructor if you prefer manual control:
const nt = new NThread(pid, mySleepAddr, myPushretAddr, 'Rbx');crt.ts resolves msvcrt.dll exports at load time. All values are NativePointer — used as RIP targets for threadCall.
import { crt } from '@cheatron/nthread';
crt.malloc // msvcrt!malloc
crt.calloc // msvcrt!calloc
crt.realloc // msvcrt!realloc
crt.free // msvcrt!free
crt.memset // msvcrt!memset
crt.strlen // msvcrt!strlen
crt.wcslen // msvcrt!wcslen
crt.fopen // msvcrt!fopen
crt.fread // msvcrt!fread
crt.fwrite // msvcrt!fwrite
crt.fseek // msvcrt!fseek
crt.fflush // msvcrt!fflush
crt.fclose // msvcrt!fclosekernel32.ts resolves kernel32.dll exports at load time. All values are NativePointer — used as RIP targets for threadCall.
import { kernel32 } from '@cheatron/nthread';
kernel32.LoadLibraryA // kernel32!LoadLibraryA
kernel32.LoadLibraryW // kernel32!LoadLibraryW
kernel32.ReadProcessMemory // kernel32!ReadProcessMemory
kernel32.WriteProcessMemory // kernel32!WriteProcessMemory
kernel32.GetCurrentProcess // kernel32!GetCurrentProcess
kernel32.GetModuleHandleA // kernel32!GetModuleHandleA
kernel32.GetModuleHandleW // kernel32!GetModuleHandleW
kernel32.GetModuleHandleExA // kernel32!GetModuleHandleExA
kernel32.GetModuleHandleExW // kernel32!GetModuleHandleExWNThreadError
├─ NoSleepAddressError — no sleep gadget found
├─ NoPushretAddressError — no pushret gadget found
├─ ThreadReadNotImplementedError — threadRead not overridden
├─ InjectError
│ ├─ InjectTimeoutError — thread didn't reach sleep in time
│ ├─ InjectAbortedError — injection cancelled via `AbortSignal`
│ └─ MsvcrtNotLoadedError — msvcrt.dll not in target process
├─ CallError
│ ├─ CallNotInjectedError — call before inject
│ ├─ CallTooManyArgsError — more than 4 args
│ ├─ CallRipMismatchError — RIP not at sleep before call
│ ├─ CallTimeoutError — function didn't return in time
│ └─ CallThreadDiedError — thread exited during call
├─ ReadError
│ └─ ReadSizeRequiredError — read(NativePointer) without size
├─ WriteError
│ ├─ WriteSizeRequiredError — NativePointer write without size
│ └─ WriteFailedError — write returned wrong byte count
├─ AllocError
│ ├─ CallocNullError — calloc returned NULL
│ └─ ReallocNullError — realloc returned NULL
├─ ProxyError
│ ├─ ProxyReadNotConfiguredError — read delegate not set, no Process
│ ├─ ProxyWriteNotConfiguredError — write delegate not set, no Process
│ └─ ProxyCallNotConfiguredError — call delegate not set
├─ HeapError
│ ├─ HeapInvalidSizeError — invalid heap zone sizes
│ ├─ HeapAllocSizeError — invalid alloc size
│ ├─ HeapZoneExhaustedError — readonly/readwrite zone full
│ └─ HeapFreeInvalidError — address not in heap
├─ FileError — fopen returned NULL
└─ GadgetError
└─ GadgetScanError — pattern scan failed
bun install
bun run build
bun run lint
bun run format
# Tests require Wine (Windows x64 on Linux)
wine /path/to/bun-windows-x64/bun.exe testMIT