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": [ - " ({ + 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 #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().DoubleValue(); + if (!(pid >= 1 && pid <= MAXDWORD) || pid != static_cast(pid)) { + return env.Undefined(); + } + ProcessInfo pinfo{}; + pinfo.pid = static_cast(pid); + GetProcessCreationTime(pinfo); + if (pinfo.creationTimeMs == 0) { + return env.Undefined(); + } + return Napi::Number::New(env, static_cast(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& 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 #include -#include +#include -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(0xC0000004L); +const NTSTATUS kStatusBufferTooSmall = static_cast(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( + 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(wide_length); + int charcount = WideCharToMultiByte(CP_UTF8, 0, data, length, NULL, 0, NULL, NULL); + if (!charcount) { return false; } + process_info.commandLine.resize(static_cast(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( - 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 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(buffer.length()); - int charcount = WideCharToMultiByte(CP_UTF8, 0, buffer.data(), wide_length, - NULL, 0, NULL, NULL); - if (charcount) { - process_info.commandLine.resize(static_cast(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(&buffer[0]); + const unsigned char* begin = &buffer[0]; + const unsigned char* end = begin + buffer.size(); + const unsigned char* chars = reinterpret_cast(command_line->Buffer); + if (chars == nullptr || chars < begin + sizeof(UNICODE_STRING) || chars > end || + command_line->Length > static_cast(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 +#include +#include + +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().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(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 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 attribute_storage(attribute_size); + auto* attribute_list = + reinterpret_cast(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(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::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(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[]; }