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,144 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace WebhookServer.Core.Execution.Native;
/// <summary>
/// Win32 P/Invoke layer for launching processes in another user's session.
/// Used by <see cref="InteractiveProcessLauncher"/>; not intended for general use.
/// </summary>
[SupportedOSPlatform("windows")]
internal static class NativeMethods
{
public const uint INVALID_SESSION_ID = 0xFFFFFFFF;
public const int MAXIMUM_ALLOWED = 0x02000000;
public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400;
public const uint CREATE_NO_WINDOW = 0x08000000;
public const uint CREATE_NEW_CONSOLE = 0x00000010;
public const uint NORMAL_PRIORITY_CLASS = 0x00000020;
public const int STARTF_USESTDHANDLES = 0x00000100;
public const int HANDLE_FLAG_INHERIT = 1;
public const uint INFINITE = 0xFFFFFFFF;
public enum SECURITY_IMPERSONATION_LEVEL
{
SecurityAnonymous = 0,
SecurityIdentification = 1,
SecurityImpersonation = 2,
SecurityDelegation = 3,
}
public enum TOKEN_TYPE
{
TokenPrimary = 1,
TokenImpersonation = 2,
}
[StructLayout(LayoutKind.Sequential)]
public struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public int bInheritHandle;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct STARTUPINFO
{
public int cb;
public string? lpReserved;
public string? lpDesktop;
public string? lpTitle;
public uint dwX;
public uint dwY;
public uint dwXSize;
public uint dwYSize;
public uint dwXCountChars;
public uint dwYCountChars;
public uint dwFillAttribute;
public uint dwFlags;
public ushort wShowWindow;
public ushort cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
public struct PROCESS_INFORMATION
{
public IntPtr hProcess;
public IntPtr hThread;
public uint dwProcessId;
public uint dwThreadId;
}
[DllImport("kernel32.dll")]
public static extern uint WTSGetActiveConsoleSessionId();
[DllImport("wtsapi32.dll", SetLastError = true)]
public static extern bool WTSQueryUserToken(uint sessionId, out IntPtr phToken);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool DuplicateTokenEx(
IntPtr hExistingToken,
uint dwDesiredAccess,
IntPtr lpTokenAttributes,
SECURITY_IMPERSONATION_LEVEL impersonationLevel,
TOKEN_TYPE tokenType,
out IntPtr phNewToken);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool CreateEnvironmentBlock(
out IntPtr lpEnvironment,
IntPtr hToken,
bool bInherit);
[DllImport("userenv.dll", SetLastError = true)]
public static extern bool DestroyEnvironmentBlock(IntPtr lpEnvironment);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
public static extern bool CreateProcessAsUser(
IntPtr hToken,
string? lpApplicationName,
string? lpCommandLine,
IntPtr lpProcessAttributes,
IntPtr lpThreadAttributes,
bool bInheritHandles,
uint dwCreationFlags,
IntPtr lpEnvironment,
string? lpCurrentDirectory,
ref STARTUPINFO lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CreatePipe(
out IntPtr hReadPipe,
out IntPtr hWritePipe,
ref SECURITY_ATTRIBUTES lpPipeAttributes,
uint nSize);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetHandleInformation(IntPtr hObject, int dwMask, int dwFlags);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool TerminateProcess(IntPtr hProcess, uint uExitCode);
public static Win32Exception LastError(string what) =>
new(Marshal.GetLastWin32Error(), $"{what} failed");
}