From 8b855ec9b984388c3da4d3449d29ff62b40c1bcd Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Fri, 8 May 2026 09:17:07 -0400 Subject: [PATCH] Route SpecificUser through LogonUser+CreateProcessAsUser, not psi.UserName CreateProcessWithLogonW (which ProcessStartInfo.UserName/Password uses under the hood) refuses to run when the caller is LocalSystem - which is exactly the scenario every hook hits, since the service runs as SYSTEM by default. The hook just got "Access is denied" with no useful context. Switch SpecificUser to the same LogonUser + DuplicateTokenEx + CreateProcessAsUser path that InteractiveUser already uses. The launcher tries LOGON32_LOGON_INTERACTIVE first, falling back to LOGON32_LOGON_BATCH for accounts without interactive-logon rights (typical for service-only users). Domain "." is normalized to the machine name so ".\justin" works. The launcher's two public entry points - LaunchAsActiveConsoleUser and LaunchAsSpecificUser - share the same LaunchWithToken core, so stdio capture and environment-block construction stay identical. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Native/InteractiveProcessLauncher.cs | 48 +++++++++++++- .../Execution/Native/NativeMethods.cs | 13 ++++ .../Execution/ProcessExecutor.cs | 64 ++++++++----------- 3 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs index 41275bc..09ce6c5 100644 --- a/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs +++ b/src/WebhookServer.Core/Execution/Native/InteractiveProcessLauncher.cs @@ -43,7 +43,12 @@ internal static class InteractiveProcessLauncher } } - public static LaunchResult Launch(LaunchOptions opts) + /// + /// 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). + /// + public static LaunchResult LaunchAsActiveConsoleUser(LaunchOptions opts) { var sessionId = WTSGetActiveConsoleSessionId(); if (sessionId == INVALID_SESSION_ID) @@ -52,6 +57,44 @@ internal static class InteractiveProcessLauncher if (!WTSQueryUserToken(sessionId, out var userToken)) throw LastError("WTSQueryUserToken (must run as SYSTEM)"); + try { return LaunchWithToken(userToken, opts); } + finally { CloseHandle(userToken); } + } + + /// + /// 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. + /// + 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); } + 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) + { IntPtr primaryToken = IntPtr.Zero; IntPtr envBlock = IntPtr.Zero; IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero; @@ -62,7 +105,7 @@ internal static class InteractiveProcessLauncher try { - if (!DuplicateTokenEx(userToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero, + if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero, SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, TOKEN_TYPE.TokenPrimary, out primaryToken)) throw LastError("DuplicateTokenEx"); @@ -140,7 +183,6 @@ internal static class InteractiveProcessLauncher } finally { - if (userToken != IntPtr.Zero) CloseHandle(userToken); if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken); if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock); if (!succeeded) diff --git a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs index b809674..ae52b72 100644 --- a/src/WebhookServer.Core/Execution/Native/NativeMethods.cs +++ b/src/WebhookServer.Core/Execution/Native/NativeMethods.cs @@ -79,6 +79,19 @@ internal static class NativeMethods public uint dwThreadId; } + public const int LOGON32_LOGON_INTERACTIVE = 2; + public const int LOGON32_LOGON_BATCH = 4; + public const int LOGON32_PROVIDER_DEFAULT = 0; + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + public static extern bool LogonUser( + string lpszUsername, + string? lpszDomain, + string lpszPassword, + int dwLogonType, + int dwLogonProvider, + out IntPtr phToken); + [DllImport("kernel32.dll")] public static extern uint WTSGetActiveConsoleSessionId(); diff --git a/src/WebhookServer.Core/Execution/ProcessExecutor.cs b/src/WebhookServer.Core/Execution/ProcessExecutor.cs index d627669..3d29aff 100644 --- a/src/WebhookServer.Core/Execution/ProcessExecutor.cs +++ b/src/WebhookServer.Core/Execution/ProcessExecutor.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Runtime.Versioning; -using System.Security; using System.Text; using WebhookServer.Core.Execution.Native; using WebhookServer.Core.Models; @@ -19,7 +18,8 @@ public sealed class ProcessExecutor : IExecutor var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service; return mode switch { - RunAsMode.InteractiveUser => await RunInteractiveAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false), + RunAsMode.InteractiveUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: true, ct).ConfigureAwait(false), + RunAsMode.SpecificUser => await RunWithLauncherAsync(endpoint, ctx, startedAt, useActiveConsole: false, ct).ConfigureAwait(false), _ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false), }; } @@ -32,12 +32,6 @@ public sealed class ProcessExecutor : IExecutor foreach (var (k, v) in envVars) psi.Environment[k] = v; - if (endpoint.RunAs?.Mode == RunAsMode.SpecificUser) - { - try { ApplySpecificUser(psi, endpoint.RunAs); } - catch (Exception ex) { return Failed(ctx.RunId, startedAt, ex.Message); } - } - using var process = new Process { StartInfo = psi, EnableRaisingEvents = true }; try @@ -107,27 +101,42 @@ public sealed class ProcessExecutor : IExecutor }; } - // ---------------- Interactive path: launches into the active console session. ---------------- + // ---------------- Token-based path: InteractiveUser + SpecificUser. ---------------- - private static async Task RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct) + private static async Task RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct) { var (psi, envVars) = BuildStartInfo(endpoint, ctx); + var opts = new InteractiveProcessLauncher.LaunchOptions + { + FileName = psi.FileName, + Arguments = psi.ArgumentList.ToList(), + WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, + ExtraEnvVars = envVars, + StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null, + }; InteractiveProcessLauncher.LaunchResult launch; try { - launch = InteractiveProcessLauncher.Launch(new InteractiveProcessLauncher.LaunchOptions + if (useActiveConsole) { - FileName = psi.FileName, - Arguments = psi.ArgumentList.ToList(), - WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, - ExtraEnvVars = envVars, - StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null, - }); + launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts); + } + else + { + var runAs = endpoint.RunAs ?? throw new InvalidOperationException("RunAs config missing"); + if (string.IsNullOrEmpty(runAs.Username)) + return Failed(ctx.RunId, startedAt, "RunAs.Username is required when Mode = SpecificUser"); + if (runAs.Password?.Plaintext is not { Length: > 0 } password) + return Failed(ctx.RunId, startedAt, "RunAs.Password is required when Mode = SpecificUser"); + + var (domain, user) = ParseUserSpec(runAs.Username); + launch = InteractiveProcessLauncher.LaunchAsSpecificUser(user, password, domain, opts); + } } catch (Exception ex) { - return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}"); + return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}"); } try @@ -261,25 +270,6 @@ public sealed class ProcessExecutor : IExecutor return endpoint.InlineCommand ?? ""; } - private static void ApplySpecificUser(ProcessStartInfo psi, RunAsConfig runAs) - { - if (string.IsNullOrEmpty(runAs.Username)) - throw new InvalidOperationException("RunAs.Username is required when Mode = SpecificUser"); - if (runAs.Password?.Plaintext is not { Length: > 0 } password) - throw new InvalidOperationException("RunAs.Password is required when Mode = SpecificUser"); - - var (domain, user) = ParseUserSpec(runAs.Username); - psi.UserName = user; - if (!string.IsNullOrEmpty(domain)) psi.Domain = domain; - - var ss = new SecureString(); - foreach (var ch in password) ss.AppendChar(ch); - ss.MakeReadOnly(); - psi.Password = ss; - - psi.LoadUserProfile = runAs.LoadProfile; - } - private static (string Domain, string User) ParseUserSpec(string spec) { var bs = spec.IndexOf('\\');