@memoryprocess/bindings 0.0.0-stage → 0.0.3-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1720 @@
1
+ #include "module.h"
2
+ #include "memory.h"
3
+ #include <system_error>
4
+ #include <tlhelp32.h>
5
+ #include <cmath>
6
+ #include <cstdio>
7
+ #include <cstring>
8
+ #include <string>
9
+ extern "C" {
10
+ #include "hde/hde64.h"
11
+ }
12
+
13
+ namespace mp {
14
+
15
+ // Max bytes a single read call may materialize - guards against negative /
16
+ // non-finite / absurd `size` values blowing up std::vector across the N-API
17
+ // boundary (a length_error crossing N-API aborts the process).
18
+ static const double MAX_READ_BYTES = 512.0 * 1024.0 * 1024.0;
19
+
20
+ // NOTE: handles and addresses cross N-API as doubles - above 2^53 they would
21
+ // silently round. Harmless today (canonical user-space VA < 2^48, real HANDLE
22
+ // values are small) but don't feed pointers >2^53 through these bindings.
23
+
24
+ static bool CheckSize(Napi::Env env, double size) {
25
+ if (!std::isfinite(size) || size < 0 || size > MAX_READ_BYTES) {
26
+ Napi::RangeError::New(env, "size must be a finite number between 0 and 512MB")
27
+ .ThrowAsJavaScriptException();
28
+ return false;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ static Napi::Object ErrToJs(Napi::Env env, const std::system_error& e) {
34
+ auto obj = Napi::Object::New(env);
35
+ obj.Set("code", Napi::Number::New(env, e.code().value()));
36
+ obj.Set("message", Napi::String::New(env, e.what()));
37
+ obj.Set("win32", Napi::Boolean::New(env, true));
38
+ return obj;
39
+ }
40
+
41
+ Napi::Value OpenProcessSync(const Napi::CallbackInfo& info) {
42
+ Napi::Env env = info.Env();
43
+ if (info.Length() < 1) { Napi::TypeError::New(env, "expected (nameOrPid[, access])").ThrowAsJavaScriptException(); return env.Null(); }
44
+
45
+ // Least-privilege default: VM_READ|VM_WRITE|VM_OPERATION|QUERY_INFORMATION
46
+ DWORD access = PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION;
47
+ if (info.Length() > 1 && info[1].IsNumber()) access = info[1].As<Napi::Number>().Uint32Value();
48
+
49
+ DWORD pid = 0;
50
+ if (info[0].IsNumber()) {
51
+ pid = info[0].As<Napi::Number>().Uint32Value();
52
+ } else if (info[0].IsString()) {
53
+ pid = PidFromName(info[0].As<Napi::String>().Utf8Value());
54
+ if (!pid) {
55
+ auto e = Napi::Error::New(env, "ProcessNotFound");
56
+ e.Set("code", "PROCESS_NOT_FOUND");
57
+ e.ThrowAsJavaScriptException();
58
+ return env.Null();
59
+ }
60
+ } else {
61
+ Napi::TypeError::New(env, "nameOrPid must be string|number").ThrowAsJavaScriptException();
62
+ return env.Null();
63
+ }
64
+
65
+ try {
66
+ HANDLE h = OpenByPid(pid, access);
67
+ auto res = Napi::Object::New(env);
68
+ res.Set("handle", Napi::Number::New(env, static_cast<double>(reinterpret_cast<std::uintptr_t>(h))));
69
+ res.Set("pid", Napi::Number::New(env, pid));
70
+ return res;
71
+ } catch (const std::system_error& e) {
72
+ auto err = Napi::Error::New(env, e.what());
73
+ err.Set("code", e.code().value() == 5 ? "ACCESS_DENIED" : "OPEN_FAILED");
74
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e.code().value())));
75
+ err.ThrowAsJavaScriptException();
76
+ return env.Null();
77
+ }
78
+ }
79
+
80
+ Napi::Value CloseHandleSync(const Napi::CallbackInfo& info) {
81
+ Napi::Env env = info.Env();
82
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected handle").ThrowAsJavaScriptException(); return env.Null(); }
83
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
84
+ return Napi::Boolean::New(env, CloseHandle(h) != 0);
85
+ }
86
+
87
+ Napi::Value ListProcessesSync(const Napi::CallbackInfo& info) {
88
+ Napi::Env env = info.Env();
89
+ auto list = ListProcesses();
90
+ auto arr = Napi::Array::New(env, list.size());
91
+ for (size_t i = 0; i < list.size(); i++) {
92
+ auto o = Napi::Object::New(env);
93
+ o.Set("pid", Napi::Number::New(env, static_cast<double>(list[i].pid)));
94
+ o.Set("name", list[i].name);
95
+ arr.Set(i, o);
96
+ }
97
+ return arr;
98
+ }
99
+
100
+ Napi::Value GetModulesSync(const Napi::CallbackInfo& info) {
101
+ Napi::Env env = info.Env();
102
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) { Napi::TypeError::New(env, "expected (handle,pid)").ThrowAsJavaScriptException(); return env.Null(); }
103
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
104
+ DWORD pid = info[1].As<Napi::Number>().Uint32Value();
105
+ auto mods = GetModules(h, pid);
106
+ auto arr = Napi::Array::New(env, mods.size());
107
+ for (size_t i = 0; i < mods.size(); i++) {
108
+ auto o = Napi::Object::New(env);
109
+ o.Set("base", Napi::Number::New(env, static_cast<double>(mods[i].base)));
110
+ o.Set("size", Napi::Number::New(env, static_cast<double>(mods[i].size)));
111
+ o.Set("path", mods[i].path);
112
+ arr.Set(i, o);
113
+ }
114
+ return arr;
115
+ }
116
+
117
+ Napi::Value GetRegionsSync(const Napi::CallbackInfo& info) {
118
+ Napi::Env env = info.Env();
119
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected handle").ThrowAsJavaScriptException(); return env.Null(); }
120
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
121
+ auto regs = GetRegions(h);
122
+ auto arr = Napi::Array::New(env, regs.size());
123
+ for (size_t i = 0; i < regs.size(); i++) {
124
+ auto o = Napi::Object::New(env);
125
+ o.Set("baseAddress", Napi::Number::New(env, static_cast<double>(regs[i].baseAddress)));
126
+ o.Set("size", Napi::Number::New(env, static_cast<double>(regs[i].size)));
127
+ o.Set("protection", Napi::Number::New(env, static_cast<double>(regs[i].protection)));
128
+ o.Set("type", Napi::Number::New(env, static_cast<double>(regs[i].type)));
129
+ arr.Set(i, o);
130
+ }
131
+ return arr;
132
+ }
133
+
134
+ Napi::Value ReadBufferSync(const Napi::CallbackInfo& info) {
135
+ Napi::Env env = info.Env();
136
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,size)").ThrowAsJavaScriptException(); return env.Null(); }
137
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
138
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
139
+ double sizeD = info[2].As<Napi::Number>().DoubleValue();
140
+ if (!CheckSize(env, sizeD)) return env.Null();
141
+ auto size = static_cast<std::size_t>(sizeD);
142
+ try {
143
+ auto bytes = ReadBytes(h, addr, size);
144
+ return Napi::Buffer<std::uint8_t>::Copy(env, bytes.data(), bytes.size());
145
+ } catch (const std::system_error& e) {
146
+ auto err = Napi::Error::New(env, e.what());
147
+ err.Set("code", "READ_FAILED");
148
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e.code().value())));
149
+ err.ThrowAsJavaScriptException();
150
+ return env.Null();
151
+ }
152
+ }
153
+
154
+ Napi::Value WriteBufferSync(const Napi::CallbackInfo& info) {
155
+ Napi::Env env = info.Env();
156
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,buffer)").ThrowAsJavaScriptException(); return env.Null(); }
157
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
158
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
159
+ if (!info[2].IsBuffer()) { Napi::TypeError::New(env, "value must be a Buffer").ThrowAsJavaScriptException(); return env.Null(); }
160
+ auto buf = info[2].As<Napi::Buffer<std::uint8_t>>();
161
+ bool ok = WriteBytes(h, addr, buf.Data(), buf.Length());
162
+ return Napi::Boolean::New(env, ok);
163
+ }
164
+
165
+ // ---------- v2 Tier-1/2 ----------
166
+
167
+ // allocMemory(handle, size, protection[, nearAddress]) -> bigint
168
+ // `nearAddress` is passed to VirtualAllocEx as lpAddress - a hint used to land
169
+ // trampoline stubs within rel32 range (+/-2GB) of a hook site.
170
+ Napi::Value AllocMemorySync(const Napi::CallbackInfo& info) {
171
+ Napi::Env env = info.Env();
172
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,size,protection[,nearAddress])").ThrowAsJavaScriptException(); return env.Null(); }
173
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
174
+ auto size = static_cast<SIZE_T>(info[1].As<Napi::Number>().DoubleValue());
175
+ DWORD prot = info[2].As<Napi::Number>().Uint32Value();
176
+ LPVOID hint = nullptr;
177
+ if (info.Length() > 3 && info[3].IsNumber())
178
+ hint = reinterpret_cast<LPVOID>(static_cast<std::uintptr_t>(info[3].As<Napi::Number>().DoubleValue()));
179
+ LPVOID p = VirtualAllocEx(h, hint, size, MEM_COMMIT | MEM_RESERVE, prot);
180
+ if (!p) { auto e = Napi::Error::New(env, "VirtualAllocEx failed"); e.Set("code", "ALLOC_FAILED"); e.Set("win32code", Napi::Number::New(env, (double)GetLastError())); e.ThrowAsJavaScriptException(); return env.Null(); }
181
+ return Napi::BigInt::New(env, reinterpret_cast<std::uint64_t>(p));
182
+ }
183
+
184
+ Napi::Value FreeMemorySync(const Napi::CallbackInfo& info) {
185
+ Napi::Env env = info.Env();
186
+ if (info.Length() < 2) { Napi::TypeError::New(env, "expected (handle,address)").ThrowAsJavaScriptException(); return env.Null(); }
187
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
188
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
189
+ return Napi::Boolean::New(env, VirtualFreeEx(h, reinterpret_cast<LPVOID>(addr), 0, MEM_RELEASE) != 0);
190
+ }
191
+
192
+ Napi::Value ProtectMemorySync(const Napi::CallbackInfo& info) {
193
+ Napi::Env env = info.Env();
194
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,size,prot)").ThrowAsJavaScriptException(); return env.Null(); }
195
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
196
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
197
+ auto size = static_cast<SIZE_T>(info[2].As<Napi::Number>().DoubleValue());
198
+ DWORD prot = info[3].As<Napi::Number>().Uint32Value();
199
+ DWORD old = 0;
200
+ if (!VirtualProtectEx(h, reinterpret_cast<LPVOID>(addr), size, prot, &old)) {
201
+ auto e = Napi::Error::New(env, "VirtualProtectEx failed"); e.Set("code", "PROTECT_FAILED"); e.Set("win32code", Napi::Number::New(env, (double)GetLastError())); e.ThrowAsJavaScriptException(); return env.Null();
202
+ }
203
+ return Napi::Number::New(env, static_cast<double>(old));
204
+ }
205
+
206
+ Napi::Value IsWow64Sync(const Napi::CallbackInfo& info) {
207
+ Napi::Env env = info.Env();
208
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected handle").ThrowAsJavaScriptException(); return env.Null(); }
209
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
210
+ BOOL w = FALSE;
211
+ IsWow64Process(h, &w);
212
+ return Napi::Boolean::New(env, w != 0);
213
+ }
214
+
215
+ Napi::Value ListThreadsSync(const Napi::CallbackInfo& info) {
216
+ Napi::Env env = info.Env();
217
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected pid").ThrowAsJavaScriptException(); return env.Null(); }
218
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
219
+ HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
220
+ if (snap == INVALID_HANDLE_VALUE) return Napi::Array::New(env, 0); // callers expect number[], never null
221
+ std::vector<DWORD> tids;
222
+ THREADENTRY32 te{}; te.dwSize = sizeof(te);
223
+ if (Thread32First(snap, &te)) {
224
+ do { if (te.th32OwnerProcessID == pid) tids.push_back(te.th32ThreadID); } while (Thread32Next(snap, &te));
225
+ }
226
+ CloseHandle(snap);
227
+ auto arr = Napi::Array::New(env, tids.size());
228
+ for (size_t i = 0; i < tids.size(); i++) arr.Set(i, Napi::Number::New(env, tids[i]));
229
+ return arr;
230
+ }
231
+
232
+ Napi::Value SuspendThreadSync(const Napi::CallbackInfo& info) {
233
+ Napi::Env env = info.Env();
234
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected tid").ThrowAsJavaScriptException(); return env.Null(); }
235
+ DWORD tid = info[0].As<Napi::Number>().Uint32Value();
236
+ // QA4-C3: suspending OUR OWN thread from a sync binding freezes the JS
237
+ // engine mid-call - it can never reach resumeThread (unrecoverable
238
+ // deadlock). Refuse silently (false) so suspendAll() on the caller's own
239
+ // pid skips the calling thread instead of hanging.
240
+ if (tid == GetCurrentThreadId()) return Napi::Boolean::New(env, false);
241
+ HANDLE t = OpenThread(THREAD_SUSPEND_RESUME, FALSE, tid);
242
+ if (!t) return Napi::Boolean::New(env, false);
243
+ DWORD r = SuspendThread(t);
244
+ CloseHandle(t);
245
+ return Napi::Boolean::New(env, r != (DWORD)-1);
246
+ }
247
+
248
+ // currentThreadId() -> number
249
+ // Exported so the TS suspendAll() can skip the calling thread explicitly.
250
+ Napi::Value CurrentThreadIdSync(const Napi::CallbackInfo& info) {
251
+ Napi::Env env = info.Env();
252
+ return Napi::Number::New(env, static_cast<double>(GetCurrentThreadId()));
253
+ }
254
+
255
+ Napi::Value ResumeThreadSync(const Napi::CallbackInfo& info) {
256
+ Napi::Env env = info.Env();
257
+ if (info.Length() < 1 || !info[0].IsNumber()) { Napi::TypeError::New(env, "expected tid").ThrowAsJavaScriptException(); return env.Null(); }
258
+ DWORD tid = info[0].As<Napi::Number>().Uint32Value();
259
+ HANDLE t = OpenThread(THREAD_SUSPEND_RESUME, FALSE, tid);
260
+ if (!t) return Napi::Boolean::New(env, false);
261
+ DWORD r = ResumeThread(t);
262
+ CloseHandle(t);
263
+ return Napi::Boolean::New(env, r != (DWORD)-1);
264
+ }
265
+
266
+ // readValue(handle, address, type) where type in:
267
+ // "int32","uint32","int64","uint64","float","double","pointer"
268
+ Napi::Value ReadValueSync(const Napi::CallbackInfo& info) {
269
+ Napi::Env env = info.Env();
270
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,type)").ThrowAsJavaScriptException(); return env.Null(); }
271
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
272
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
273
+ std::string type = info[2].As<Napi::String>().Utf8Value();
274
+ try {
275
+ if (type == "int32") return Napi::Number::New(env, ReadValue<std::int32_t>(h, addr));
276
+ if (type == "uint32") return Napi::Number::New(env, ReadValue<std::uint32_t>(h, addr));
277
+ if (type == "int64") return Napi::BigInt::New(env, ReadValue<std::int64_t>(h, addr));
278
+ if (type == "uint64") return Napi::BigInt::New(env, ReadValue<std::uint64_t>(h, addr));
279
+ if (type == "float") return Napi::Number::New(env, ReadValue<float>(h, addr));
280
+ if (type == "double") return Napi::Number::New(env, ReadValue<double>(h, addr));
281
+ if (type == "pointer") return Napi::BigInt::New(env, static_cast<std::uint64_t>(ReadValue<std::uintptr_t>(h, addr)));
282
+ Napi::TypeError::New(env, "unknown type " + type).ThrowAsJavaScriptException();
283
+ return env.Null();
284
+ } catch (const std::system_error& e) {
285
+ auto err = Napi::Error::New(env, e.what());
286
+ err.Set("code", "READ_FAILED");
287
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e.code().value())));
288
+ err.ThrowAsJavaScriptException();
289
+ return env.Null();
290
+ }
291
+ }
292
+
293
+ Napi::Value WriteValueSync(const Napi::CallbackInfo& info) {
294
+ Napi::Env env = info.Env();
295
+ if (info.Length() < 4) { Napi::TypeError::New(env, "expected (handle,address,type,value)").ThrowAsJavaScriptException(); return env.Null(); }
296
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
297
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
298
+ std::string type = info[2].As<Napi::String>().Utf8Value();
299
+ bool ok = false;
300
+ try {
301
+ if (type == "int32") ok = WriteValue<std::int32_t>(h, addr, info[3].As<Napi::Number>().Int32Value());
302
+ else if (type == "uint32") ok = WriteValue<std::uint32_t>(h, addr, info[3].As<Napi::Number>().Uint32Value());
303
+ else if (type == "float") ok = WriteValue<float>(h, addr, info[3].As<Napi::Number>().FloatValue());
304
+ else if (type == "double") ok = WriteValue<double>(h, addr, info[3].As<Napi::Number>().DoubleValue());
305
+ else if (type == "int64") { bool ls; ok = WriteValue<std::int64_t>(h, addr, info[3].As<Napi::BigInt>().Int64Value(&ls)); }
306
+ else if (type == "uint64") { bool ls; ok = WriteValue<std::uint64_t>(h, addr, info[3].As<Napi::BigInt>().Uint64Value(&ls)); }
307
+ else if (type == "pointer") { bool ls; ok = WriteValue<std::uintptr_t>(h, addr, static_cast<std::uintptr_t>(info[3].As<Napi::BigInt>().Uint64Value(&ls))); }
308
+ else { Napi::TypeError::New(env, "unknown type " + type).ThrowAsJavaScriptException(); return env.Null(); }
309
+ return Napi::Boolean::New(env, ok);
310
+ } catch (...) {
311
+ auto err = Napi::Error::New(env, "WriteProcessMemory failed");
312
+ err.Set("code", "WRITE_FAILED");
313
+ err.ThrowAsJavaScriptException();
314
+ return env.Null();
315
+ }
316
+ }
317
+
318
+ // ---------- v2 final batch ----------
319
+
320
+ // readSafe(handle, address, size) -> Buffer
321
+ // Walks regions via VirtualQueryEx; reads committed+readable spans, zero-fills
322
+ // gaps/unreadable spans. Only throws if the handle is invalid for VirtualQueryEx.
323
+ Napi::Value ReadSafeSync(const Napi::CallbackInfo& info) {
324
+ Napi::Env env = info.Env();
325
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,size)").ThrowAsJavaScriptException(); return env.Null(); }
326
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
327
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
328
+ double sizeD = info[2].As<Napi::Number>().DoubleValue();
329
+ if (!CheckSize(env, sizeD)) return env.Null();
330
+ auto size = static_cast<std::size_t>(sizeD);
331
+
332
+ std::vector<std::uint8_t> out(size, 0);
333
+ const std::uintptr_t end = addr + size;
334
+ const std::uintptr_t kMax = 0x7FFFFFFFFFFFULL;
335
+ std::uintptr_t cur = addr;
336
+
337
+ while (cur < end && cur <= kMax) {
338
+ MEMORY_BASIC_INFORMATION mbi{};
339
+ if (!VirtualQueryEx(h, reinterpret_cast<LPCVOID>(cur), &mbi, sizeof(mbi))) {
340
+ DWORD e = GetLastError();
341
+ // ERROR_INVALID_HANDLE / ERROR_ACCESS_DENIED => handle problem: throw.
342
+ // Anything else (invalid address etc.) => leave zeros, we're done.
343
+ if (e == ERROR_INVALID_HANDLE || e == ERROR_ACCESS_DENIED) {
344
+ auto err = Napi::Error::New(env, "VirtualQueryEx failed");
345
+ err.Set("code", "READ_FAILED");
346
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e)));
347
+ err.ThrowAsJavaScriptException();
348
+ return env.Null();
349
+ }
350
+ break;
351
+ }
352
+
353
+ std::uintptr_t rBase = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
354
+ std::uintptr_t rEnd = rBase + mbi.RegionSize;
355
+ if (mbi.RegionSize == 0) break;
356
+ if (rEnd > cur && rBase < end) { // overlap
357
+ std::uintptr_t lo = rBase > cur ? rBase : cur;
358
+ std::uintptr_t hi = rEnd < end ? rEnd : end;
359
+ bool readable = (mbi.State == MEM_COMMIT) &&
360
+ !(mbi.Protect & PAGE_NOACCESS) &&
361
+ !(mbi.Protect & PAGE_GUARD);
362
+ if (readable) {
363
+ SIZE_T got = 0;
364
+ ReadProcessMemory(h, reinterpret_cast<LPCVOID>(lo),
365
+ out.data() + (lo - addr), hi - lo, &got);
366
+ // short/failed reads leave those bytes zeroed
367
+ }
368
+ }
369
+ cur = rEnd;
370
+ }
371
+ return Napi::Buffer<std::uint8_t>::Copy(env, out.data(), out.size());
372
+ }
373
+
374
+ // flushInstructions(handle, address, size) -> boolean (FlushInstructionCache)
375
+ Napi::Value FlushInstructionsSync(const Napi::CallbackInfo& info) {
376
+ Napi::Env env = info.Env();
377
+ if (info.Length() < 3) { Napi::TypeError::New(env, "expected (handle,address,size)").ThrowAsJavaScriptException(); return env.Null(); }
378
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
379
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
380
+ auto size = static_cast<SIZE_T>(info[2].As<Napi::Number>().DoubleValue());
381
+ return Napi::Boolean::New(env,
382
+ FlushInstructionCache(h, reinterpret_cast<LPCVOID>(addr), size) != 0);
383
+ }
384
+
385
+ // writeProtectedMemory(handle, address, buffer[, protection]) -> boolean
386
+ // QA4-M1: the old TS flow did protect(addr,len)->oldProt(first page only)->
387
+ // write->protect(addr,len,oldProt) - a write spanning a protection boundary
388
+ // restored EVERY page in the span to the first page's old protection,
389
+ // silently changing protections on the second region. This native
390
+ // implementation walks VirtualQueryEx over the write span, records each
391
+ // region's real old protection, unprotects the whole span, writes, then
392
+ // restores each region individually. Single-region writes keep identical
393
+ // behavior. Restore failures are reported via the `restored` flag - a
394
+ // restored=false result means part of the span is left at `protection`.
395
+ Napi::Value WriteProtectedMemorySync(const Napi::CallbackInfo& info) {
396
+ Napi::Env env = info.Env();
397
+ if (info.Length() < 3 || !info[0].IsNumber() || !info[1].IsNumber()) {
398
+ Napi::TypeError::New(env, "expected (handle,address,buffer[,protection])")
399
+ .ThrowAsJavaScriptException();
400
+ return env.Null();
401
+ }
402
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
403
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
404
+ if (!info[2].IsBuffer()) {
405
+ Napi::TypeError::New(env, "value must be a Buffer").ThrowAsJavaScriptException();
406
+ return env.Null();
407
+ }
408
+ auto buf = info[2].As<Napi::Buffer<std::uint8_t>>();
409
+ DWORD newProt = PAGE_EXECUTE_READWRITE;
410
+ if (info.Length() > 3 && info[3].IsNumber())
411
+ newProt = info[3].As<Napi::Number>().Uint32Value();
412
+ if (buf.Length() == 0) {
413
+ // Nothing to do - return the same result shape as a real write.
414
+ auto o = Napi::Object::New(env);
415
+ o.Set("ok", Napi::Boolean::New(env, true));
416
+ o.Set("restored", Napi::Boolean::New(env, true));
417
+ o.Set("protected", Napi::Boolean::New(env, true));
418
+ return o;
419
+ }
420
+
421
+ // QA4-M1: record per-region old protection across the whole write span
422
+ // BEFORE unprotecting - VirtualProtectEx only reports the first page's
423
+ // old value, so the query walk is the only correct source.
424
+ struct RegionSnap { std::uintptr_t base; std::uintptr_t end; DWORD oldProt; };
425
+ std::vector<RegionSnap> regions;
426
+ const std::uintptr_t end = addr + buf.Length();
427
+ {
428
+ std::uintptr_t cur = addr;
429
+ while (cur < end) {
430
+ MEMORY_BASIC_INFORMATION mbi{};
431
+ if (!VirtualQueryEx(h, reinterpret_cast<LPCVOID>(cur), &mbi, sizeof(mbi)) ||
432
+ mbi.State != MEM_COMMIT || mbi.RegionSize == 0) {
433
+ auto e = Napi::Error::New(env, "VirtualQueryEx failed or region not committed");
434
+ e.Set("code", "PROTECT_FAILED");
435
+ e.Set("win32code", Napi::Number::New(env, (double)GetLastError()));
436
+ e.ThrowAsJavaScriptException();
437
+ return env.Null();
438
+ }
439
+ std::uintptr_t rBase = reinterpret_cast<std::uintptr_t>(mbi.BaseAddress);
440
+ std::uintptr_t rEnd = rBase + mbi.RegionSize;
441
+ regions.push_back({ rBase < cur ? cur : rBase,
442
+ rEnd > end ? end : rEnd, mbi.Protect });
443
+ cur = rEnd;
444
+ }
445
+ }
446
+
447
+ // Unprotect each region span (VirtualProtectEx clamps to whole pages, which
448
+ // is fine - only pages intersecting [addr,end) are touched).
449
+ bool allProtected = true;
450
+ for (const auto& r : regions) {
451
+ DWORD old = 0;
452
+ if (!VirtualProtectEx(h, reinterpret_cast<LPVOID>(r.base), r.end - r.base,
453
+ newProt, &old)) {
454
+ allProtected = false; // continue - best effort
455
+ }
456
+ }
457
+
458
+ SIZE_T written = 0;
459
+ const bool wrote = WriteProcessMemory(
460
+ h, reinterpret_cast<LPVOID>(addr), buf.Data(), buf.Length(), &written) &&
461
+ written == buf.Length();
462
+
463
+ // Restore each region to ITS recorded protection (not the first page's).
464
+ bool allRestored = true;
465
+ for (const auto& r : regions) {
466
+ DWORD old = 0;
467
+ if (!VirtualProtectEx(h, reinterpret_cast<LPVOID>(r.base), r.end - r.base,
468
+ r.oldProt, &old)) {
469
+ allRestored = false;
470
+ }
471
+ }
472
+ FlushInstructionCache(h, reinterpret_cast<LPCVOID>(addr), buf.Length());
473
+
474
+ auto o = Napi::Object::New(env);
475
+ o.Set("ok", Napi::Boolean::New(env, wrote));
476
+ o.Set("restored", Napi::Boolean::New(env, allRestored));
477
+ o.Set("protected", Napi::Boolean::New(env, allProtected));
478
+ return o;
479
+ }
480
+
481
+ static void ThrowInject(Napi::Env env, const char* what, DWORD code) {
482
+ auto err = Napi::Error::New(env, what);
483
+ err.Set("code", "INJECT_FAILED");
484
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(code)));
485
+ err.ThrowAsJavaScriptException();
486
+ }
487
+
488
+ // injectDll(pid, dllPath) -> boolean
489
+ // QA4-M2: dllPath must be ABSOLUTE. A relative name resolves against the
490
+ // TARGET's cwd + DLL search order - a DLL-planting primitive when that cwd is
491
+ // attacker-writable. Reject anything that isn't `X:\...` or a `\\` UNC path.
492
+ // QA4-M2 also: the path is written UTF-16LE and LoadLibraryW is used - the
493
+ // old LoadLibraryA path mangled non-ANSI filenames per the target's codepage.
494
+ static bool IsAbsolutePath(const std::string& p) {
495
+ if (p.size() >= 3 && ((p[0] >= 'A' && p[0] <= 'Z') || (p[0] >= 'a' && p[0] <= 'z')) &&
496
+ p[1] == ':' && (p[2] == '\\' || p[2] == '/'))
497
+ return true; // X:\...
498
+ if (p.size() >= 2 && p[0] == '\\' && p[1] == '\\')
499
+ return true; // \\server\share or \\?\...
500
+ return false;
501
+ }
502
+
503
+ Napi::Value InjectDllSync(const Napi::CallbackInfo& info) {
504
+ Napi::Env env = info.Env();
505
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsString()) {
506
+ Napi::TypeError::New(env, "expected (pid, dllPath)").ThrowAsJavaScriptException();
507
+ return env.Null();
508
+ }
509
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
510
+ std::string path = info[1].As<Napi::String>().Utf8Value();
511
+ if (!IsAbsolutePath(path)) {
512
+ ThrowInject(env, "dllPath must be an absolute path (X:\\... or \\\\UNC)", ERROR_INVALID_NAME);
513
+ return env.Null();
514
+ }
515
+ if (path.size() > 32767) {
516
+ ThrowInject(env, "dllPath exceeds maximum NT path length (32767)", ERROR_FILENAME_EXCED_RANGE);
517
+ return env.Null();
518
+ }
519
+ // Optional 3rd arg: remote-thread wait timeout (bounded - a negative or
520
+ // huge value would wrap DWORD into an ~infinite sync wait, QA4-H1).
521
+ DWORD waitMs = 10000;
522
+ if (info.Length() > 2 && info[2].IsNumber()) {
523
+ double t = info[2].As<Napi::Number>().DoubleValue();
524
+ if (!std::isfinite(t) || t < 0 || t > 60000) {
525
+ Napi::RangeError::New(env, "timeoutMs must be a finite number in [0, 60000]")
526
+ .ThrowAsJavaScriptException();
527
+ return env.Null();
528
+ }
529
+ waitMs = static_cast<DWORD>(t);
530
+ }
531
+ // UTF-8 -> UTF-16LE for LoadLibraryW.
532
+ std::wstring wpath;
533
+ {
534
+ int n = MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, nullptr, 0);
535
+ if (n <= 0) { ThrowInject(env, "dllPath is not valid UTF-8", ERROR_NO_UNICODE_TRANSLATION); return env.Null(); }
536
+ wpath.assign(static_cast<size_t>(n) - 1, L'\0');
537
+ MultiByteToWideChar(CP_UTF8, 0, path.c_str(), -1, wpath.data(), n);
538
+ }
539
+ const DWORD access = PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
540
+ PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION;
541
+ HANDLE h = OpenProcess(access, FALSE, pid);
542
+ if (!h) { ThrowInject(env, "OpenProcess failed", GetLastError()); return env.Null(); }
543
+
544
+ // WoW64 targets load a 32-bit kernel32 at a different base - LoadLibraryW
545
+ // resolved in *our* (x64) process would be the wrong address. Refuse rather
546
+ // than silently corrupting the target.
547
+ {
548
+ BOOL wow = FALSE;
549
+ IsWow64Process(h, &wow);
550
+ if (wow) {
551
+ CloseHandle(h);
552
+ ThrowInject(env, "injectDll does not support WoW64 (32-bit) targets", ERROR_NOT_SUPPORTED);
553
+ return env.Null();
554
+ }
555
+ }
556
+
557
+ bool ok = false;
558
+ do {
559
+ const SIZE_T wbytes = (wpath.size() + 1) * sizeof(wchar_t);
560
+ LPVOID remote = VirtualAllocEx(h, nullptr, wbytes,
561
+ MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
562
+ if (!remote) { DWORD e = GetLastError(); ThrowInject(env, "VirtualAllocEx failed", e); break; }
563
+ SIZE_T w = 0;
564
+ if (!WriteProcessMemory(h, remote, wpath.c_str(), wbytes, &w)) {
565
+ DWORD e = GetLastError();
566
+ VirtualFreeEx(h, remote, 0, MEM_RELEASE);
567
+ ThrowInject(env, "WriteProcessMemory failed", e); break;
568
+ }
569
+ // NOTE: kernel32 is loaded at the same base in every x64 process, so this
570
+ // locally-resolved LoadLibraryW address is valid in the target. This holds
571
+ // because kernel32 is a KnownDll mapped at a shared ASLR base.
572
+ HMODULE k32 = GetModuleHandleW(L"kernel32.dll");
573
+ FARPROC loadLib = k32 ? GetProcAddress(k32, "LoadLibraryW") : nullptr;
574
+ if (!loadLib) {
575
+ DWORD e = GetLastError();
576
+ VirtualFreeEx(h, remote, 0, MEM_RELEASE);
577
+ ThrowInject(env, "LoadLibraryW resolution failed", e); break;
578
+ }
579
+ HANDLE t = CreateRemoteThread(h, nullptr, 0,
580
+ reinterpret_cast<LPTHREAD_START_ROUTINE>(loadLib), remote, 0, nullptr);
581
+ if (!t) {
582
+ DWORD e = GetLastError();
583
+ VirtualFreeEx(h, remote, 0, MEM_RELEASE);
584
+ ThrowInject(env, "CreateRemoteThread failed", e); break;
585
+ }
586
+ DWORD wr = WaitForSingleObject(t, waitMs);
587
+ if (wr != WAIT_OBJECT_0) {
588
+ // QA4-C1: do NOT TerminateThread here. TerminateThread is asynchronous
589
+ // and killing a thread inside LoadLibraryW can leave the target's
590
+ // loader lock permanently held - every subsequent LoadLibrary /
591
+ // thread-creation in the victim deadlocks. Instead we close our handle
592
+ // and deliberately LEAK the small path buffer so a late-finishing
593
+ // thread never reads freed memory. Bounded leak <=64KB; a wedged target
594
+ // is worse.
595
+ CloseHandle(t);
596
+ ThrowInject(env, "LoadLibraryW remote thread timed out (leaked path buffer; target not terminated to avoid loader-lock deadlock)",
597
+ wr == WAIT_FAILED ? GetLastError() : ERROR_TIMEOUT);
598
+ break;
599
+ }
600
+ DWORD exitCode = 0;
601
+ GetExitCodeThread(t, &exitCode);
602
+ CloseHandle(t);
603
+ VirtualFreeEx(h, remote, 0, MEM_RELEASE);
604
+ // LoadLibraryW returns the module HMODULE as the thread exit code; 0 means
605
+ // the DLL failed to load (not found, bad bitness, missing dependency).
606
+ if (exitCode == 0) {
607
+ ThrowInject(env, "LoadLibraryW returned NULL in target", ERROR_MOD_NOT_FOUND);
608
+ break;
609
+ }
610
+ ok = true;
611
+ } while (0);
612
+ CloseHandle(h);
613
+ if (!ok) return env.Null(); // exception already thrown
614
+ return Napi::Boolean::New(env, true);
615
+ }
616
+
617
+ // ejectDll(pid, moduleBase) -> boolean
618
+ Napi::Value EjectDllSync(const Napi::CallbackInfo& info) {
619
+ Napi::Env env = info.Env();
620
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
621
+ Napi::TypeError::New(env, "expected (pid, moduleBase)").ThrowAsJavaScriptException();
622
+ return env.Null();
623
+ }
624
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
625
+ auto base = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
626
+ const DWORD access = PROCESS_CREATE_THREAD | PROCESS_VM_OPERATION |
627
+ PROCESS_VM_WRITE | PROCESS_VM_READ | PROCESS_QUERY_INFORMATION;
628
+ HANDLE h = OpenProcess(access, FALSE, pid);
629
+ if (!h) { ThrowInject(env, "OpenProcess failed", GetLastError()); return env.Null(); }
630
+
631
+ // QA4-C4: same WoW64 guard as injectDll - our 64-bit FreeLibrary address is
632
+ // meaningless inside a 32-bit target's address space; the remote thread
633
+ // would execute a wild pointer.
634
+ {
635
+ BOOL wow = FALSE;
636
+ IsWow64Process(h, &wow);
637
+ if (wow) {
638
+ CloseHandle(h);
639
+ ThrowInject(env, "ejectDll does not support WoW64 (32-bit) targets", ERROR_NOT_SUPPORTED);
640
+ return env.Null();
641
+ }
642
+ }
643
+
644
+ bool ok = false;
645
+ do {
646
+ HMODULE k32 = GetModuleHandleW(L"kernel32.dll");
647
+ FARPROC freeLib = k32 ? GetProcAddress(k32, "FreeLibrary") : nullptr;
648
+ if (!freeLib) { ThrowInject(env, "FreeLibrary resolution failed", GetLastError()); break; }
649
+ HANDLE t = CreateRemoteThread(h, nullptr, 0,
650
+ reinterpret_cast<LPTHREAD_START_ROUTINE>(freeLib),
651
+ reinterpret_cast<LPVOID>(base), 0, nullptr);
652
+ if (!t) { ThrowInject(env, "CreateRemoteThread failed", GetLastError()); break; }
653
+ DWORD wr = WaitForSingleObject(t, 10000);
654
+ if (wr != WAIT_OBJECT_0) {
655
+ // QA4-C1: never TerminateThread inside FreeLibrary - the remote thread
656
+ // can hold the loader lock; killing it wedges the target permanently.
657
+ // Just close our handle and report the timeout.
658
+ CloseHandle(t);
659
+ ThrowInject(env, "FreeLibrary remote thread timed out (target thread left running to avoid loader-lock deadlock)",
660
+ wr == WAIT_FAILED ? GetLastError() : ERROR_TIMEOUT);
661
+ break;
662
+ }
663
+ // QA4-C4: FreeLibrary returns BOOL as the thread exit code - verify it.
664
+ // Without this check, ejecting kernel32/main.exe/garbage bases reported
665
+ // success while nothing (or worse, the wrong thing) happened.
666
+ DWORD exitCode = 0;
667
+ GetExitCodeThread(t, &exitCode);
668
+ CloseHandle(t);
669
+ if (exitCode == 0) {
670
+ ThrowInject(env, "FreeLibrary returned FALSE in target (bad/foreign module base or refcount pinned)",
671
+ ERROR_MOD_NOT_FOUND);
672
+ break;
673
+ }
674
+ ok = true;
675
+ } while (0);
676
+ CloseHandle(h);
677
+ if (!ok) return env.Null();
678
+ return Napi::Boolean::New(env, true);
679
+ }
680
+
681
+ // ---------- v2 Tier-3 ----------
682
+
683
+ // queryAddress(handle, address) -> { baseAddress, allocationBase, size,
684
+ // protection, state, type } | null
685
+ Napi::Value QueryAddressSync(const Napi::CallbackInfo& info) {
686
+ Napi::Env env = info.Env();
687
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
688
+ Napi::TypeError::New(env, "expected (handle,address)").ThrowAsJavaScriptException();
689
+ return env.Null();
690
+ }
691
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
692
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
693
+ MEMORY_BASIC_INFORMATION mbi{};
694
+ if (!VirtualQueryEx(h, reinterpret_cast<LPCVOID>(addr), &mbi, sizeof(mbi)))
695
+ return env.Null();
696
+ if (mbi.State == MEM_FREE) return env.Null(); // unmapped
697
+ auto o = Napi::Object::New(env);
698
+ o.Set("baseAddress", Napi::Number::New(env, static_cast<double>(reinterpret_cast<std::uintptr_t>(mbi.BaseAddress))));
699
+ o.Set("allocationBase", Napi::Number::New(env, static_cast<double>(reinterpret_cast<std::uintptr_t>(mbi.AllocationBase))));
700
+ o.Set("size", Napi::Number::New(env, static_cast<double>(mbi.RegionSize)));
701
+ o.Set("protection", Napi::Number::New(env, static_cast<double>(mbi.Protect)));
702
+ o.Set("state", Napi::Number::New(env, static_cast<double>(mbi.State)));
703
+ o.Set("type", Napi::Number::New(env, static_cast<double>(mbi.Type)));
704
+ return o;
705
+ }
706
+
707
+ static bool GetFullContext(DWORD tid, CONTEXT& ctx) {
708
+ HANDLE t = OpenThread(THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION, FALSE, tid);
709
+ if (!t) {
710
+ // Some systems require SET_CONTEXT rights for debug registers read too;
711
+ // retry with ALL access on failure.
712
+ t = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
713
+ if (!t) return false;
714
+ }
715
+ ZeroMemory(&ctx, sizeof(ctx));
716
+ ctx.ContextFlags = CONTEXT_ALL | CONTEXT_DEBUG_REGISTERS;
717
+ BOOL ok = GetThreadContext(t, &ctx);
718
+ CloseHandle(t);
719
+ return ok != 0;
720
+ }
721
+
722
+ // getThreadContext(tid) -> { rip,rsp,rbp,dr0..dr3,dr6,dr7,eflags } | null
723
+ // NOTE: caller is expected to have suspended the thread first.
724
+ Napi::Value GetThreadContextSync(const Napi::CallbackInfo& info) {
725
+ Napi::Env env = info.Env();
726
+ if (info.Length() < 1 || !info[0].IsNumber()) {
727
+ Napi::TypeError::New(env, "expected (tid)").ThrowAsJavaScriptException();
728
+ return env.Null();
729
+ }
730
+ DWORD tid = info[0].As<Napi::Number>().Uint32Value();
731
+ CONTEXT ctx{};
732
+ if (!GetFullContext(tid, ctx)) return env.Null();
733
+ auto o = Napi::Object::New(env);
734
+ o.Set("rip", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Rip)));
735
+ o.Set("rsp", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Rsp)));
736
+ o.Set("rbp", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Rbp)));
737
+ o.Set("dr0", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr0)));
738
+ o.Set("dr1", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr1)));
739
+ o.Set("dr2", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr2)));
740
+ o.Set("dr3", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr3)));
741
+ o.Set("dr6", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr6)));
742
+ o.Set("dr7", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.Dr7)));
743
+ o.Set("eflags", Napi::BigInt::New(env, static_cast<std::uint64_t>(ctx.EFlags)));
744
+ return o;
745
+ }
746
+
747
+ static bool JsToU64(const Napi::Value& v, std::uint64_t& out) {
748
+ if (v.IsBigInt()) { bool ls; out = v.As<Napi::BigInt>().Uint64Value(&ls); return true; }
749
+ if (v.IsNumber()) { out = static_cast<std::uint64_t>(v.As<Napi::Number>().DoubleValue()); return true; }
750
+ return false;
751
+ }
752
+
753
+ // setThreadContext(tid, { rip?, rsp?, rbp?, dr0?..dr3?, dr6?, dr7?, eflags? }) -> boolean
754
+ // Reads the current context, patches provided fields, writes it back.
755
+ Napi::Value SetThreadContextSync(const Napi::CallbackInfo& info) {
756
+ Napi::Env env = info.Env();
757
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsObject()) {
758
+ Napi::TypeError::New(env, "expected (tid, ctx)").ThrowAsJavaScriptException();
759
+ return env.Null();
760
+ }
761
+ DWORD tid = info[0].As<Napi::Number>().Uint32Value();
762
+ auto patch = info[1].As<Napi::Object>();
763
+
764
+ CONTEXT ctx{};
765
+ if (!GetFullContext(tid, ctx)) return Napi::Boolean::New(env, false);
766
+
767
+ std::uint64_t v;
768
+ auto set = [&](const char* name, std::uint64_t& field) {
769
+ if (patch.Has(name) && JsToU64(patch.Get(name), v)) field = v;
770
+ };
771
+ set("rip", *reinterpret_cast<std::uint64_t*>(&ctx.Rip));
772
+ set("rsp", *reinterpret_cast<std::uint64_t*>(&ctx.Rsp));
773
+ set("rbp", *reinterpret_cast<std::uint64_t*>(&ctx.Rbp));
774
+ set("dr0", *reinterpret_cast<std::uint64_t*>(&ctx.Dr0));
775
+ set("dr1", *reinterpret_cast<std::uint64_t*>(&ctx.Dr1));
776
+ set("dr2", *reinterpret_cast<std::uint64_t*>(&ctx.Dr2));
777
+ set("dr3", *reinterpret_cast<std::uint64_t*>(&ctx.Dr3));
778
+ set("dr6", *reinterpret_cast<std::uint64_t*>(&ctx.Dr6));
779
+ set("dr7", *reinterpret_cast<std::uint64_t*>(&ctx.Dr7));
780
+ if (patch.Has("eflags") && JsToU64(patch.Get("eflags"), v)) ctx.EFlags = static_cast<DWORD>(v);
781
+
782
+ HANDLE t = OpenThread(THREAD_SET_CONTEXT, FALSE, tid);
783
+ if (!t) {
784
+ t = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
785
+ if (!t) return Napi::Boolean::New(env, false);
786
+ }
787
+ ctx.ContextFlags = CONTEXT_ALL | CONTEXT_DEBUG_REGISTERS;
788
+ BOOL ok = SetThreadContext(t, &ctx);
789
+ CloseHandle(t);
790
+ return Napi::Boolean::New(env, ok != 0);
791
+ }
792
+
793
+ // callRemote(pid, fnAddress, param, timeoutMs?) -> exitCode | null
794
+ Napi::Value CallRemoteSync(const Napi::CallbackInfo& info) {
795
+ Napi::Env env = info.Env();
796
+ if (info.Length() < 3 || !info[0].IsNumber()) {
797
+ Napi::TypeError::New(env, "expected (pid, fnAddress, param[, timeoutMs])").ThrowAsJavaScriptException();
798
+ return env.Null();
799
+ }
800
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
801
+ std::uint64_t fn = 0, param = 0;
802
+ if (!JsToU64(info[1], fn) || !JsToU64(info[2], param)) {
803
+ Napi::TypeError::New(env, "fnAddress and param must be number|bigint").ThrowAsJavaScriptException();
804
+ return env.Null();
805
+ }
806
+ DWORD timeout = 10000;
807
+ if (info.Length() > 3 && info[3].IsNumber()) {
808
+ // QA4-H1: Uint32Value() on -1 wraps to 0xFFFFFFFF = INFINITE - a sync
809
+ // binding that never returns bricks the whole JS process. Clamp to a
810
+ // bounded range; negative/non-finite values are a caller bug.
811
+ double t = info[3].As<Napi::Number>().DoubleValue();
812
+ if (!std::isfinite(t) || t < 0 || t > 60000) {
813
+ Napi::RangeError::New(env, "timeoutMs must be a finite number in [0, 60000]")
814
+ .ThrowAsJavaScriptException();
815
+ return env.Null();
816
+ }
817
+ timeout = static_cast<DWORD>(t);
818
+ }
819
+
820
+ HANDLE h = OpenProcess(PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION, FALSE, pid);
821
+ if (!h) return env.Null();
822
+ HANDLE t = CreateRemoteThread(h, nullptr, 0,
823
+ reinterpret_cast<LPTHREAD_START_ROUTINE>(static_cast<std::uintptr_t>(fn)),
824
+ reinterpret_cast<LPVOID>(static_cast<std::uintptr_t>(param)), 0, nullptr);
825
+ if (!t) { CloseHandle(h); return env.Null(); }
826
+ DWORD wr = WaitForSingleObject(t, timeout);
827
+ DWORD code = 0;
828
+ // QA4-C1: TerminateThread is asynchronous - it only *requests* termination.
829
+ // Returning immediately after TerminateThread lets the caller (execShellcode
830
+ // finally-block, injectDll) free memory the zombie thread may still be
831
+ // executing in. Wait for the thread object itself to be signaled (max 2s)
832
+ // so a timeout-return really means the thread is dead. NOTE: terminating a
833
+ // thread that was mid-LoadLibrary can still leave the target's loader lock
834
+ // held - an inherent TerminateThread hazard; callers needing guaranteed
835
+ // non-wedging behavior should prefer a longer timeout over termination.
836
+ if (wr != WAIT_OBJECT_0) {
837
+ TerminateThread(t, 0);
838
+ WaitForSingleObject(t, 2000);
839
+ CloseHandle(t);
840
+ CloseHandle(h);
841
+ return env.Null();
842
+ }
843
+ bool ok = GetExitCodeThread(t, &code) != 0;
844
+ CloseHandle(t);
845
+ CloseHandle(h);
846
+ if (!ok) return env.Null();
847
+ return Napi::Number::New(env, static_cast<double>(code));
848
+ }
849
+
850
+ // ---------- v2 disasm (HDE64 length decode) ----------
851
+
852
+ // disasmInstructions(handle, address, count) -> [{offset, length}]
853
+ // Reads up to count*16 bytes (capped 4096) once via ReadBytes, then walks
854
+ // the buffer with hde64_disasm returning per-instruction offsets+lengths.
855
+ Napi::Value DisasmInstructionsSync(const Napi::CallbackInfo& info) {
856
+ Napi::Env env = info.Env();
857
+ if (info.Length() < 3 || !info[0].IsNumber() || !info[1].IsNumber() || !info[2].IsNumber()) {
858
+ Napi::TypeError::New(env, "expected (handle, address, count)").ThrowAsJavaScriptException();
859
+ return env.Null();
860
+ }
861
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
862
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
863
+ auto count = static_cast<std::size_t>(info[2].As<Napi::Number>().DoubleValue());
864
+ if (count == 0) return Napi::Array::New(env, 0);
865
+ if (count > 256) count = 256; // 256*15 ~ 4KB window; also prevents count*16 wraparound
866
+
867
+ std::size_t readSize = count * 16; // max x64 instruction length is 15
868
+ if (readSize > 4096) readSize = 4096;
869
+
870
+ std::vector<std::uint8_t> buf;
871
+ try {
872
+ buf = ReadBytes(h, addr, readSize);
873
+ } catch (const std::system_error& e) {
874
+ auto err = Napi::Error::New(env, e.what());
875
+ err.Set("code", "READ_FAILED");
876
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e.code().value())));
877
+ err.ThrowAsJavaScriptException();
878
+ return env.Null();
879
+ }
880
+
881
+ auto arr = Napi::Array::New(env);
882
+ std::size_t off = 0, n = 0;
883
+ while (off < buf.size() && n < count) {
884
+ // hde64_disasm takes no length and may read up to 15 bytes for one
885
+ // instruction - feeding it buf.data()+off directly walks past the end of
886
+ // the allocation on a truncated tail. Decode from a 16-byte padded stack
887
+ // scratch instead; the trailing zeros make a truncated insn fail cleanly.
888
+ std::uint8_t scratch[16] = {};
889
+ const std::size_t avail = buf.size() - off;
890
+ std::memcpy(scratch, buf.data() + off, avail < sizeof(scratch) ? avail : sizeof(scratch));
891
+ hde64s hs{};
892
+ unsigned int len = hde64_disasm(scratch, &hs);
893
+ if (len == 0 || (hs.flags & F_ERROR)) break;
894
+ if (off + len > buf.size()) break; // truncated tail instruction
895
+ auto o = Napi::Object::New(env);
896
+ o.Set("offset", Napi::Number::New(env, static_cast<double>(off)));
897
+ o.Set("length", Napi::Number::New(env, static_cast<double>(len)));
898
+ // QA4-C2: expose position-dependence so callers (hook) can refuse to copy
899
+ // the instruction to a differently-based trampoline. F_RELATIVE covers
900
+ // rel8/16/32 branch+call immediates (E8/E9/EB, 70-7F, 0F 8x, E0-E3, xbegin).
901
+ // A ModRM mod=00 rm=101 encoding is RIP-relative ([rip+disp32]).
902
+ const bool ripRel = (hs.flags & F_MODRM) && hs.modrm_mod == 0 &&
903
+ hs.modrm_rm == 5;
904
+ o.Set("relative",
905
+ Napi::Boolean::New(env, (hs.flags & F_RELATIVE) != 0 || ripRel));
906
+ arr.Set(n, o);
907
+ off += len;
908
+ n++;
909
+ }
910
+ return arr;
911
+ }
912
+
913
+ // hookSteal(handle, address[, minBytes][, stubAddr]) -> { length, bytes:Buffer }
914
+ //
915
+ // MinHook-style relocation: steals whole instructions until >= minBytes are
916
+ // covered, then rewrites position-relative operands so the stolen bytes are
917
+ // correct when executed from `stubAddr` (which must be within rel32 range of
918
+ // `address` - the caller's #allocNear guarantees this):
919
+ // - rel32 branch/call (E8/E9/0F8x, xbegin): rel32 rewritten in place, OR
920
+ // copied verbatim when the target is INSIDE the stolen window (MinHook's
921
+ // "internal jump" rule - distances are preserved by the copy).
922
+ // - RIP-relative ModRM (mod=00 rm=101): disp32 rewritten in place so it
923
+ // still reaches the original absolute target.
924
+ // - rel8/rel16 branches incl. LOOP/JECXZ (E0-E3): verbatim if internal -
925
+ // the operand is position-independent within the window - otherwise
926
+ // REFUSED (rel8 can't be widened to a same-length rewrite; MinHook
927
+ // refuses LOOP/JECXZ-external too).
928
+ // Without `stubAddr` every position-relative instruction is refused
929
+ // (HOOK_RELOCATE) - the old verify-only behavior for raw inspection calls.
930
+ // Throws HOOK_STEAL when <minBytes of decodable instructions exist, or
931
+ // HOOK_RELOCATE naming the offending opcode/offset. Returns the stolen bytes
932
+ // so the caller doesn't need a second remote read.
933
+ Napi::Value HookStealSync(const Napi::CallbackInfo& info) {
934
+ Napi::Env env = info.Env();
935
+ if (info.Length() < 2 || !info[0].IsNumber() || !info[1].IsNumber()) {
936
+ Napi::TypeError::New(env, "expected (handle, address[, minBytes][, stubAddr])")
937
+ .ThrowAsJavaScriptException();
938
+ return env.Null();
939
+ }
940
+ HANDLE h = reinterpret_cast<HANDLE>(static_cast<std::uintptr_t>(info[0].As<Napi::Number>().DoubleValue()));
941
+ auto addr = static_cast<std::uintptr_t>(info[1].As<Napi::Number>().DoubleValue());
942
+ std::size_t minBytes = 5;
943
+ std::uintptr_t stub = 0;
944
+ // Accepted arg shapes:
945
+ // hookSteal(h, a, 5) - minBytes number
946
+ // hookSteal(h, a, 5, stub) - minBytes + stubAddr (number|bigint)
947
+ // hookSteal(h, a, {minBytes, stubAddr}) - options object
948
+ if (info.Length() > 2) {
949
+ if (info[2].IsNumber()) {
950
+ double m = info[2].As<Napi::Number>().DoubleValue();
951
+ if (std::isfinite(m) && m >= 1 && m <= 64) minBytes = static_cast<std::size_t>(m);
952
+ if (info.Length() > 3) {
953
+ std::uint64_t s = 0;
954
+ if (JsToU64(info[3], s)) stub = static_cast<std::uintptr_t>(s);
955
+ }
956
+ } else if (info[2].IsObject() && !info[2].IsNull()) {
957
+ auto opts = info[2].As<Napi::Object>();
958
+ auto mb = opts.Get("minBytes");
959
+ if (mb.IsNumber()) {
960
+ double m = mb.As<Napi::Number>().DoubleValue();
961
+ if (std::isfinite(m) && m >= 1 && m <= 64) minBytes = static_cast<std::size_t>(m);
962
+ }
963
+ auto sa = opts.Get("stubAddr");
964
+ if (!sa.IsUndefined()) {
965
+ std::uint64_t s = 0;
966
+ if (JsToU64(sa, s)) stub = static_cast<std::uintptr_t>(s);
967
+ }
968
+ }
969
+ }
970
+ // An instruction beginning <15 bytes before minBytes can overshoot past it -
971
+ // read minBytes+15 so no valid tail instruction truncates.
972
+ std::vector<std::uint8_t> buf;
973
+ try {
974
+ buf = ReadBytes(h, addr, minBytes + 15);
975
+ } catch (const std::system_error& e) {
976
+ auto err = Napi::Error::New(env, e.what());
977
+ err.Set("code", "READ_FAILED");
978
+ err.Set("win32code", Napi::Number::New(env, static_cast<double>(e.code().value())));
979
+ err.ThrowAsJavaScriptException();
980
+ return env.Null();
981
+ }
982
+
983
+ auto throwAt = [&](const char* code, const std::string& msg) {
984
+ auto err = Napi::Error::New(env, msg);
985
+ err.Set("code", code);
986
+ err.ThrowAsJavaScriptException();
987
+ };
988
+
989
+ // Pass 1: decode - collect each instruction's decode state for relocation.
990
+ struct StolenInsn {
991
+ std::size_t off; // offset within buf
992
+ unsigned len;
993
+ std::uint32_t flags;
994
+ std::uint8_t opcode, opcode2;
995
+ bool ripRel; // RIP-relative ModRM operand (mod=00 rm=101)
996
+ std::size_t immOff; // offset of immediate field (valid if immSize>0)
997
+ unsigned immSize; // immediate field size in bytes
998
+ std::size_t dispOff; // offset of displacement field (valid if dispSize>0)
999
+ unsigned dispSize; // displacement field size in bytes
1000
+ };
1001
+ std::vector<StolenInsn> insns;
1002
+ std::size_t off = 0;
1003
+ while (off < minBytes && off < buf.size()) {
1004
+ std::uint8_t scratch[16] = {};
1005
+ const std::size_t avail = buf.size() - off;
1006
+ std::memcpy(scratch, buf.data() + off, avail < sizeof(scratch) ? avail : sizeof(scratch));
1007
+ hde64s hs{};
1008
+ unsigned int len = hde64_disasm(scratch, &hs);
1009
+ if (len == 0 || (hs.flags & F_ERROR)) {
1010
+ char msg[128];
1011
+ std::snprintf(msg, sizeof(msg),
1012
+ "hookSteal: undecodable instruction at +%zu - refusing to steal", off);
1013
+ throwAt("HOOK_STEAL", msg);
1014
+ return env.Null();
1015
+ }
1016
+ if (off + len > buf.size()) {
1017
+ char msg[128];
1018
+ std::snprintf(msg, sizeof(msg),
1019
+ "hookSteal: truncated instruction at +%zu - cannot cover %zu bytes", off, minBytes);
1020
+ throwAt("HOOK_STEAL", msg);
1021
+ return env.Null();
1022
+ }
1023
+ StolenInsn si{};
1024
+ si.off = off;
1025
+ si.len = len;
1026
+ si.flags = hs.flags;
1027
+ si.opcode = hs.opcode;
1028
+ si.opcode2 = hs.opcode2;
1029
+ si.ripRel = (hs.flags & F_MODRM) && hs.modrm_mod == 0 && hs.modrm_rm == 5;
1030
+ // Field sizes: the immediate/disp land at the tail of the instruction.
1031
+ // imm is the LAST field; disp precedes it - MinHook's offset math:
1032
+ // disp field = len - immSize - dispSize (same as len-((flags&0x3C)>>2)-4)
1033
+ si.immSize = (hs.flags & F_IMM64) ? 8 :
1034
+ (hs.flags & F_IMM32) ? 4 :
1035
+ (hs.flags & F_IMM16) ? 2 :
1036
+ (hs.flags & F_IMM8) ? 1 : 0;
1037
+ si.dispSize = (hs.flags & F_DISP32) ? 4 :
1038
+ (hs.flags & F_DISP16) ? 2 :
1039
+ (hs.flags & F_DISP8) ? 1 : 0;
1040
+ si.immOff = si.immSize ? off + len - si.immSize : 0;
1041
+ si.dispOff = si.dispSize ? off + len - si.immSize - si.dispSize : 0;
1042
+ insns.push_back(si);
1043
+ off += len;
1044
+ }
1045
+ if (off < minBytes) {
1046
+ char msg[128];
1047
+ std::snprintf(msg, sizeof(msg),
1048
+ "hookSteal: only %zu decodable bytes at 0x%llX - need >= %zu",
1049
+ off, static_cast<unsigned long long>(addr), minBytes);
1050
+ throwAt("HOOK_STEAL", msg);
1051
+ return env.Null();
1052
+ }
1053
+ const std::size_t stolenLen = off;
1054
+
1055
+ // Pass 2: relocate - rewrite position-relative operands in the copied
1056
+ // bytes. `bytes` becomes the relocated copy (what the caller writes to the
1057
+ // stub); `original` keeps the pristine bytes for unhook.
1058
+ std::vector<std::uint8_t> original(buf.begin(), buf.begin() + stolenLen);
1059
+ for (const StolenInsn& si : insns) {
1060
+ if (!(si.flags & F_RELATIVE) && !si.ripRel) continue;
1061
+
1062
+ const char* which = (si.flags & F_RELATIVE) ? "relative branch/call"
1063
+ : "RIP-relative operand";
1064
+ auto refuse = [&](const char* detail) {
1065
+ char msg[192];
1066
+ std::snprintf(msg, sizeof(msg),
1067
+ "hookSteal: stolen instruction at +%zu (opcode %02X%s) is %s: %s",
1068
+ si.off, si.opcode2 ? si.opcode2 : si.opcode,
1069
+ si.opcode2 ? " (0F map)" : "", which, detail);
1070
+ throwAt("HOOK_RELOCATE", msg);
1071
+ return env.Null();
1072
+ };
1073
+
1074
+ if (stub == 0) {
1075
+ return refuse("pass stubAddr to enable relocation (copying it verbatim "
1076
+ "would produce a wild jump/read)");
1077
+ }
1078
+
1079
+ // Immediate-carried relative branches/calls (E8/E9/EB, 70-7F, 0F8x, E0-E3,
1080
+ // xbegin C7 F8): the rel field IS the immediate.
1081
+ if (si.flags & F_RELATIVE) {
1082
+ if (si.immSize == 0) continue;
1083
+ // Old absolute branch target.
1084
+ std::int64_t oldRel = 0;
1085
+ std::memcpy(&oldRel, buf.data() + si.immOff, si.immSize);
1086
+ switch (si.immSize) { // sign-extend
1087
+ case 1: oldRel = static_cast<std::int8_t>(oldRel); break;
1088
+ case 2: oldRel = static_cast<std::int16_t>(oldRel); break;
1089
+ case 4: oldRel = static_cast<std::int32_t>(oldRel); break;
1090
+ }
1091
+ const std::uintptr_t oldTarget =
1092
+ static_cast<std::uintptr_t>(addr + si.off + si.len + oldRel);
1093
+ // Internal jump: target lands inside the stolen window. The copy
1094
+ // preserves the operand->target distance - leave the bytes verbatim.
1095
+ if (oldTarget >= addr && oldTarget < addr + stolenLen) continue;
1096
+ if (si.immSize != 4) {
1097
+ return refuse("rel8/rel16 external target cannot be widened in place "
1098
+ "(MinHook refuses LOOP/JECXZ-external too) - hook a "
1099
+ "different instruction-aligned site");
1100
+ }
1101
+ const std::int64_t newRel =
1102
+ static_cast<std::int64_t>(oldTarget) -
1103
+ (static_cast<std::int64_t>(stub) + si.off + si.len);
1104
+ if (newRel < INT32_MIN || newRel > INT32_MAX) {
1105
+ return refuse("rel32 rewrite lands out of +/-2GB range from the stub - "
1106
+ "allocate the stub nearer to the branch target");
1107
+ }
1108
+ const std::int32_t r32 = static_cast<std::int32_t>(newRel);
1109
+ std::memcpy(buf.data() + si.immOff, &r32, 4);
1110
+ continue;
1111
+ }
1112
+
1113
+ // RIP-relative ModRM memory operand: the disp32 field is the position-
1114
+ // dependent part - rewrite it to reach the same absolute address.
1115
+ if (si.ripRel) {
1116
+ if (si.dispSize != 4) continue; // mod=00 rm=101 always disp32
1117
+ std::int32_t d32 = 0;
1118
+ std::memcpy(&d32, buf.data() + si.dispOff, 4);
1119
+ const std::uintptr_t oldTarget =
1120
+ static_cast<std::uintptr_t>(addr + si.off + si.len + d32);
1121
+ if (oldTarget >= addr && oldTarget < addr + stolenLen) continue; // internal
1122
+ const std::int64_t newDisp =
1123
+ static_cast<std::int64_t>(oldTarget) -
1124
+ (static_cast<std::int64_t>(stub) + si.off + si.len);
1125
+ if (newDisp < INT32_MIN || newDisp > INT32_MAX) {
1126
+ return refuse("disp32 rewrite lands out of +/-2GB range from the stub - "
1127
+ "allocate the stub nearer to the operand target");
1128
+ }
1129
+ const std::int32_t n32 = static_cast<std::int32_t>(newDisp);
1130
+ std::memcpy(buf.data() + si.dispOff, &n32, 4);
1131
+ continue;
1132
+ }
1133
+ }
1134
+
1135
+ auto o = Napi::Object::New(env);
1136
+ o.Set("length", Napi::Number::New(env, static_cast<double>(stolenLen)));
1137
+ o.Set("bytes", Napi::Buffer<std::uint8_t>::Copy(env, buf.data(), stolenLen));
1138
+ o.Set("original",
1139
+ Napi::Buffer<std::uint8_t>::Copy(env, original.data(), stolenLen));
1140
+ return o;
1141
+ }
1142
+
1143
+ // ---------- v2.1 additions ----------
1144
+
1145
+ // suspendProcess(pid) / resumeProcess(pid) via undocumented-but-stable
1146
+ // NtSuspendProcess / NtResumeProcess exported by ntdll. Atomic whole-process
1147
+ // suspend - strictly better than per-thread suspendAll (no TOCTOU where a
1148
+ // new thread spawns mid-enumeration).
1149
+ static Napi::Value NtSuspendResume(const Napi::CallbackInfo& info, bool suspend) {
1150
+ Napi::Env env = info.Env();
1151
+ if (info.Length() < 1 || !info[0].IsNumber()) {
1152
+ Napi::TypeError::New(env, "expected (pid)").ThrowAsJavaScriptException();
1153
+ return env.Null();
1154
+ }
1155
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
1156
+ // QA4-C3: suspending OUR OWN process freezes the JS engine - even though
1157
+ // NtSuspendProcess skips the calling thread, every other thread (including
1158
+ // any that would resume us) is suspended and the object can never reach
1159
+ // resumeProcess(). Suspend on self-pid is an unrecoverable deadlock; refuse
1160
+ // with a clear error. Resume on self-pid is harmless - it just clears
1161
+ // suspend counts on our own threads.
1162
+ if (suspend && pid == GetCurrentProcessId()) {
1163
+ auto err = Napi::Error::New(env,
1164
+ "refusing to suspend the calling process (self-suspend deadlock)");
1165
+ err.Set("code", "SELF_SUSPEND");
1166
+ err.ThrowAsJavaScriptException();
1167
+ return env.Null();
1168
+ }
1169
+ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
1170
+ if (!ntdll) return Napi::Boolean::New(env, false);
1171
+ using NtProcFn = LONG (NTAPI*)(HANDLE);
1172
+ FARPROC fp = GetProcAddress(ntdll, suspend ? "NtSuspendProcess" : "NtResumeProcess");
1173
+ if (!fp) return Napi::Boolean::New(env, false);
1174
+ HANDLE h = OpenProcess(PROCESS_SUSPEND_RESUME, FALSE, pid);
1175
+ if (!h) return Napi::Boolean::New(env, false);
1176
+ LONG status = reinterpret_cast<NtProcFn>(fp)(h);
1177
+ CloseHandle(h);
1178
+ return Napi::Boolean::New(env, status == 0 /* STATUS_SUCCESS */);
1179
+ }
1180
+
1181
+ Napi::Value SuspendProcessSync(const Napi::CallbackInfo& info) { return NtSuspendResume(info, true); }
1182
+ Napi::Value ResumeProcessSync(const Napi::CallbackInfo& info) { return NtSuspendResume(info, false); }
1183
+
1184
+ // enableDebugPrivilege() -> boolean
1185
+ // Grants the CALLING process SeDebugPrivilege so it can open
1186
+ // protected/system processes. No target handle needed.
1187
+ Napi::Value EnableDebugPrivilegeSync(const Napi::CallbackInfo& info) {
1188
+ Napi::Env env = info.Env();
1189
+ HANDLE tok = nullptr;
1190
+ if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &tok))
1191
+ return Napi::Boolean::New(env, false);
1192
+ LUID luid{};
1193
+ bool ok = LookupPrivilegeValueW(nullptr, L"SeDebugPrivilege", &luid) != 0;
1194
+ if (ok) {
1195
+ TOKEN_PRIVILEGES tp{};
1196
+ tp.PrivilegeCount = 1;
1197
+ tp.Privileges[0].Luid = luid;
1198
+ tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
1199
+ ok = AdjustTokenPrivileges(tok, FALSE, &tp, sizeof(tp), nullptr, nullptr) != 0 &&
1200
+ GetLastError() == ERROR_SUCCESS;
1201
+ }
1202
+ CloseHandle(tok);
1203
+ return Napi::Boolean::New(env, ok);
1204
+ }
1205
+
1206
+ // WaitForExitWorker: WaitForSingleObject on the (dup'd) process handle, then
1207
+ // GetExitCodeProcess. Resolve(exitCode) or resolve(null) on timeout.
1208
+ class WaitExitWorker : public Napi::AsyncWorker {
1209
+ public:
1210
+ WaitExitWorker(Napi::Env env, Napi::Promise::Deferred deferred, DWORD pid, DWORD timeoutMs)
1211
+ : Napi::AsyncWorker(env), deferred_(deferred), pid_(pid), timeout_(timeoutMs) {}
1212
+ ~WaitExitWorker() override { if (h_) CloseHandle(h_); }
1213
+
1214
+ void Execute() override {
1215
+ h_ = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, FALSE, pid_);
1216
+ if (!h_) { SetError("OpenProcess failed"); return; }
1217
+ DWORD wr = WaitForSingleObject(h_, timeout_);
1218
+ if (wr == WAIT_OBJECT_0) {
1219
+ DWORD code = 0;
1220
+ if (GetExitCodeProcess(h_, &code)) { exited_ = true; exitCode_ = code; }
1221
+ }
1222
+ // WAIT_TIMEOUT / WAIT_FAILED -> exited_ stays false -> resolve null
1223
+ }
1224
+ void OnOK() override {
1225
+ if (exited_) deferred_.Resolve(Napi::Number::New(Env(), static_cast<double>(exitCode_)));
1226
+ else deferred_.Resolve(Env().Null());
1227
+ }
1228
+ void OnError(const Napi::Error& e) override { deferred_.Reject(e.Value()); }
1229
+ private:
1230
+ Napi::Promise::Deferred deferred_;
1231
+ DWORD pid_, timeout_;
1232
+ HANDLE h_ = nullptr;
1233
+ bool exited_ = false;
1234
+ DWORD exitCode_ = 0;
1235
+ };
1236
+
1237
+ // waitForExit(pid, timeoutMs) -> Promise<exitCode | null>
1238
+ Napi::Value WaitForExitAsync(const Napi::CallbackInfo& info) {
1239
+ Napi::Env env = info.Env();
1240
+ if (info.Length() < 1 || !info[0].IsNumber()) {
1241
+ Napi::TypeError::New(env, "expected (pid[, timeoutMs])").ThrowAsJavaScriptException();
1242
+ return env.Null();
1243
+ }
1244
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
1245
+ DWORD timeout = INFINITE;
1246
+ if (info.Length() > 1 && info[1].IsNumber()) {
1247
+ double t = info[1].As<Napi::Number>().DoubleValue();
1248
+ // QA4-H1: a negative timeout would wrap DWORD and become a ~49-day
1249
+ // blocking wait. Reject negatives/NaN; Infinity stays INFINITE by design.
1250
+ if (!std::isfinite(t)) {
1251
+ if (std::isinf(t) && t > 0) { timeout = INFINITE; }
1252
+ else {
1253
+ Napi::RangeError::New(env, "timeoutMs must be >= 0 (Infinity allowed)")
1254
+ .ThrowAsJavaScriptException();
1255
+ return env.Null();
1256
+ }
1257
+ } else if (t < 0) {
1258
+ Napi::RangeError::New(env, "timeoutMs must be >= 0 (Infinity allowed)")
1259
+ .ThrowAsJavaScriptException();
1260
+ return env.Null();
1261
+ } else if (t > 0xFFFFFFFE) timeout = INFINITE;
1262
+ else timeout = static_cast<DWORD>(t);
1263
+ }
1264
+ auto deferred = Napi::Promise::Deferred::New(env);
1265
+ auto promise = deferred.Promise();
1266
+ auto* w = new WaitExitWorker(env, std::move(deferred), pid, timeout);
1267
+ w->Queue();
1268
+ return promise;
1269
+ }
1270
+
1271
+ // ---------- findWhatWrites (DebugActiveProcess + HW write breakpoints) ----------
1272
+
1273
+ // Hardware breakpoints only deliver their #DB exception to a debugger attached
1274
+ // to the TARGET - setting Dr regs without a debug loop just crashes the victim
1275
+ // with an unhandled EXCEPTION_SINGLE_STEP. So the CE "find what writes" flow
1276
+ // must own a real debugger session: DebugActiveProcess -> arm RW=01 data
1277
+ // breakpoints on every thread -> pump WaitForDebugEvent collecting
1278
+ // EXCEPTION_SINGLE_STEP hits -> restore Dr regs, ContinueDebugEvent, detach.
1279
+ //
1280
+ // NOTE on `rip`: a data breakpoint is a *trap* - it fires AFTER the write, so
1281
+ // the reported address is the instruction FOLLOWING the write, not the writer
1282
+ // itself. CE shows the same caveat; disassemble backwards to find the store.
1283
+
1284
+ #ifndef DBG_CONTINUE
1285
+ #define DBG_CONTINUE 0x00010002L
1286
+ #endif
1287
+ #ifndef DBG_EXCEPTION_NOT_HANDLED
1288
+ #define DBG_EXCEPTION_NOT_HANDLED 0x80010001L
1289
+ #endif
1290
+
1291
+ namespace {
1292
+
1293
+ struct FwwHit {
1294
+ std::uint64_t rip = 0;
1295
+ DWORD tid = 0;
1296
+ CONTEXT ctx{};
1297
+ };
1298
+
1299
+ struct ArmedThread {
1300
+ DWORD tid = 0;
1301
+ int slot = -1;
1302
+ std::uint64_t origDr = 0;
1303
+ std::uint64_t origDr7 = 0;
1304
+ };
1305
+
1306
+ // LEN encoding for Dr7: 00=1B, 01=2B, 11=4B, 10=8B (validated 1|2|4|8 by TS).
1307
+ static std::uint64_t LenBits(unsigned size) {
1308
+ switch (size) {
1309
+ case 1: return 0;
1310
+ case 2: return 1;
1311
+ case 8: return 2;
1312
+ default: return 3; // 4
1313
+ }
1314
+ }
1315
+
1316
+ // Arm a RW=01 (data write) breakpoint on the first free Dr slot of `hThread`.
1317
+ // `hThread` may be a handle from CREATE_THREAD/CREATE_PROCESS debug events or
1318
+ // OpenThread; it must allow GET/SET_CONTEXT and SUSPEND_RESUME. Suspend+resume
1319
+ // is balanced (net-zero suspend count) so threads already suspended by the
1320
+ // debug attach stay suspended and running threads stay running.
1321
+ // Returns slot (0-3) on success and fills origDr/origDr7 for restore; -1 on
1322
+ // failure.
1323
+ static int ArmWriteBp(HANDLE hThread, std::uint64_t addr, unsigned size,
1324
+ std::uint64_t& origDr, std::uint64_t& origDr7) {
1325
+ if (SuspendThread(hThread) == (DWORD)-1) return -1;
1326
+ int slot = -1;
1327
+ do {
1328
+ CONTEXT ctx{};
1329
+ ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
1330
+ if (!GetThreadContext(hThread, &ctx)) break;
1331
+ std::uint64_t dr7 = ctx.Dr7;
1332
+ for (int s = 0; s < 4; s++) {
1333
+ if ((dr7 & (0x3ull << (s * 2))) == 0) { slot = s; break; } // L+G clear
1334
+ }
1335
+ if (slot < 0) break;
1336
+ origDr7 = dr7;
1337
+ origDr = (&ctx.Dr0)[slot];
1338
+ const std::uint64_t shift = 16ull + slot * 4;
1339
+ dr7 &= ~(0xfull << shift); // clear RW+LEN
1340
+ dr7 |= (0x1ull | (LenBits(size) << 2)) << shift; // RW=01 write, LEN=size
1341
+ dr7 |= 1ull << (slot * 2); // local enable L{slot}
1342
+ ctx.Dr7 = dr7;
1343
+ (&ctx.Dr0)[slot] = addr;
1344
+ ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
1345
+ if (!SetThreadContext(hThread, &ctx)) { slot = -1; break; }
1346
+ } while (0);
1347
+ ResumeThread(hThread);
1348
+ return slot;
1349
+ }
1350
+
1351
+ static void DisarmBp(HANDLE hThread, int slot, std::uint64_t origDr,
1352
+ std::uint64_t origDr7) {
1353
+ if (SuspendThread(hThread) == (DWORD)-1) return;
1354
+ CONTEXT ctx{};
1355
+ ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
1356
+ if (GetThreadContext(hThread, &ctx)) {
1357
+ ctx.Dr7 = origDr7;
1358
+ (&ctx.Dr0)[slot] = origDr;
1359
+ ctx.Dr6 = 0;
1360
+ ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
1361
+ SetThreadContext(hThread, &ctx);
1362
+ }
1363
+ ResumeThread(hThread);
1364
+ }
1365
+
1366
+ class FindWhatWritesWorker : public Napi::AsyncWorker {
1367
+ public:
1368
+ FindWhatWritesWorker(Napi::Env env, Napi::Promise::Deferred deferred,
1369
+ DWORD pid, std::uint64_t addr, unsigned size,
1370
+ DWORD timeoutMs, unsigned maxHits)
1371
+ : Napi::AsyncWorker(env), deferred_(deferred), pid_(pid), addr_(addr),
1372
+ size_(size), timeoutMs_(timeoutMs), maxHits_(maxHits) {}
1373
+
1374
+ ~FindWhatWritesWorker() override {
1375
+ for (HANDLE h : openHandles_) CloseHandle(h);
1376
+ }
1377
+
1378
+ // Attach is best-effort: callers may need enableDebugPrivilege() first for
1379
+ // protected targets. DebugSetProcessKillOnExit(false) keeps the debuggee
1380
+ // alive when our process exits while still attached.
1381
+ void Execute() override {
1382
+ // Detach completion is asynchronous on Windows - a fresh
1383
+ // DebugActiveProcess immediately after DebugActiveProcessStop can return
1384
+ // ERROR_ACCESS_DENIED while the kernel tears down the debug port. Retry
1385
+ // for up to ~500ms so back-to-back findWhatWrites calls work.
1386
+ DWORD err = 0;
1387
+ bool ok = false;
1388
+ for (int i = 0; i < 50 && !ok; i++) {
1389
+ ok = DebugActiveProcess(pid_) != 0;
1390
+ if (!ok) { err = GetLastError(); Sleep(10); }
1391
+ }
1392
+ if (!ok) {
1393
+ SetError("DebugActiveProcess failed (win32 " + std::to_string(err) + ")");
1394
+ return;
1395
+ }
1396
+ attached_ = true;
1397
+ DebugSetProcessKillOnExit(FALSE);
1398
+
1399
+ // QA4-M3 (lifecycle): wrap all post-attach phases so ANY C++ exception
1400
+ // (bad_alloc under a flooding target, length_error in hits_/armed_
1401
+ // growth) still runs disarm+drain+Stop. Without this the debuggee is
1402
+ // left attached with armed Dr7 breakpoints and suspended threads - the
1403
+ // next write to the watched addr would fire an unhandled #DB and kill it.
1404
+ try {
1405
+ RunPhases();
1406
+ } catch (const std::exception& e) {
1407
+ CleanupDebuggee();
1408
+ SetError(std::string("findWhatWrites failed: ") + e.what());
1409
+ return;
1410
+ } catch (...) {
1411
+ CleanupDebuggee();
1412
+ SetError("findWhatWrites failed: unknown exception");
1413
+ return;
1414
+ }
1415
+ CleanupDebuggee();
1416
+ }
1417
+
1418
+ void RunPhases() {
1419
+ const ULONGLONG armDeadline = GetTickCount64() + 500;
1420
+ const ULONGLONG deadline = GetTickCount64() + timeoutMs_;
1421
+
1422
+ // Phase 1: collect the attach-time event burst (CREATE_PROCESS,
1423
+ // LOAD_DLLs, CREATE_THREAD per existing thread, initial breakpoint) and
1424
+ // arm each reported thread. Threads reported by CREATE_THREAD are already
1425
+ // debug-suspended at this point; our suspend/resume pair is balanced so
1426
+ // the bp is on before they can run. Hits are collected here too - a thread
1427
+ // armed+continued early in this loop can fire while we drain the rest.
1428
+ int quietWaits = 0;
1429
+ while (quietWaits < 3 && GetTickCount64() < armDeadline &&
1430
+ hits_.size() < maxHits_ && !exited_) {
1431
+ DEBUG_EVENT ev{};
1432
+ if (!WaitForDebugEvent(&ev, 20)) { quietWaits++; continue; }
1433
+ quietWaits = 0;
1434
+ ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, HandleEvent(ev));
1435
+ }
1436
+ // Fallback: arm anything the event burst missed (rare - attach reports
1437
+ // every thread, but belt-and-suspenders for filtered/racy cases).
1438
+ ArmSnapshotThreads();
1439
+
1440
+ // Phase 2: collect hits until timeout / maxHits / process exit.
1441
+ while (!exited_ && hits_.size() < maxHits_ && GetTickCount64() < deadline) {
1442
+ const ULONGLONG left = deadline - GetTickCount64();
1443
+ DEBUG_EVENT ev{};
1444
+ if (!WaitForDebugEvent(&ev, left > 25 ? 25 : (DWORD)left)) continue;
1445
+ ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, HandleEvent(ev));
1446
+ }
1447
+ }
1448
+
1449
+ // Phase 3: disarm every armed thread (restoring ORIGINAL Dr regs - the
1450
+ // target may legitimately use its own hw breakpoints), drain pending
1451
+ // events, disarm any stragglers hit mid-disarm, drain again, detach.
1452
+ // Everything runs on this thread - DebugActiveProcess/Continue/DebugStop
1453
+ // are debugger-thread-bound. Idempotent: runs only while attached_ and is
1454
+ // invoked from every Execute() exit path (normal + exception).
1455
+ void CleanupDebuggee() {
1456
+ if (!attached_) return;
1457
+ auto disarmAll = [this]() {
1458
+ for (auto& at : armed_) {
1459
+ HANDLE t = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT |
1460
+ THREAD_SUSPEND_RESUME,
1461
+ FALSE, at.tid);
1462
+ if (!t) continue;
1463
+ DisarmBp(t, at.slot, at.origDr, at.origDr7);
1464
+ CloseHandle(t);
1465
+ }
1466
+ };
1467
+ auto drain = [this]() {
1468
+ DEBUG_EVENT ev{};
1469
+ for (int i = 0; i < 64 && WaitForDebugEvent(&ev, 2); i++) {
1470
+ if (ev.dwDebugEventCode == CREATE_PROCESS_DEBUG_EVENT &&
1471
+ ev.u.CreateProcessInfo.hFile)
1472
+ CloseHandle(ev.u.CreateProcessInfo.hFile);
1473
+ if (ev.dwDebugEventCode == LOAD_DLL_DEBUG_EVENT && ev.u.LoadDll.hFile)
1474
+ CloseHandle(ev.u.LoadDll.hFile);
1475
+ // A queued single-step is our bp firing mid-disarm - swallow it
1476
+ // (DBG_CONTINUE). A queued genuine exception gets NOT_HANDLED so it
1477
+ // reaches the debuggee's SEH as it would without us.
1478
+ DWORD disp = DBG_CONTINUE;
1479
+ if (ev.dwDebugEventCode == EXCEPTION_DEBUG_EVENT &&
1480
+ ev.u.Exception.ExceptionRecord.ExceptionCode !=
1481
+ EXCEPTION_SINGLE_STEP &&
1482
+ ev.u.Exception.ExceptionRecord.ExceptionCode !=
1483
+ EXCEPTION_BREAKPOINT)
1484
+ disp = DBG_EXCEPTION_NOT_HANDLED;
1485
+ ContinueDebugEvent(ev.dwProcessId, ev.dwThreadId, disp);
1486
+ if (ev.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT) break;
1487
+ }
1488
+ };
1489
+ disarmAll();
1490
+ drain(); // flush pending bp hits + resume stopped threads
1491
+ disarmAll(); // threads stopped on a queued event during pass 1 skipped
1492
+ // SetThreadContext - disarm them now that they're resumed
1493
+ drain(); // release the last stopped threads, empty the port
1494
+ DebugActiveProcessStop(pid_);
1495
+ attached_ = false;
1496
+ }
1497
+
1498
+ void OnOK() override {
1499
+ Napi::Env env = Env();
1500
+ auto arr = Napi::Array::New(env, hits_.size());
1501
+ for (size_t i = 0; i < hits_.size(); i++) {
1502
+ auto o = Napi::Object::New(env);
1503
+ const FwwHit& h = hits_[i];
1504
+ o.Set("rip", Napi::BigInt::New(env, h.rip));
1505
+ o.Set("tid", Napi::Number::New(env, static_cast<double>(h.tid)));
1506
+ auto c = Napi::Object::New(env);
1507
+ c.Set("rip", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Rip)));
1508
+ c.Set("rsp", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Rsp)));
1509
+ c.Set("rbp", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Rbp)));
1510
+ c.Set("dr0", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr0)));
1511
+ c.Set("dr1", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr1)));
1512
+ c.Set("dr2", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr2)));
1513
+ c.Set("dr3", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr3)));
1514
+ c.Set("dr6", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr6)));
1515
+ c.Set("dr7", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.Dr7)));
1516
+ c.Set("eflags", Napi::BigInt::New(env, static_cast<std::uint64_t>(h.ctx.EFlags)));
1517
+ o.Set("threadContext", c);
1518
+ arr.Set(i, o);
1519
+ }
1520
+ deferred_.Resolve(arr);
1521
+ }
1522
+
1523
+ void OnError(const Napi::Error& e) override {
1524
+ deferred_.Reject(e.Value());
1525
+ }
1526
+
1527
+ private:
1528
+ // Returns the ContinueDebugEvent disposition for `ev`; also feeds side
1529
+ // effects (arming, hit collection, handle tracking).
1530
+ DWORD HandleEvent(const DEBUG_EVENT& ev) {
1531
+ switch (ev.dwDebugEventCode) {
1532
+ case EXCEPTION_DEBUG_EVENT: {
1533
+ const auto& er = ev.u.Exception.ExceptionRecord;
1534
+ if (er.ExceptionCode == EXCEPTION_SINGLE_STEP) {
1535
+ return HandleSingleStep(ev);
1536
+ }
1537
+ // The attach-time int3 (and any target int3) is debugger-handled.
1538
+ if (er.ExceptionCode == EXCEPTION_BREAKPOINT) return DBG_CONTINUE;
1539
+ // Everything else belongs to the debuggee's SEH chain.
1540
+ return DBG_EXCEPTION_NOT_HANDLED;
1541
+ }
1542
+ case CREATE_THREAD_DEBUG_EVENT: {
1543
+ const DWORD tid = ev.dwThreadId;
1544
+ // QA4-m4: hThread here is system-owned (unlike CREATE_PROCESS's hFile
1545
+ // which the debugger must close). Microsoft's debug samples and docs
1546
+ // never close it - the system cleans it up on EXIT_THREAD. Verified:
1547
+ // do NOT close; closing could later collide with a recycled handle
1548
+ // value the debugger also sees. Disarm uses OpenThread-by-tid anyway.
1549
+ HANDLE t = ev.u.CreateThread.hThread;
1550
+ ArmOnHandle(tid, t);
1551
+ return DBG_CONTINUE;
1552
+ }
1553
+ case CREATE_PROCESS_DEBUG_EVENT: {
1554
+ if (ev.u.CreateProcessInfo.hFile) CloseHandle(ev.u.CreateProcessInfo.hFile);
1555
+ ArmOnHandle(ev.dwThreadId, ev.u.CreateProcessInfo.hThread);
1556
+ return DBG_CONTINUE;
1557
+ }
1558
+ case LOAD_DLL_DEBUG_EVENT: {
1559
+ if (ev.u.LoadDll.hFile) CloseHandle(ev.u.LoadDll.hFile);
1560
+ return DBG_CONTINUE;
1561
+ }
1562
+ case EXIT_THREAD_DEBUG_EVENT:
1563
+ // If it was armed there's nothing to restore - the thread is gone.
1564
+ return DBG_CONTINUE;
1565
+ case EXIT_PROCESS_DEBUG_EVENT:
1566
+ exited_ = true;
1567
+ return DBG_CONTINUE;
1568
+ case RIP_EVENT:
1569
+ case OUTPUT_DEBUG_STRING_EVENT:
1570
+ case UNLOAD_DLL_DEBUG_EVENT:
1571
+ default:
1572
+ return DBG_CONTINUE;
1573
+ }
1574
+ }
1575
+
1576
+ DWORD HandleSingleStep(const DEBUG_EVENT& ev) {
1577
+ const DWORD tid = ev.dwThreadId;
1578
+ HANDLE t = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT, FALSE, tid);
1579
+ if (!t) return DBG_EXCEPTION_NOT_HANDLED;
1580
+ CONTEXT ctx{};
1581
+ ctx.ContextFlags = CONTEXT_ALL | CONTEXT_DEBUG_REGISTERS;
1582
+ bool ours = false;
1583
+ if (GetThreadContext(t, &ctx)) {
1584
+ int slot = -1;
1585
+ for (const auto& at : armed_)
1586
+ if (at.tid == tid) { slot = at.slot; break; }
1587
+ if (slot >= 0 && (ctx.Dr6 & (1ull << slot))) ours = true;
1588
+ if (ours) {
1589
+ FwwHit h;
1590
+ h.rip = static_cast<std::uint64_t>(
1591
+ reinterpret_cast<std::uintptr_t>(
1592
+ ev.u.Exception.ExceptionRecord.ExceptionAddress));
1593
+ h.tid = tid;
1594
+ h.ctx = ctx;
1595
+ hits_.push_back(h);
1596
+ ctx.Dr6 = 0; // acknowledge so a stale B bit can't false-trigger
1597
+ SetThreadContext(t, &ctx);
1598
+ }
1599
+ }
1600
+ CloseHandle(t);
1601
+ // Ours -> swallowed; foreign single-steps (e.g. target's own TF use) go
1602
+ // back unhandled so we don't silently swallow them.
1603
+ return ours ? DBG_CONTINUE : DBG_EXCEPTION_NOT_HANDLED;
1604
+ }
1605
+
1606
+ void ArmOnHandle(DWORD tid, HANDLE hThread) {
1607
+ if (!hThread) return;
1608
+ for (const auto& at : armed_)
1609
+ if (at.tid == tid) return; // already armed
1610
+ ArmedThread at;
1611
+ at.tid = tid;
1612
+ at.slot = ArmWriteBp(hThread, addr_, size_, at.origDr, at.origDr7);
1613
+ if (at.slot >= 0) armed_.push_back(at);
1614
+ }
1615
+
1616
+ // Arm threads the event burst didn't report (Toolhelp fallback).
1617
+ void ArmSnapshotThreads() {
1618
+ HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
1619
+ if (snap == INVALID_HANDLE_VALUE) return;
1620
+ THREADENTRY32 te{};
1621
+ te.dwSize = sizeof(te);
1622
+ if (Thread32First(snap, &te)) {
1623
+ do {
1624
+ if (te.th32OwnerProcessID != pid_) continue;
1625
+ bool armed = false;
1626
+ for (const auto& at : armed_)
1627
+ if (at.tid == te.th32ThreadID) { armed = true; break; }
1628
+ if (armed) continue;
1629
+ HANDLE t = OpenThread(THREAD_GET_CONTEXT | THREAD_SET_CONTEXT |
1630
+ THREAD_SUSPEND_RESUME,
1631
+ FALSE, te.th32ThreadID);
1632
+ if (!t) continue;
1633
+ ArmedThread at;
1634
+ at.tid = te.th32ThreadID;
1635
+ at.slot = ArmWriteBp(t, addr_, size_, at.origDr, at.origDr7);
1636
+ if (at.slot >= 0) { armed_.push_back(at); openHandles_.push_back(t); }
1637
+ else CloseHandle(t);
1638
+ } while (Thread32Next(snap, &te));
1639
+ }
1640
+ CloseHandle(snap);
1641
+ }
1642
+
1643
+ Napi::Promise::Deferred deferred_;
1644
+ DWORD pid_;
1645
+ std::uint64_t addr_;
1646
+ unsigned size_;
1647
+ DWORD timeoutMs_;
1648
+ unsigned maxHits_;
1649
+ bool attached_ = false;
1650
+ bool exited_ = false;
1651
+ std::vector<ArmedThread> armed_;
1652
+ std::vector<FwwHit> hits_;
1653
+ // Toolhelp-armed threads keep their handle so phase 3 could disarm via the
1654
+ // SAME handle if needed; closed in dtor.
1655
+ std::vector<HANDLE> openHandles_;
1656
+ };
1657
+
1658
+ } // anonymous namespace
1659
+
1660
+ // findWhatWrites(pid, address, size, timeoutMs, maxHits) ->
1661
+ // Promise<{rip, tid, threadContext}[]>
1662
+ Napi::Value FindWhatWritesAsync(const Napi::CallbackInfo& info) {
1663
+ Napi::Env env = info.Env();
1664
+ if (info.Length() < 2 || !info[0].IsNumber()) {
1665
+ Napi::TypeError::New(env, "expected (pid, address[, size, timeoutMs, maxHits])")
1666
+ .ThrowAsJavaScriptException();
1667
+ return env.Null();
1668
+ }
1669
+ DWORD pid = info[0].As<Napi::Number>().Uint32Value();
1670
+ std::uint64_t addr = 0;
1671
+ if (!JsToU64(info[1], addr)) {
1672
+ Napi::TypeError::New(env, "address must be number|bigint").ThrowAsJavaScriptException();
1673
+ return env.Null();
1674
+ }
1675
+ unsigned size = 4;
1676
+ if (info.Length() > 2 && info[2].IsNumber())
1677
+ size = info[2].As<Napi::Number>().Uint32Value();
1678
+ if (size != 1 && size != 2 && size != 4 && size != 8) {
1679
+ Napi::RangeError::New(env, "size must be 1|2|4|8").ThrowAsJavaScriptException();
1680
+ return env.Null();
1681
+ }
1682
+ DWORD timeout = 1000;
1683
+ if (info.Length() > 3 && info[3].IsNumber()) {
1684
+ double t = info[3].As<Napi::Number>().DoubleValue();
1685
+ if (std::isfinite(t) && t > 0 && t <= 60000) timeout = static_cast<DWORD>(t);
1686
+ }
1687
+ unsigned maxHits = 32;
1688
+ if (info.Length() > 4 && info[4].IsNumber()) {
1689
+ double m = info[4].As<Napi::Number>().DoubleValue();
1690
+ if (std::isfinite(m) && m >= 1) maxHits = m > 1024 ? 1024u : static_cast<unsigned>(m);
1691
+ }
1692
+
1693
+ // QA4-H7: CONTEXT/Dr registers use the x64 struct - on a WoW64 (32-bit)
1694
+ // target Get/SetThreadContext operate on WOW64_CONTEXT and the arm would
1695
+ // silently corrupt the debuggee or never fire. Refuse up front (same
1696
+ // pattern as injectDll).
1697
+ {
1698
+ HANDLE ph = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
1699
+ if (ph) {
1700
+ BOOL wow = FALSE;
1701
+ IsWow64Process(ph, &wow);
1702
+ CloseHandle(ph);
1703
+ if (wow) {
1704
+ Napi::Error::New(env,
1705
+ "findWhatWrites does not support WoW64 (32-bit) targets")
1706
+ .ThrowAsJavaScriptException();
1707
+ return env.Null();
1708
+ }
1709
+ }
1710
+ }
1711
+
1712
+ auto deferred = Napi::Promise::Deferred::New(env);
1713
+ auto promise = deferred.Promise();
1714
+ auto* w = new FindWhatWritesWorker(env, std::move(deferred), pid, addr, size,
1715
+ timeout, maxHits);
1716
+ w->Queue();
1717
+ return promise;
1718
+ }
1719
+
1720
+ } // namespace mp