Per-endpoint RunAs: Service / InteractiveUser / SpecificUser

Native per-endpoint identity instead of the schtasks bridge:
- Service (default) keeps the existing path - hooks inherit the service
  account (SYSTEM by default, or whatever you installed under).
- SpecificUser binds ProcessStartInfo.UserName / Password / Domain so
  the hook runs in a batch logon session as the named account. Useful
  for AD-write hooks that should NOT run as SYSTEM.
- InteractiveUser uses WTSQueryUserToken(WTSGetActiveConsoleSessionId)
  + DuplicateTokenEx + CreateProcessAsUser to drop the child into the
  logged-in user's session with their environment block. This is the
  real fix for "calc.exe should pop up on my desktop" - no Task
  Scheduler bridge required. Stdio is captured via inheritable
  anonymous pipes so the hook still returns stdout/stderr to the
  caller normally.

Implementation:
- New RunAsMode enum + RunAsConfig model on EndpointConfig
- ConfigStore round-trips RunAs.Password through DPAPI alongside
  bearer/HMAC/PFX secrets
- AdminPipeServer's secret-merge logic preserves the encrypted blob
  when the GUI saves an endpoint without re-typing the password
- New WebhookServer.Core.Execution.Native namespace with NativeMethods
  (P/Invoke) and InteractiveProcessLauncher (token-based launcher)
- ProcessExecutor branches on RunAs.Mode; the Service/SpecificUser
  paths share .NET's Process; InteractiveUser uses the launcher
- GUI editor gets a "Run as" section: dropdown + conditional
  username/password/load-profile fields under SpecificUser

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:08:08 -04:00
parent 27e5264714
commit 24d8701b65
10 changed files with 701 additions and 20 deletions
@@ -0,0 +1,287 @@
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 { }
}
}
public static LaunchResult Launch(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)");
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(userToken, (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,
lpDesktop = @"winsta0\default",
};
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 (userToken != IntPtr.Zero) CloseHandle(userToken);
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('"');
}
}