mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* fix(ssh): launch the Windows relay outside sshd's job without WMI Win32-OpenSSH kills a session's job on close but allows breakaway. relay.js gains a one-shot launcher mode that starts the detached relay with CREATE_BREAKAWAY_FROM_JOB through the staged process-tree addon, so a standard user no longer needs a WMI Remote Enable grant. WMI stays as the fallback for a relay without the addon, and a refusal there is named. The Windows SSH-host lanes drop their WMI grant and assert the breakaway route and adoption. * fix(ssh): find runtime holds without WMI on a standard-user Windows host The store GC read held runtimes through Get-CimInstance Win32_Process, which WMI refuses to a standard user's SSH logon, so the pass kept every runtime. On a refusal it now reads this account's own process image paths through Get-Process. * build(relay): ship the Windows relay launcher addon in every desktop package macOS and Linux packages carried Windows relays without windows-process-tree.node, so a legacy-runtime relay they uploaded to a Windows SSH host could not launch outside sshd's job and fell back to WMI, which a standard user is refused. A reusable Windows job now compiles the x64 and arm64 addons once and uploads them; release-cut, release-mac-build, and the hourly/daily/adhoc mac builds download them before build:release and require both arches. Staging now rejects a binary with the wrong PE machine, the ReadProcessMemory import, or no spawnOutsideJob export, so a stale pre-launcher build cannot ship. * ci(ssh): run the Windows SSH-host lanes when the relay process-tree build scripts change The staging and gyp-rebuild scripts decide which windows-process-tree addon the relay ships, so a change to either must re-prove the Windows host cells. * test(ci): find the mac orcad-template download by artifact name The release mac job now also downloads the relay Windows process-tree addons, so the first download-artifact step is no longer the template's. --------- Co-authored-by: m4air <m4air@m4airs-Air.localdomain>
641 lines
24 KiB
Diff
641 lines
24 KiB
Diff
diff --git a/binding.gyp b/binding.gyp
|
|
index 11a5e71..1b2e74b 100644
|
|
--- a/binding.gyp
|
|
+++ b/binding.gyp
|
|
@@ -3,7 +3,6 @@
|
|
{
|
|
"target_name": "windows_process_tree",
|
|
"dependencies": [
|
|
- "<!(node -p \"require('node-addon-api').targets\"):node_addon_api_except",
|
|
],
|
|
"conditions": [
|
|
['OS=="win"', {
|
|
@@ -12,15 +11,15 @@
|
|
"src/cpu_worker.cc",
|
|
"src/process.cc",
|
|
"src/process_worker.cc",
|
|
- "src/process_commandline.cc"
|
|
+ "src/process_commandline.cc",
|
|
+ "src/process_launch.cc"
|
|
],
|
|
- "include_dirs": [],
|
|
+ "include_dirs": ["deps/node-addon-api"],
|
|
+ "defines": ["NAPI_CPP_EXCEPTIONS", "_HAS_EXCEPTIONS=1"],
|
|
"libraries": [ 'psapi.lib' ],
|
|
- "msvs_configuration_attributes": {
|
|
- "SpectreMitigation": "Spectre"
|
|
- },
|
|
"msvs_settings": {
|
|
"VCCLCompilerTool": {
|
|
+ "ExceptionHandling": 1,
|
|
"AdditionalOptions": [
|
|
"/guard:cf",
|
|
"/sdl",
|
|
diff --git a/lib/index.js b/lib/index.js
|
|
index e586cd6..0ee62c3 100644
|
|
--- a/lib/index.js
|
|
+++ b/lib/index.js
|
|
@@ -7,11 +7,14 @@
|
|
exports.getAllProcesses = exports.getProcessTree = exports.getProcessCpuUsage = exports.getProcessList = exports.filterProcessList = exports.buildProcessTree = exports.ProcessDataFlag = void 0;
|
|
const util_1 = require("util");
|
|
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
|
|
+exports.getProcessCreationTime = native === undefined ? undefined : native.getProcessCreationTime;
|
|
+exports.supportedProcessDataFlags = native === undefined ? undefined : native.supportedProcessDataFlags;
|
|
var ProcessDataFlag;
|
|
(function (ProcessDataFlag) {
|
|
ProcessDataFlag[ProcessDataFlag["None"] = 0] = "None";
|
|
ProcessDataFlag[ProcessDataFlag["Memory"] = 1] = "Memory";
|
|
ProcessDataFlag[ProcessDataFlag["CommandLine"] = 2] = "CommandLine";
|
|
+ ProcessDataFlag[ProcessDataFlag["CreationTime"] = 4] = "CreationTime";
|
|
})(ProcessDataFlag = exports.ProcessDataFlag || (exports.ProcessDataFlag = {}));
|
|
// requestInProgress is used for any function that uses CreateToolhelp32Snapshot, as multiple calls
|
|
// to this cannot be done at the same time.
|
|
@@ -66,11 +69,12 @@
|
|
// • the properties are inlined/splatted
|
|
// • the 'ppid' field is omitted
|
|
// • the depth of the tree is limited by `maxDepth`
|
|
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }, depth) => ({
|
|
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }, depth) => ({
|
|
pid,
|
|
name,
|
|
memory,
|
|
commandLine,
|
|
+ creationTimeMs,
|
|
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
|
|
});
|
|
return buildNode(root, maxDepth);
|
|
diff --git a/lib/index.ts b/lib/index.ts
|
|
index 7b53aad..284dae1 100644
|
|
--- a/lib/index.ts
|
|
+++ b/lib/index.ts
|
|
@@ -6,12 +6,16 @@
|
|
import { promisify } from 'util';
|
|
|
|
const native = process.platform === 'win32' ? require('../build/Release/windows_process_tree.node') : undefined;
|
|
+export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined = native?.getProcessCreationTime;
|
|
+/** The flag bits this compiled addon reports; undefined off win32. */
|
|
+export const supportedProcessDataFlags: number | undefined = native?.supportedProcessDataFlags;
|
|
import { IProcessInfo, IProcessTreeNode, IProcessCpuInfo } from '@vscode/windows-process-tree';
|
|
|
|
export enum ProcessDataFlag {
|
|
None = 0,
|
|
Memory = 1,
|
|
- CommandLine = 2
|
|
+ CommandLine = 2,
|
|
+ CreationTime = 4
|
|
}
|
|
|
|
type RequestCallback = (processList: IProcessInfo[]) => void;
|
|
@@ -81,11 +85,12 @@
|
|
// • the properties are inlined/splatted
|
|
// • the 'ppid' field is omitted
|
|
// • the depth of the tree is limited by `maxDepth`
|
|
- const buildNode = ({ info: { pid, name, memory, commandLine }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
|
|
+ const buildNode = ({ info: { pid, name, memory, commandLine, creationTimeMs }, children }: IProcessInfoNode, depth: number): IProcessTreeNode => ({
|
|
pid,
|
|
name,
|
|
memory,
|
|
commandLine,
|
|
+ creationTimeMs,
|
|
children: depth > 0 ? children.map(c => buildNode(c, depth - 1)) : [],
|
|
});
|
|
|
|
diff --git a/src/addon.cc b/src/addon.cc
|
|
index 5253960..f146490 100644
|
|
--- a/src/addon.cc
|
|
+++ b/src/addon.cc
|
|
@@ -6,6 +6,7 @@
|
|
#include <napi.h>
|
|
#include "cpu_worker.h"
|
|
#include "process_worker.h"
|
|
+#include "process_launch.h"
|
|
|
|
void GetProcessList(const Napi::CallbackInfo& args) {
|
|
Napi::Env env(args.Env());
|
|
@@ -50,9 +51,33 @@
|
|
worker->Queue();
|
|
}
|
|
|
|
+Napi::Value ReadProcessCreationTime(const Napi::CallbackInfo& args) {
|
|
+ Napi::Env env(args.Env());
|
|
+ if (args.Length() != 1 || !args[0].IsNumber()) {
|
|
+ return env.Undefined();
|
|
+ }
|
|
+ const double pid = args[0].As<Napi::Number>().DoubleValue();
|
|
+ if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast<DWORD>(pid)) {
|
|
+ return env.Undefined();
|
|
+ }
|
|
+ ProcessInfo pinfo{};
|
|
+ pinfo.pid = static_cast<DWORD>(pid);
|
|
+ GetProcessCreationTime(pinfo);
|
|
+ if (pinfo.creationTimeMs == 0) {
|
|
+ return env.Undefined();
|
|
+ }
|
|
+ return Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs));
|
|
+}
|
|
+
|
|
Napi::Object Init(Napi::Env env, Napi::Object exports) {
|
|
+ exports.Set("getProcessCreationTime", Napi::Function::New(env, ReadProcessCreationTime));
|
|
exports.Set("getProcessList", Napi::Function::New(env, GetProcessList));
|
|
exports.Set("getProcessCpuUsage", Napi::Function::New(env, GetProcessCpuUsage));
|
|
+ exports.Set("spawnOutsideJob", Napi::Function::New(env, SpawnOutsideJob));
|
|
+ // Lets a caller prove THIS BINARY understands CREATIONTIME. The JS enum is
|
|
+ // patched source and says nothing about what the .node was compiled from.
|
|
+ exports.Set("supportedProcessDataFlags",
|
|
+ Napi::Number::New(env, MEMORY | COMMANDLINE | CREATIONTIME));
|
|
return exports;
|
|
}
|
|
|
|
diff --git a/src/process.cc b/src/process.cc
|
|
index ad63727..22a4742 100644
|
|
--- a/src/process.cc
|
|
+++ b/src/process.cc
|
|
@@ -21,7 +21,8 @@
|
|
if (Process32First(snapshot_handle, &process_entry)) {
|
|
do {
|
|
if (process_entry.th32ProcessID != 0) {
|
|
- ProcessInfo pinfo;
|
|
+ // Value-initialize: `memory` is otherwise stack garbage when the flag is unset.
|
|
+ ProcessInfo pinfo{};
|
|
pinfo.pid = process_entry.th32ProcessID;
|
|
pinfo.ppid = process_entry.th32ParentProcessID;
|
|
|
|
@@ -33,23 +34,51 @@
|
|
GetProcessCommandLine(pinfo);
|
|
}
|
|
|
|
+ if (CREATIONTIME & process_data_flags) {
|
|
+ GetProcessCreationTime(pinfo);
|
|
+ }
|
|
+
|
|
strcpy(pinfo.name, process_entry.szExeFile);
|
|
process_info.push_back(std::move(pinfo));
|
|
process_count++;
|
|
}
|
|
- } while (process_count < 1024 && Process32Next(snapshot_handle, &process_entry));
|
|
+ } while (Process32Next(snapshot_handle, &process_entry));
|
|
}
|
|
|
|
CloseHandle(snapshot_handle);
|
|
return process_count;
|
|
}
|
|
|
|
+void GetProcessCreationTime(ProcessInfo& process_info) {
|
|
+ HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, process_info.pid);
|
|
+ if (hProcess == NULL) {
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ FILETIME creationTime, exitTime, kernelTime, userTime;
|
|
+ if (GetProcessTimes(hProcess, &creationTime, &exitTime, &kernelTime, &userTime)) {
|
|
+ ULARGE_INTEGER timestamp;
|
|
+ timestamp.LowPart = creationTime.dwLowDateTime;
|
|
+ timestamp.HighPart = creationTime.dwHighDateTime;
|
|
+ constexpr ULONGLONG WINDOWS_EPOCH_OFFSET_100NS = 116444736000000000ULL;
|
|
+ constexpr ULONGLONG HUNDRED_NS_PER_MILLISECOND = 10000ULL;
|
|
+ if (timestamp.QuadPart >= WINDOWS_EPOCH_OFFSET_100NS) {
|
|
+ process_info.creationTimeMs =
|
|
+ (timestamp.QuadPart - WINDOWS_EPOCH_OFFSET_100NS) / HUNDRED_NS_PER_MILLISECOND;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ CloseHandle(hProcess);
|
|
+}
|
|
+
|
|
void GetProcessMemoryUsage(ProcessInfo& process_info) {
|
|
DWORD pid = process_info.pid;
|
|
HANDLE hProcess;
|
|
PROCESS_MEMORY_COUNTERS pmc;
|
|
|
|
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
|
+ // PROCESS_VM_READ is never used here -- GetProcessMemoryInfo reads counters the
|
|
+ // kernel keeps, not the address space -- and acquiring it is what EDR scores.
|
|
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
|
|
|
if (hProcess == NULL) {
|
|
return;
|
|
@@ -81,7 +110,8 @@
|
|
DWORD pid = cpu_info.pid;
|
|
HANDLE hProcess;
|
|
|
|
- hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, false, pid);
|
|
+ // GetProcessTimes needs no more than PROCESS_QUERY_LIMITED_INFORMATION.
|
|
+ hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid);
|
|
|
|
if (hProcess == NULL) {
|
|
return;
|
|
diff --git a/src/process.h b/src/process.h
|
|
index 3c9b852..72e1648 100644
|
|
--- a/src/process.h
|
|
+++ b/src/process.h
|
|
@@ -22,18 +22,22 @@
|
|
DWORD ppid;
|
|
DWORD memory; // Reported in bytes
|
|
std::string commandLine;
|
|
+ ULONGLONG creationTimeMs;
|
|
};
|
|
|
|
enum ProcessDataFlags {
|
|
NONE = 0,
|
|
MEMORY = 1,
|
|
- COMMANDLINE = 2
|
|
+ COMMANDLINE = 2,
|
|
+ CREATIONTIME = 4
|
|
};
|
|
|
|
uint32_t GetRawProcessList(std::vector<ProcessInfo>& process_info, DWORD flags);
|
|
|
|
void GetProcessMemoryUsage(ProcessInfo& process_info);
|
|
|
|
+void GetProcessCreationTime(ProcessInfo& process_info);
|
|
+
|
|
void GetCpuUsage(Cpu& cpu_info, bool first_run);
|
|
|
|
#endif // SRC_PROCESS_H_
|
|
diff --git a/src/process_commandline.cc b/src/process_commandline.cc
|
|
index 716051d..25907c0 100644
|
|
--- a/src/process_commandline.cc
|
|
+++ b/src/process_commandline.cc
|
|
@@ -7,61 +7,119 @@
|
|
#include "process_commandline.h"
|
|
#include <windows.h>
|
|
#include <winternl.h>
|
|
-#include <iostream>
|
|
+#include <vector>
|
|
|
|
-bool GetProcessCommandLine(ProcessInfo& process_info) {
|
|
- HINSTANCE ntdll = GetModuleHandleW(L"ntdll.dll");
|
|
+namespace {
|
|
+
|
|
+// Windows 8.1 and later hand back a process's command line as a UNICODE_STRING
|
|
+// the kernel builds, needing only PROCESS_QUERY_LIMITED_INFORMATION.
|
|
+//
|
|
+// There is deliberately no PEB fallback. Reading the command line out of the
|
|
+// target's address space -- opening it for VM reads and then chaining
|
|
+// memory reads across every pid on a timer -- is the credential-dumping
|
|
+// primitive this reader exists to not perform, so it is absent from the binary
|
|
+// rather than one anomalous NTSTATUS away. Electron's floor is Windows 10, so
|
|
+// every OS Orca supports has this class; if a hooked ntdll refuses it anyway,
|
|
+// the command line comes back empty, which callers already handle, instead of
|
|
+// silently reinstating the primitive on exactly the instrumented machines this
|
|
+// reader was written for.
|
|
+const ULONG kProcessCommandLineInformation = 60;
|
|
+
|
|
+const NTSTATUS kStatusInfoLengthMismatch = static_cast<NTSTATUS>(0xC0000004L);
|
|
+const NTSTATUS kStatusBufferTooSmall = static_cast<NTSTATUS>(0xC0000023L);
|
|
+
|
|
+// A command line is a UNICODE_STRING, whose Length is a USHORT, so the kernel
|
|
+// can never need more than the header plus 64 KiB. Refusing anything larger
|
|
+// keeps a bogus size from throwing bad_alloc out of a scan that has already
|
|
+// walked most of the table.
|
|
+const ULONG kMaxCommandLineBytes = sizeof(UNICODE_STRING) + 0xFFFF + sizeof(wchar_t);
|
|
+
|
|
+// winternl.h's PROCESSINFOCLASS does not name class 60 and its enumerator range
|
|
+// stops far short of it, so the class travels as a ULONG rather than a cast enum.
|
|
+typedef NTSTATUS(NTAPI* NtQueryInformationProcessFn)(HANDLE, ULONG, PVOID, ULONG, PULONG);
|
|
+
|
|
+// ntdll ships no import library for this entry point; it has to be resolved.
|
|
+NtQueryInformationProcessFn ResolveNtQueryInformationProcess() {
|
|
+ HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
|
if (!ntdll) {
|
|
+ return nullptr;
|
|
+ }
|
|
+ return reinterpret_cast<NtQueryInformationProcessFn>(
|
|
+ GetProcAddress(ntdll, "NtQueryInformationProcess"));
|
|
+}
|
|
+
|
|
+NtQueryInformationProcessFn NtQueryInformationProcessEntry() {
|
|
+ static NtQueryInformationProcessFn entry = ResolveNtQueryInformationProcess();
|
|
+ return entry;
|
|
+}
|
|
+
|
|
+bool StoreCommandLineUtf8(ProcessInfo& process_info, const wchar_t* data, size_t wide_length) {
|
|
+ if (wide_length == 0) {
|
|
+ return false;
|
|
+ }
|
|
+ int length = static_cast<int>(wide_length);
|
|
+ int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL);
|
|
+ if (!charcount) {
|
|
return false;
|
|
}
|
|
+ process_info.commandLine.resize(static_cast<size_t>(charcount));
|
|
+ WideCharToMultiByte(CP_UTF8, 0, data, length, &process_info.commandLine[0], charcount, NULL,
|
|
+ NULL);
|
|
+ return true;
|
|
+}
|
|
+
|
|
+} // namespace
|
|
|
|
- decltype(NtQueryInformationProcess)* nt_query_information_process =
|
|
- reinterpret_cast<decltype(NtQueryInformationProcess)*>(
|
|
- GetProcAddress(ntdll, "NtQueryInformationProcess"));
|
|
+bool GetProcessCommandLine(ProcessInfo& process_info) {
|
|
+ NtQueryInformationProcessFn query = NtQueryInformationProcessEntry();
|
|
+ if (!query) {
|
|
+ return false;
|
|
+ }
|
|
|
|
- if (!nt_query_information_process) {
|
|
+ HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_info.pid);
|
|
+ if (process == NULL) {
|
|
return false;
|
|
}
|
|
|
|
- PROCESS_BASIC_INFORMATION pbi{};
|
|
- PEB peb = {NULL};
|
|
- RTL_USER_PROCESS_PARAMETERS process_parameters = {NULL};
|
|
+ ULONG size = 0;
|
|
+ NTSTATUS status = query(process, kProcessCommandLineInformation, nullptr, 0, &size);
|
|
+ if (NT_SUCCESS(status)) {
|
|
+ // Nothing was written, so there is no command line to read.
|
|
+ CloseHandle(process);
|
|
+ return false;
|
|
+ }
|
|
+ if (status != kStatusInfoLengthMismatch && status != kStatusBufferTooSmall) {
|
|
+ CloseHandle(process);
|
|
+ return false;
|
|
+ }
|
|
+ if (size < sizeof(UNICODE_STRING) || size > kMaxCommandLineBytes) {
|
|
+ CloseHandle(process);
|
|
+ return false;
|
|
+ }
|
|
|
|
- // Get process handle
|
|
- DWORD pid = process_info.pid;
|
|
- HANDLE hProcess = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
|
|
- if (hProcess == INVALID_HANDLE_VALUE) {
|
|
+ std::vector<unsigned char> buffer(size);
|
|
+ status = query(process, kProcessCommandLineInformation, &buffer[0], size, &size);
|
|
+ CloseHandle(process);
|
|
+ if (!NT_SUCCESS(status)) {
|
|
return false;
|
|
}
|
|
|
|
- // Get Process Environment Block (PEB)
|
|
- NTSTATUS status = nt_query_information_process(hProcess, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
|
|
- if (NT_SUCCESS(status) && pbi.PebBaseAddress) {
|
|
- // Read PEB
|
|
- if (ReadProcessMemory(hProcess, pbi.PebBaseAddress, &peb, sizeof(peb), nullptr)) {
|
|
- // Read the processs parameters
|
|
- if (ReadProcessMemory(hProcess, peb.ProcessParameters, &process_parameters, sizeof(RTL_USER_PROCESS_PARAMETERS), nullptr)) {
|
|
- if (process_parameters.CommandLine.Length > 0) {
|
|
- std::wstring buffer;
|
|
- buffer.resize(process_parameters.CommandLine.Length / sizeof(wchar_t));
|
|
- if (ReadProcessMemory(hProcess, process_parameters.CommandLine.Buffer, &buffer[0], process_parameters.CommandLine.Length, nullptr)) {
|
|
- int wide_length = static_cast<int>(buffer.length());
|
|
- int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
|
- NULL, 0, NULL, NULL);
|
|
- if (charcount) {
|
|
- process_info.commandLine.resize(static_cast<size_t>(charcount));
|
|
- WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length,
|
|
- &process_info.commandLine[0], charcount,
|
|
- NULL, NULL);
|
|
- }
|
|
- CloseHandle(hProcess);
|
|
- return true;
|
|
- }
|
|
- }
|
|
- }
|
|
- }
|
|
+ // Header and characters arrive in one allocation, but treat the header as
|
|
+ // untrusted: a hooked ntdll is the case this reader is written for, and an
|
|
+ // unchecked Buffer/Length here would be an over-read encoded straight into JS.
|
|
+ // Bound against buffer.size(), never `size` -- the second query overwrote it.
|
|
+ const UNICODE_STRING* command_line = reinterpret_cast<const UNICODE_STRING*>(&buffer[0]);
|
|
+ const unsigned char* begin = &buffer[0];
|
|
+ const unsigned char* end = begin + buffer.size();
|
|
+ const unsigned char* chars = reinterpret_cast<const unsigned char*>(command_line->Buffer);
|
|
+ if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end ||
|
|
+ command_line->Length > static_cast<ULONG>(end - chars)) {
|
|
+ return false;
|
|
}
|
|
|
|
- CloseHandle(hProcess);
|
|
- return false;
|
|
+ // True only when a command line was actually stored, so "empty" and "not
|
|
+ // recovered" stay the same answer they were before this reader replaced the
|
|
+ // PEB read. `src/process.cc` discards the result either way.
|
|
+ return StoreCommandLineUtf8(process_info, command_line->Buffer,
|
|
+ command_line->Length / sizeof(wchar_t));
|
|
}
|
|
diff --git a/src/process_launch.cc b/src/process_launch.cc
|
|
new file mode 100644
|
|
index 0000000..e3141c5
|
|
--- /dev/null
|
|
+++ b/src/process_launch.cc
|
|
@@ -0,0 +1,140 @@
|
|
+// Orca: start a detached process outside the caller's job object.
|
|
+//
|
|
+// Win32-OpenSSH puts every session's shell in a job with KILL_ON_JOB_CLOSE, so
|
|
+// anything a session starts dies when the session ends. The same job carries
|
|
+// BREAKAWAY_OK, so CREATE_BREAKAWAY_FROM_JOB lets a standard user start a
|
|
+// process that outlives it -- which Node cannot ask for: libuv never passes it.
|
|
+
|
|
+#include "process_launch.h"
|
|
+
|
|
+#include <windows.h>
|
|
+#include <string>
|
|
+#include <vector>
|
|
+
|
|
+namespace {
|
|
+
|
|
+class OwnedHandle {
|
|
+ public:
|
|
+ explicit OwnedHandle(HANDLE handle) : handle_(handle) {}
|
|
+ ~OwnedHandle() {
|
|
+ if (valid()) {
|
|
+ CloseHandle(handle_);
|
|
+ }
|
|
+ }
|
|
+ OwnedHandle(const OwnedHandle&) = delete;
|
|
+ OwnedHandle& operator=(const OwnedHandle&) = delete;
|
|
+ bool valid() const { return handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE; }
|
|
+ HANDLE get() const { return handle_; }
|
|
+
|
|
+ private:
|
|
+ HANDLE handle_;
|
|
+};
|
|
+
|
|
+std::wstring ToWide(const Napi::Value& value) {
|
|
+ const std::u16string text = value.As<Napi::String>().Utf16Value();
|
|
+ return std::wstring(text.begin(), text.end());
|
|
+}
|
|
+
|
|
+HANDLE OpenInheritable(const std::wstring& path, DWORD access, DWORD disposition) {
|
|
+ SECURITY_ATTRIBUTES attributes{};
|
|
+ attributes.nLength = sizeof(attributes);
|
|
+ attributes.bInheritHandle = TRUE;
|
|
+ return CreateFileW(path.c_str(), access,
|
|
+ FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
|
+ &attributes, disposition, FILE_ATTRIBUTE_NORMAL, nullptr);
|
|
+}
|
|
+
|
|
+Napi::Object Failure(Napi::Env env, const char* reason, const char* step, DWORD code) {
|
|
+ Napi::Object result = Napi::Object::New(env);
|
|
+ result.Set("ok", false);
|
|
+ result.Set("reason", reason);
|
|
+ result.Set("step", step);
|
|
+ result.Set("code", static_cast<double>(code));
|
|
+ return result;
|
|
+}
|
|
+
|
|
+} // namespace
|
|
+
|
|
+// spawnOutsideJob(application, commandLine, cwd, stdoutPath, stderrPath)
|
|
+Napi::Value SpawnOutsideJob(const Napi::CallbackInfo& args) {
|
|
+ Napi::Env env(args.Env());
|
|
+ if (args.Length() != 5) {
|
|
+ throw Napi::TypeError::New(env, "spawnOutsideJob expects five string arguments.");
|
|
+ }
|
|
+ for (size_t index = 0; index < args.Length(); index++) {
|
|
+ if (!args[index].IsString()) {
|
|
+ throw Napi::TypeError::New(env, "spawnOutsideJob expects five string arguments.");
|
|
+ }
|
|
+ }
|
|
+ const std::wstring application = ToWide(args[0]);
|
|
+ const std::wstring command_line = ToWide(args[1]);
|
|
+ const std::wstring cwd = ToWide(args[2]);
|
|
+ // CreateProcessW may write into the command-line buffer.
|
|
+ std::vector<wchar_t> command_buffer(command_line.begin(), command_line.end());
|
|
+ command_buffer.push_back(L'\0');
|
|
+
|
|
+ OwnedHandle input(OpenInheritable(L"NUL", GENERIC_READ, OPEN_EXISTING));
|
|
+ if (!input.valid()) {
|
|
+ return Failure(env, "failed", "open-stdin", GetLastError());
|
|
+ }
|
|
+ OwnedHandle output(OpenInheritable(ToWide(args[3]), GENERIC_WRITE, CREATE_ALWAYS));
|
|
+ if (!output.valid()) {
|
|
+ return Failure(env, "failed", "open-stdout", GetLastError());
|
|
+ }
|
|
+ OwnedHandle error_output(OpenInheritable(ToWide(args[4]), GENERIC_WRITE, CREATE_ALWAYS));
|
|
+ if (!error_output.valid()) {
|
|
+ return Failure(env, "failed", "open-stderr", GetLastError());
|
|
+ }
|
|
+
|
|
+ // Inherit exactly these three: an inherited SSH channel pipe would hold the
|
|
+ // launching session open for the relay's whole life.
|
|
+ HANDLE inherited[3] = {input.get(), output.get(), error_output.get()};
|
|
+ SIZE_T attribute_size = 0;
|
|
+ InitializeProcThreadAttributeList(nullptr, 1, 0, &attribute_size);
|
|
+ std::vector<unsigned char> attribute_storage(attribute_size);
|
|
+ auto* attribute_list =
|
|
+ reinterpret_cast<LPPROC_THREAD_ATTRIBUTE_LIST>(attribute_storage.data());
|
|
+ if (!InitializeProcThreadAttributeList(attribute_list, 1, 0, &attribute_size)) {
|
|
+ return Failure(env, "failed", "attribute-list", GetLastError());
|
|
+ }
|
|
+ if (!UpdateProcThreadAttribute(attribute_list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
|
|
+ inherited, sizeof(inherited), nullptr, nullptr)) {
|
|
+ const DWORD code = GetLastError();
|
|
+ DeleteProcThreadAttributeList(attribute_list);
|
|
+ return Failure(env, "failed", "handle-list", code);
|
|
+ }
|
|
+
|
|
+ STARTUPINFOEXW startup{};
|
|
+ startup.StartupInfo.cb = sizeof(startup);
|
|
+ startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
|
+ startup.StartupInfo.hStdInput = input.get();
|
|
+ startup.StartupInfo.hStdOutput = output.get();
|
|
+ startup.StartupInfo.hStdError = error_output.get();
|
|
+ startup.lpAttributeList = attribute_list;
|
|
+ PROCESS_INFORMATION info{};
|
|
+ // CREATE_NO_WINDOW keeps one hidden console for the relay's console
|
|
+ // children, as the cmd.exe that WMI used to start it did.
|
|
+ const DWORD flags = CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP |
|
|
+ CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT;
|
|
+ const BOOL created = CreateProcessW(
|
|
+ application.c_str(), command_buffer.data(), nullptr, nullptr, TRUE, flags, nullptr,
|
|
+ cwd.empty() ? nullptr : cwd.c_str(), &startup.StartupInfo, &info);
|
|
+ const DWORD create_error = created ? ERROR_SUCCESS : GetLastError();
|
|
+ DeleteProcThreadAttributeList(attribute_list);
|
|
+ if (!created) {
|
|
+ BOOL caller_in_job = FALSE;
|
|
+ IsProcessInJob(GetCurrentProcess(), nullptr, &caller_in_job);
|
|
+ const bool denied = create_error == ERROR_ACCESS_DENIED && caller_in_job;
|
|
+ return Failure(env, denied ? "breakaway-denied" : "failed", "create-process", create_error);
|
|
+ }
|
|
+ BOOL child_in_job = FALSE;
|
|
+ IsProcessInJob(info.hProcess, nullptr, &child_in_job);
|
|
+ CloseHandle(info.hThread);
|
|
+ CloseHandle(info.hProcess);
|
|
+
|
|
+ Napi::Object result = Napi::Object::New(env);
|
|
+ result.Set("ok", true);
|
|
+ result.Set("pid", static_cast<double>(info.dwProcessId));
|
|
+ result.Set("inJob", child_in_job != FALSE);
|
|
+ return result;
|
|
+}
|
|
diff --git a/src/process_launch.h b/src/process_launch.h
|
|
new file mode 100644
|
|
index 0000000..88872a1
|
|
--- /dev/null
|
|
+++ b/src/process_launch.h
|
|
@@ -0,0 +1,10 @@
|
|
+// Orca: start a detached process outside the caller's job object.
|
|
+
|
|
+#ifndef SRC_PROCESS_LAUNCH_H_
|
|
+#define SRC_PROCESS_LAUNCH_H_
|
|
+
|
|
+#include <napi.h>
|
|
+
|
|
+Napi::Value SpawnOutsideJob(const Napi::CallbackInfo& args);
|
|
+
|
|
+#endif // SRC_PROCESS_LAUNCH_H_
|
|
diff --git a/src/process_worker.cc b/src/process_worker.cc
|
|
index f59559d..1d61c87 100644
|
|
--- a/src/process_worker.cc
|
|
+++ b/src/process_worker.cc
|
|
@@ -43,6 +43,11 @@
|
|
Napi::String::New(env, pinfo.commandLine));
|
|
}
|
|
|
|
+ if ((CREATIONTIME & process_data_flags_) && pinfo.creationTimeMs != 0) {
|
|
+ object.Set("creationTimeMs",
|
|
+ Napi::Number::New(env, static_cast<double>(pinfo.creationTimeMs)));
|
|
+ }
|
|
+
|
|
result.Set(i, object);
|
|
}
|
|
|
|
diff --git a/typings/windows-process-tree.d.ts b/typings/windows-process-tree.d.ts
|
|
index 70e242b..b1d0a53 100644
|
|
--- a/typings/windows-process-tree.d.ts
|
|
+++ b/typings/windows-process-tree.d.ts
|
|
@@ -7,9 +7,18 @@
|
|
export enum ProcessDataFlag {
|
|
None = 0,
|
|
Memory = 1,
|
|
- CommandLine = 2
|
|
+ CommandLine = 2,
|
|
+ CreationTime = 4
|
|
}
|
|
|
|
+ /**
|
|
+ * The flag bits the compiled addon actually understands, or undefined off
|
|
+ * win32. `ProcessDataFlag` above is source; this is what the binary reports,
|
|
+ * so it is the only way to tell a patched build from a stale prebuilt.
|
|
+ */
|
|
+ export const supportedProcessDataFlags: number | undefined;
|
|
+ export const getProcessCreationTime: ((pid: number) => number | undefined) | undefined;
|
|
+
|
|
export interface IProcessInfo {
|
|
pid: number;
|
|
ppid: number;
|
|
@@ -24,6 +33,9 @@
|
|
* The string returned is at most 512 chars, strings exceeding this length are truncated.
|
|
*/
|
|
commandLine?: string;
|
|
+
|
|
+ /** Process creation time in Unix milliseconds. */
|
|
+ creationTimeMs?: number;
|
|
}
|
|
|
|
export interface IProcessCpuInfo extends IProcessInfo {
|
|
@@ -35,6 +47,7 @@
|
|
name: string;
|
|
memory?: number;
|
|
commandLine?: string;
|
|
+ creationTimeMs?: number;
|
|
children: IProcessTreeNode[];
|
|
}
|
|
|