4ef8d20578
Setting lpDesktop on STARTUPINFO forces the child to open that desktop; the LogonUser-derived token in SpecificUser mode usually cannot, since winsta0\default's DACL only grants the currently-logged-in user. The result was STATUS_DLL_INIT_FAILED (exit 0xC0000142) with empty stdio. Only InteractiveUser mode needs the explicit interactive desktop - that whole point of the mode is to land in the user's session. For SpecificUser, leaving lpDesktop null lets the child inherit our service desktop, which works for headless batch tasks (AD reads, file ops, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
333 lines
13 KiB
C#
333 lines
13 KiB
C#
using System.Runtime.InteropServices;
|
|
using System.Runtime.Versioning;
|
|
using System.Text;
|
|
using Microsoft.Win32.SafeHandles;
|
|
using static WebhookServer.Core.Execution.Native.NativeMethods;
|
|
|
|
namespace WebhookServer.Core.Execution.Native;
|
|
|
|
/// <summary>
|
|
/// Launches a child process inside the active console session under whoever is
|
|
/// logged in at the keyboard. Required when running as SYSTEM (the service account)
|
|
/// because <c>Process.Start</c> would land the child in Session 0 where it can't
|
|
/// show UI on the user's desktop.
|
|
///
|
|
/// Caller must already be SYSTEM (the service runs as SYSTEM by default) — only
|
|
/// SYSTEM can call <c>WTSQueryUserToken</c>.
|
|
/// </summary>
|
|
[SupportedOSPlatform("windows")]
|
|
internal static class InteractiveProcessLauncher
|
|
{
|
|
public sealed class LaunchOptions
|
|
{
|
|
public required string FileName { get; init; }
|
|
public required IReadOnlyList<string> Arguments { get; init; }
|
|
public string? WorkingDirectory { get; init; }
|
|
public IReadOnlyDictionary<string, string>? ExtraEnvVars { get; init; }
|
|
public byte[]? StdinBytes { get; init; }
|
|
}
|
|
|
|
public sealed class LaunchResult : IDisposable
|
|
{
|
|
public required IntPtr ProcessHandle { get; init; }
|
|
public required uint ProcessId { get; init; }
|
|
public required StreamReader Stdout { get; init; }
|
|
public required StreamReader Stderr { get; init; }
|
|
|
|
public void Dispose()
|
|
{
|
|
try { Stdout.Dispose(); } catch { }
|
|
try { Stderr.Dispose(); } catch { }
|
|
if (ProcessHandle != IntPtr.Zero)
|
|
try { CloseHandle(ProcessHandle); } catch { }
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launch into the session of whoever is logged in at the keyboard. Lets hooks
|
|
/// pop UI on the user's desktop. Caller must be SYSTEM (only SYSTEM can call
|
|
/// WTSQueryUserToken).
|
|
/// </summary>
|
|
public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts)
|
|
{
|
|
var sessionId = WTSGetActiveConsoleSessionId();
|
|
if (sessionId == INVALID_SESSION_ID)
|
|
throw new InvalidOperationException("No active console session - is anyone logged in at the keyboard?");
|
|
|
|
if (!WTSQueryUserToken(sessionId, out var userToken))
|
|
throw LastError("WTSQueryUserToken (must run as SYSTEM)");
|
|
|
|
try { return LaunchWithToken(userToken, opts, useInteractiveDesktop: true); }
|
|
finally { CloseHandle(userToken); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Launch under a username/password by calling LogonUser to obtain a token.
|
|
/// Used instead of psi.UserName/Password because CreateProcessWithLogonW (what
|
|
/// .NET uses under the hood) refuses to run when the caller is SYSTEM.
|
|
/// Tries interactive logon first, then batch.
|
|
/// </summary>
|
|
public static LaunchResult LaunchAsSpecificUser(string username, string password, string? domain, LaunchOptions opts)
|
|
{
|
|
var resolvedDomain = NormalizeDomain(domain);
|
|
IntPtr token;
|
|
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_INTERACTIVE, LOGON32_PROVIDER_DEFAULT, out token))
|
|
{
|
|
if (!LogonUser(username, resolvedDomain, password, LOGON32_LOGON_BATCH, LOGON32_PROVIDER_DEFAULT, out token))
|
|
{
|
|
var who = string.IsNullOrEmpty(domain) ? username : $"{domain}\\{username}";
|
|
throw LastError($"LogonUser ({who})");
|
|
}
|
|
}
|
|
|
|
try { return LaunchWithToken(token, opts, useInteractiveDesktop: false); }
|
|
finally { CloseHandle(token); }
|
|
}
|
|
|
|
private static string? NormalizeDomain(string? domain)
|
|
{
|
|
if (string.IsNullOrEmpty(domain)) return null;
|
|
// "." is a common shorthand for "this machine"; LogonUser wants the actual
|
|
// machine name or null for local accounts.
|
|
if (domain == ".") return Environment.MachineName;
|
|
return domain;
|
|
}
|
|
|
|
private static LaunchResult LaunchWithToken(IntPtr sourceToken, LaunchOptions opts, bool useInteractiveDesktop)
|
|
{
|
|
IntPtr primaryToken = IntPtr.Zero;
|
|
IntPtr envBlock = IntPtr.Zero;
|
|
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
|
|
IntPtr stderrRead = IntPtr.Zero, stderrWrite = IntPtr.Zero;
|
|
IntPtr stdinRead = IntPtr.Zero, stdinWrite = IntPtr.Zero;
|
|
var pi = new PROCESS_INFORMATION();
|
|
bool succeeded = false;
|
|
|
|
try
|
|
{
|
|
if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
|
|
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
|
|
TOKEN_TYPE.TokenPrimary, out primaryToken))
|
|
throw LastError("DuplicateTokenEx");
|
|
|
|
if (!CreateEnvironmentBlock(out envBlock, primaryToken, false))
|
|
throw LastError("CreateEnvironmentBlock");
|
|
|
|
if (opts.ExtraEnvVars is { Count: > 0 })
|
|
envBlock = AppendEnvVars(envBlock, opts.ExtraEnvVars);
|
|
|
|
CreateInheritablePipe(out stdoutRead, out stdoutWrite, parentReads: true);
|
|
CreateInheritablePipe(out stderrRead, out stderrWrite, parentReads: true);
|
|
CreateInheritablePipe(out stdinRead, out stdinWrite, parentReads: false);
|
|
|
|
var si = new STARTUPINFO
|
|
{
|
|
cb = Marshal.SizeOf<STARTUPINFO>(),
|
|
dwFlags = STARTF_USESTDHANDLES,
|
|
hStdInput = stdinRead,
|
|
hStdOutput = stdoutWrite,
|
|
hStdError = stderrWrite,
|
|
// For InteractiveUser we explicitly target the logged-in user's desktop.
|
|
// For SpecificUser the LogonUser-derived token typically can't open that
|
|
// DACL; leave lpDesktop null and let the new process inherit ours.
|
|
lpDesktop = useInteractiveDesktop ? @"winsta0\default" : null,
|
|
};
|
|
|
|
var commandLine = BuildCommandLine(opts.FileName, opts.Arguments);
|
|
|
|
if (!CreateProcessAsUser(
|
|
primaryToken,
|
|
null,
|
|
commandLine,
|
|
IntPtr.Zero, IntPtr.Zero,
|
|
bInheritHandles: true,
|
|
CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW | NORMAL_PRIORITY_CLASS,
|
|
envBlock,
|
|
string.IsNullOrEmpty(opts.WorkingDirectory) ? null : opts.WorkingDirectory,
|
|
ref si,
|
|
out pi))
|
|
{
|
|
throw LastError("CreateProcessAsUser");
|
|
}
|
|
|
|
// Close child-side handles in our process so EOF propagates when the child exits.
|
|
CloseHandle(stdoutWrite); stdoutWrite = IntPtr.Zero;
|
|
CloseHandle(stderrWrite); stderrWrite = IntPtr.Zero;
|
|
CloseHandle(stdinRead); stdinRead = IntPtr.Zero;
|
|
|
|
// Pipe stdin if provided, then close the write end.
|
|
if (opts.StdinBytes is { Length: > 0 })
|
|
{
|
|
using var stdinStream = new FileStream(new SafeFileHandle(stdinWrite, ownsHandle: true), FileAccess.Write);
|
|
stdinStream.Write(opts.StdinBytes, 0, opts.StdinBytes.Length);
|
|
stdinStream.Flush();
|
|
stdinWrite = IntPtr.Zero; // ownership transferred to the FileStream
|
|
}
|
|
else
|
|
{
|
|
CloseHandle(stdinWrite); stdinWrite = IntPtr.Zero;
|
|
}
|
|
|
|
CloseHandle(pi.hThread);
|
|
|
|
var stdout = new StreamReader(new FileStream(new SafeFileHandle(stdoutRead, true), FileAccess.Read), Encoding.UTF8);
|
|
var stderr = new StreamReader(new FileStream(new SafeFileHandle(stderrRead, true), FileAccess.Read), Encoding.UTF8);
|
|
stdoutRead = IntPtr.Zero;
|
|
stderrRead = IntPtr.Zero;
|
|
|
|
succeeded = true;
|
|
return new LaunchResult
|
|
{
|
|
ProcessHandle = pi.hProcess,
|
|
ProcessId = pi.dwProcessId,
|
|
Stdout = stdout,
|
|
Stderr = stderr,
|
|
};
|
|
}
|
|
finally
|
|
{
|
|
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
|
|
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
|
|
if (!succeeded)
|
|
{
|
|
if (stdoutRead != IntPtr.Zero) CloseHandle(stdoutRead);
|
|
if (stdoutWrite != IntPtr.Zero) CloseHandle(stdoutWrite);
|
|
if (stderrRead != IntPtr.Zero) CloseHandle(stderrRead);
|
|
if (stderrWrite != IntPtr.Zero) CloseHandle(stderrWrite);
|
|
if (stdinRead != IntPtr.Zero) CloseHandle(stdinRead);
|
|
if (stdinWrite != IntPtr.Zero) CloseHandle(stdinWrite);
|
|
if (pi.hProcess != IntPtr.Zero) CloseHandle(pi.hProcess);
|
|
if (pi.hThread != IntPtr.Zero) CloseHandle(pi.hThread);
|
|
}
|
|
}
|
|
}
|
|
|
|
public static async Task<int> WaitAsync(IntPtr processHandle, TimeSpan timeout, CancellationToken ct)
|
|
{
|
|
// Poll WaitForSingleObject in 100ms slices to honor cancellation/timeout cheaply.
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (true)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
var remaining = deadline - DateTime.UtcNow;
|
|
if (remaining <= TimeSpan.Zero) throw new TimeoutException();
|
|
|
|
var slice = (uint)Math.Min(100, (int)remaining.TotalMilliseconds);
|
|
var result = WaitForSingleObject(processHandle, slice);
|
|
if (result == 0) // WAIT_OBJECT_0
|
|
{
|
|
if (!GetExitCodeProcess(processHandle, out var code))
|
|
throw LastError("GetExitCodeProcess");
|
|
return (int)code;
|
|
}
|
|
if (result == 0xFFFFFFFF)
|
|
throw LastError("WaitForSingleObject");
|
|
await Task.Yield();
|
|
}
|
|
}
|
|
|
|
public static void Kill(IntPtr processHandle)
|
|
{
|
|
try { TerminateProcess(processHandle, 1); } catch { }
|
|
}
|
|
|
|
private static void CreateInheritablePipe(out IntPtr read, out IntPtr write, bool parentReads)
|
|
{
|
|
var sa = new SECURITY_ATTRIBUTES
|
|
{
|
|
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
|
|
bInheritHandle = 1,
|
|
lpSecurityDescriptor = IntPtr.Zero,
|
|
};
|
|
if (!CreatePipe(out read, out write, ref sa, 0))
|
|
throw LastError("CreatePipe");
|
|
|
|
// Mark the parent's end non-inheritable so it's not duplicated into the child.
|
|
var parentEnd = parentReads ? read : write;
|
|
if (!SetHandleInformation(parentEnd, HANDLE_FLAG_INHERIT, 0))
|
|
throw LastError("SetHandleInformation");
|
|
}
|
|
|
|
private static IntPtr AppendEnvVars(IntPtr existingBlock, IReadOnlyDictionary<string, string> extras)
|
|
{
|
|
// The existing block is a sequence of null-terminated WCHAR strings ending with
|
|
// an extra null. Walk it byte-pair by byte-pair to find the end.
|
|
var parsed = new List<string>();
|
|
var offset = 0;
|
|
while (true)
|
|
{
|
|
var ch0 = Marshal.ReadInt16(existingBlock, offset);
|
|
if (ch0 == 0) break;
|
|
var entry = Marshal.PtrToStringUni(existingBlock + offset)!;
|
|
parsed.Add(entry);
|
|
offset += (entry.Length + 1) * sizeof(char);
|
|
}
|
|
DestroyEnvironmentBlock(existingBlock);
|
|
|
|
var combined = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var entry in parsed)
|
|
{
|
|
var eq = entry.IndexOf('=');
|
|
if (eq <= 0) continue;
|
|
combined[entry.Substring(0, eq)] = entry.Substring(eq + 1);
|
|
}
|
|
foreach (var (k, v) in extras) combined[k] = v;
|
|
|
|
var sb = new StringBuilder();
|
|
foreach (var (k, v) in combined)
|
|
{
|
|
sb.Append(k).Append('=').Append(v).Append('\0');
|
|
}
|
|
sb.Append('\0');
|
|
|
|
var bytes = Encoding.Unicode.GetBytes(sb.ToString());
|
|
var ptr = Marshal.AllocHGlobal(bytes.Length);
|
|
Marshal.Copy(bytes, 0, ptr, bytes.Length);
|
|
return ptr;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Build a Windows command line string from a filename + arg list using the
|
|
/// quoting rules consumed by CommandLineToArgvW.
|
|
/// </summary>
|
|
private static string BuildCommandLine(string fileName, IReadOnlyList<string> args)
|
|
{
|
|
var sb = new StringBuilder();
|
|
AppendArg(sb, fileName);
|
|
foreach (var arg in args)
|
|
{
|
|
sb.Append(' ');
|
|
AppendArg(sb, arg);
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static void AppendArg(StringBuilder sb, string arg)
|
|
{
|
|
// Empty arg → "".
|
|
if (arg.Length == 0) { sb.Append("\"\""); return; }
|
|
|
|
var needsQuoting = arg.IndexOfAny(new[] { ' ', '\t', '\n', '\v', '"' }) >= 0;
|
|
if (!needsQuoting) { sb.Append(arg); return; }
|
|
|
|
sb.Append('"');
|
|
var backslashes = 0;
|
|
foreach (var ch in arg)
|
|
{
|
|
if (ch == '\\') { backslashes++; continue; }
|
|
if (ch == '"')
|
|
{
|
|
sb.Append('\\', backslashes * 2 + 1);
|
|
sb.Append('"');
|
|
backslashes = 0;
|
|
continue;
|
|
}
|
|
if (backslashes > 0) { sb.Append('\\', backslashes); backslashes = 0; }
|
|
sb.Append(ch);
|
|
}
|
|
// Trailing backslashes before closing quote must be doubled.
|
|
if (backslashes > 0) sb.Append('\\', backslashes * 2);
|
|
sb.Append('"');
|
|
}
|
|
}
|