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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 09:17:07 -04:00
parent 1e48b8185b
commit 8b855ec9b9
3 changed files with 85 additions and 40 deletions
@@ -43,7 +43,12 @@ internal static class InteractiveProcessLauncher
} }
} }
public static LaunchResult Launch(LaunchOptions opts) /// <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(); var sessionId = WTSGetActiveConsoleSessionId();
if (sessionId == INVALID_SESSION_ID) if (sessionId == INVALID_SESSION_ID)
@@ -52,6 +57,44 @@ internal static class InteractiveProcessLauncher
if (!WTSQueryUserToken(sessionId, out var userToken)) if (!WTSQueryUserToken(sessionId, out var userToken))
throw LastError("WTSQueryUserToken (must run as SYSTEM)"); throw LastError("WTSQueryUserToken (must run as SYSTEM)");
try { return LaunchWithToken(userToken, opts); }
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); }
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 primaryToken = IntPtr.Zero;
IntPtr envBlock = IntPtr.Zero; IntPtr envBlock = IntPtr.Zero;
IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero; IntPtr stdoutRead = IntPtr.Zero, stdoutWrite = IntPtr.Zero;
@@ -62,7 +105,7 @@ internal static class InteractiveProcessLauncher
try try
{ {
if (!DuplicateTokenEx(userToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero, if (!DuplicateTokenEx(sourceToken, (uint)MAXIMUM_ALLOWED, IntPtr.Zero,
SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation, SECURITY_IMPERSONATION_LEVEL.SecurityImpersonation,
TOKEN_TYPE.TokenPrimary, out primaryToken)) TOKEN_TYPE.TokenPrimary, out primaryToken))
throw LastError("DuplicateTokenEx"); throw LastError("DuplicateTokenEx");
@@ -140,7 +183,6 @@ internal static class InteractiveProcessLauncher
} }
finally finally
{ {
if (userToken != IntPtr.Zero) CloseHandle(userToken);
if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken); if (primaryToken != IntPtr.Zero) CloseHandle(primaryToken);
if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock); if (envBlock != IntPtr.Zero) DestroyEnvironmentBlock(envBlock);
if (!succeeded) if (!succeeded)
@@ -79,6 +79,19 @@ internal static class NativeMethods
public uint dwThreadId; 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")] [DllImport("kernel32.dll")]
public static extern uint WTSGetActiveConsoleSessionId(); public static extern uint WTSGetActiveConsoleSessionId();
@@ -1,6 +1,5 @@
using System.Diagnostics; using System.Diagnostics;
using System.Runtime.Versioning; using System.Runtime.Versioning;
using System.Security;
using System.Text; using System.Text;
using WebhookServer.Core.Execution.Native; using WebhookServer.Core.Execution.Native;
using WebhookServer.Core.Models; using WebhookServer.Core.Models;
@@ -19,7 +18,8 @@ public sealed class ProcessExecutor : IExecutor
var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service; var mode = endpoint.RunAs?.Mode ?? RunAsMode.Service;
return mode switch 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), _ => await RunWithProcessAsync(endpoint, ctx, startedAt, ct).ConfigureAwait(false),
}; };
} }
@@ -32,12 +32,6 @@ public sealed class ProcessExecutor : IExecutor
foreach (var (k, v) in envVars) foreach (var (k, v) in envVars)
psi.Environment[k] = v; 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 }; using var process = new Process { StartInfo = psi, EnableRaisingEvents = true };
try 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<ExecutionResult> RunInteractiveAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, CancellationToken ct) private static async Task<ExecutionResult> RunWithLauncherAsync(EndpointConfig endpoint, ExecutionContext ctx, DateTimeOffset startedAt, bool useActiveConsole, CancellationToken ct)
{ {
var (psi, envVars) = BuildStartInfo(endpoint, ctx); 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; InteractiveProcessLauncher.LaunchResult launch;
try try
{ {
launch = InteractiveProcessLauncher.Launch(new InteractiveProcessLauncher.LaunchOptions if (useActiveConsole)
{ {
FileName = psi.FileName, launch = InteractiveProcessLauncher.LaunchAsActiveConsoleUser(opts);
Arguments = psi.ArgumentList.ToList(), }
WorkingDirectory = string.IsNullOrEmpty(psi.WorkingDirectory) ? null : psi.WorkingDirectory, else
ExtraEnvVars = envVars, {
StdinBytes = endpoint.DataPassing.StdinJson ? ctx.BodyBytes : null, 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) catch (Exception ex)
{ {
return Failed(ctx.RunId, startedAt, $"interactive launch error: {ex.Message}"); return Failed(ctx.RunId, startedAt, $"launch error: {ex.Message}");
} }
try try
@@ -261,25 +270,6 @@ public sealed class ProcessExecutor : IExecutor
return endpoint.InlineCommand ?? ""; 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) private static (string Domain, string User) ParseUserSpec(string spec)
{ {
var bs = spec.IndexOf('\\'); var bs = spec.IndexOf('\\');