summaryrefslogtreecommitdiff
path: root/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
diff options
context:
space:
mode:
Diffstat (limited to 'src/Agent.Windows/Security/RestrictedProcessLauncher.cs')
-rw-r--r--src/Agent.Windows/Security/RestrictedProcessLauncher.cs208
1 files changed, 163 insertions, 45 deletions
diff --git a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
index 687af94..c3a19da 100644
--- a/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
+++ b/src/Agent.Windows/Security/RestrictedProcessLauncher.cs
@@ -14,21 +14,27 @@ namespace Agent.Windows.Security;
public sealed record RestrictedProcessResult(uint ProcessId, uint ExitCode, string StandardOutput, string StandardError);
public sealed record SandboxJobLimits(uint MaxProcesses = 16, ulong MaxJobMemoryBytes = 4UL * 1024 * 1024 * 1024);
-public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null, SandboxJobLimits? jobLimits = null)
+public sealed unsafe class RestrictedProcessLauncher(EntryExecutablePolicy executablePolicy, ISecurityAuditSink audit, SecurityIdentifier? appContainerSid = null,
+ SandboxJobLimits? jobLimits = null, bool allowNetwork = false)
{
- private const uint ProcessCreationChildProcessRestricted = 0x1;
+ private const uint ProcessCreationAllApplicationPackagesOptOut = 0x1;
private readonly SandboxJobLimits _jobLimits = jobLimits ?? new();
public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
- string? standardInput = null, Guid? sessionId = null)
+ string? standardInput = null, Guid? sessionId = null) =>
+ Run(taskId, executable, arguments, workingDirectory, timeout, standardInput, sessionId, CancellationToken.None);
+
+ public RestrictedProcessResult Run(Guid taskId, string executable, IReadOnlyList<string> arguments, string workingDirectory, TimeSpan timeout,
+ string? standardInput, Guid? sessionId, CancellationToken cancellationToken)
{
- VerifiedExecutable verified;
- VerifiedExecutable? managedEntry = null;
+ string cwd = ResolvedPath.Resolve(workingDirectory);
+ VerifiedEntryExecutable verified;
+ string? managedEntry = null;
try
{
- verified = executablePolicy.Verify(executable);
- string? managedEntryPath = ExecutablePolicy.ManagedEntryPoint(verified.CanonicalPath, arguments);
- if (managedEntryPath is not null) managedEntry = executablePolicy.Verify(managedEntryPath);
+ verified = executablePolicy.VerifyEntry(executable, cwd);
+ managedEntry = executablePolicy.ManagedEntryPoint(verified.ResolvedPath, arguments, cwd);
+ if (managedEntry is not null) executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -36,7 +42,6 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw;
}
- string cwd = CanonicalPath.Resolve(workingDirectory);
using FileStream stdin = CaptureFile();
using FileStream stdout = CaptureFile();
using FileStream stderr = CaptureFile();
@@ -63,18 +68,14 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
throw LastError("SetInformationJobObject");
nuint attributeSize = 0;
- uint attributeCount = appContainerSid is null ? 2u : 3u;
+ uint attributeCount = appContainerSid is null ? 1u : 3u;
_ = PInvoke.InitializeProcThreadAttributeList(default, attributeCount, 0, &attributeSize);
LPPROC_THREAD_ATTRIBUTE_LIST attributes = (LPPROC_THREAD_ATTRIBUTE_LIST)NativeMemory.Alloc(attributeSize);
void* sidBuffer = null;
- void* internetClientSidBuffer = null;
- void* privateNetworkSidBuffer = null;
+ var capabilityBuffers = new List<nint>();
try
{
if (!PInvoke.InitializeProcThreadAttributeList(attributes, attributeCount, 0, &attributeSize)) throw LastError("InitializeProcThreadAttributeList");
- uint childPolicy = ProcessCreationChildProcessRestricted;
- if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &childPolicy, sizeof(uint), null, null))
- throw LastError("UpdateProcThreadAttribute");
HANDLE[] inheritedHandles =
[
new(stdin.SafeFileHandle.DangerousGetHandle()),
@@ -95,17 +96,24 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
sidBuffer = NativeMemory.Alloc((nuint)sidBytes.Length);
Marshal.Copy(sidBytes, 0, (nint)sidBuffer, sidBytes.Length);
securityCapabilities.AppContainerSid = (nint)sidBuffer;
- internetClientSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-1"));
- privateNetworkSidBuffer = AllocateSid(new SecurityIdentifier("S-1-15-3-3"));
- SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[2];
- capabilities[0].Sid = new PSID(internetClientSidBuffer);
- capabilities[0].Attributes = 0x00000004;
- capabilities[1].Sid = new PSID(privateNetworkSidBuffer);
- capabilities[1].Attributes = 0x00000004;
+ SecurityIdentifier[] capabilitySids = allowNetwork
+ ? [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation"), new("S-1-15-3-1"), new("S-1-15-3-3")]
+ : [DeriveCapabilitySid("registryRead"), DeriveCapabilitySid("lpacInstrumentation")];
+ SID_AND_ATTRIBUTES* capabilities = stackalloc SID_AND_ATTRIBUTES[capabilitySids.Length];
+ for (int i = 0; i < capabilitySids.Length; i++)
+ {
+ void* buffer = AllocateSid(capabilitySids[i]);
+ capabilityBuffers.Add((nint)buffer);
+ capabilities[i].Sid = new PSID(buffer);
+ capabilities[i].Attributes = 0x00000004;
+ }
securityCapabilities.Capabilities = (nint)capabilities;
- securityCapabilities.CapabilityCount = 2;
+ securityCapabilities.CapabilityCount = (uint)capabilitySids.Length;
if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES,
- &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LastError("UpdateProcThreadAttribute(SecurityCapabilities)");
+ &securityCapabilities, (nuint)sizeof(SecurityCapabilities), null, null)) throw LpacFailure("UpdateProcThreadAttribute(SecurityCapabilities)", verified.ResolvedPath);
+ uint allApplicationPackagesPolicy = ProcessCreationAllApplicationPackagesOptOut;
+ if (!PInvoke.UpdateProcThreadAttribute(attributes, 0, PInvoke.PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY,
+ &allApplicationPackagesPolicy, sizeof(uint), null, null)) throw LpacFailure("UpdateProcThreadAttribute(AllApplicationPackagesPolicy)", verified.ResolvedPath);
}
STARTUPINFOEXW startup = default;
startup.StartupInfo.cb = (uint)sizeof(STARTUPINFOEXW);
@@ -115,28 +123,29 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
startup.StartupInfo.hStdError = inheritedHandles[2];
startup.lpAttributeList = attributes;
PROCESS_INFORMATION process = default;
- string commandLine = BuildCommandLine(verified.CanonicalPath, arguments);
+ string commandLine = BuildCommandLine(verified.ResolvedPath, arguments);
Span<char> commandBuffer = new char[commandLine.Length + 1];
commandLine.AsSpan().CopyTo(commandBuffer);
- if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
- PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED,
- null, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ char[] environmentBlock = BuildEnvironmentBlock(cwd);
+ fixed (char* environment = environmentBlock)
+ {
+ if (!PInvoke.CreateProcess(null, ref commandBuffer, null, null, true,
+ PROCESS_CREATION_FLAGS.EXTENDED_STARTUPINFO_PRESENT | PROCESS_CREATION_FLAGS.CREATE_SUSPENDED |
+ PROCESS_CREATION_FLAGS.CREATE_UNICODE_ENVIRONMENT,
+ environment, cwd, in startup.StartupInfo, out process)) throw LastError("CreateProcess");
+ }
try
{
using var processView = new SafeFileHandle(process.hProcess, ownsHandle: false);
if (!PInvoke.AssignProcessToJobObject(job, processView)) throw LastError("AssignProcessToJobObject");
try
{
- VerifiedExecutable postCreate = executablePolicy.Verify(executable);
- if (!string.Equals(postCreate.CanonicalPath, verified.CanonicalPath, StringComparison.OrdinalIgnoreCase) ||
+ VerifiedEntryExecutable postCreate = executablePolicy.VerifyEntry(executable, cwd);
+ if (!string.Equals(postCreate.ResolvedPath, verified.ResolvedPath, StringComparison.OrdinalIgnoreCase) ||
!string.Equals(postCreate.Sha256, verified.Sha256, StringComparison.Ordinal))
throw new SandboxPolicyException("process.identity_changed", "Executable identity changed during process creation.");
if (managedEntry is not null)
- {
- VerifiedExecutable postCreateManaged = executablePolicy.Verify(managedEntry.CanonicalPath);
- if (!string.Equals(postCreateManaged.Sha256, managedEntry.Sha256, StringComparison.Ordinal))
- throw new SandboxPolicyException("process.managed_identity_changed", "Managed entry assembly identity changed during process creation.");
- }
+ executablePolicy.VerifyManagedEntryPoint(managedEntry);
}
catch (SandboxPolicyException ex)
{
@@ -146,20 +155,41 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
}, SessionId: sessionId, ErrorCode: ex.Code));
throw;
}
+ (bool tokenIsAppContainer, string? tokenAppContainerSid, bool processInJob) = InspectConfinement(processView, job);
+ if (appContainerSid is not null && (!tokenIsAppContainer ||
+ !string.Equals(tokenAppContainerSid, appContainerSid.Value, StringComparison.Ordinal) || !processInJob))
+ throw new SandboxPolicyException("process.lpac_mismatch",
+ "Entry Process did not receive the required LPAC identity and Job Object confinement.",
+ "process", verified.ResolvedPath, "execute", "Verify LPAC support on this Windows host; no fallback is permitted.");
if (PInvoke.ResumeThread(process.hThread) == uint.MaxValue) throw LastError("ResumeThread");
var processAudit = new Dictionary<string, string>
{
- ["path"] = verified.CanonicalPath, ["sha256"] = verified.Sha256, ["child_process_policy"] = "restricted",
- ["app_container_sid"] = appContainerSid?.Value ?? "none", ["network_capabilities"] = appContainerSid is null ? "none" : "internetClient,privateNetworkClientServer",
+ ["path"] = verified.ResolvedPath, ["sha256"] = verified.Sha256, ["entry_classification"] = verified.Classification.ToString(),
+ ["alias"] = verified.Alias ?? string.Empty, ["descendant_policy"] = "lpac_inherited",
+ ["app_container_sid"] = appContainerSid?.Value ?? "none", ["lpac"] = (appContainerSid is not null).ToString(),
+ ["token_is_app_container"] = tokenIsAppContainer.ToString(), ["token_app_container_sid"] = tokenAppContainerSid ?? "none",
+ ["process_in_job"] = processInJob.ToString(), ["all_application_packages_opt_out"] = (appContainerSid is not null).ToString(),
+ ["network_capabilities"] = appContainerSid is null || !allowNetwork ? "none" : "internetClient,privateNetworkClientServer",
+ ["baseline_capabilities"] = appContainerSid is null ? "none" : "registryRead,lpacInstrumentation",
+ ["environment_policy"] = "minimal_no_broker_credentials",
+ ["arguments"] = "redacted", ["argument_count"] = arguments.Count.ToString(System.Globalization.CultureInfo.InvariantCulture),
["pid"] = process.dwProcessId.ToString()
};
if (managedEntry is not null)
{
- processAudit["managed_entry_path"] = managedEntry.CanonicalPath;
- processAudit["managed_entry_sha256"] = managedEntry.Sha256;
+ processAudit["managed_entry_path"] = ResolvedPath.Resolve(managedEntry);
}
audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.start", "allowed", processAudit, SessionId: sessionId));
- WAIT_EVENT wait = PInvoke.WaitForSingleObject(process.hProcess, checked((uint)Math.Clamp(timeout.TotalMilliseconds, 1, uint.MaxValue - 1)));
+ long deadline = Environment.TickCount64 + checked((long)Math.Clamp(timeout.TotalMilliseconds, 1, int.MaxValue));
+ WAIT_EVENT wait;
+ while (true)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ long remaining = deadline - Environment.TickCount64;
+ if (remaining <= 0) { wait = WAIT_EVENT.WAIT_TIMEOUT; break; }
+ wait = PInvoke.WaitForSingleObject(process.hProcess, (uint)Math.Min(remaining, 100));
+ if (wait != WAIT_EVENT.WAIT_TIMEOUT) break;
+ }
if (wait == WAIT_EVENT.WAIT_TIMEOUT)
{
_ = PInvoke.TerminateJobObject(job, 1460);
@@ -172,12 +202,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
audit.Write(new(DateTimeOffset.UtcNow, taskId, "policy.deny", "observed", new Dictionary<string, string>
{
- ["code"] = "restricted_process_failure", ["pid"] = process.dwProcessId.ToString(),
- ["child_process_policy"] = "restricted", ["exit_code"] = exitCode.ToString()
- }, SessionId: sessionId, ErrorCode: "process.child_denied"));
+ ["code"] = "sandbox_process_failure", ["pid"] = process.dwProcessId.ToString(),
+ ["descendant_policy"] = "lpac_inherited", ["exit_code"] = exitCode.ToString()
+ }, SessionId: sessionId, ErrorCode: "process.nonzero_exit"));
}
return new(process.dwProcessId, exitCode, ReadCapture(stdout), ReadCapture(stderr));
}
+ catch (OperationCanceledException)
+ {
+ _ = PInvoke.TerminateJobObject(job, 1223);
+ _ = PInvoke.WaitForSingleObject(process.hProcess, 5000);
+ audit.Write(new(DateTimeOffset.UtcNow, taskId, "process.cancel", "terminated",
+ new Dictionary<string, string> { ["pid"] = process.dwProcessId.ToString() }, SessionId: sessionId));
+ throw;
+ }
finally
{
PInvoke.CloseHandle(process.hThread);
@@ -188,8 +226,7 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
{
if (attributes.Value is not null) PInvoke.DeleteProcThreadAttributeList(attributes);
NativeMemory.Free(sidBuffer);
- NativeMemory.Free(internetClientSidBuffer);
- NativeMemory.Free(privateNetworkSidBuffer);
+ foreach (nint buffer in capabilityBuffers) NativeMemory.Free((void*)buffer);
NativeMemory.Free(attributes.Value);
}
}
@@ -201,6 +238,34 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
int code = Marshal.GetLastWin32Error();
return new(code, $"{operation} failed with Win32 error {code}.");
}
+
+ private static SandboxPolicyException LpacFailure(string operation, string executable)
+ {
+ int code = Marshal.GetLastWin32Error();
+ return new("process.lpac_unsupported", $"{operation} failed with Win32 error {code}; no fallback is permitted.",
+ "process", executable, "execute", "Use a supported Windows 11 host and verify LPAC capability derivation and process-attribute support.");
+ }
+
+ private static (bool IsAppContainer, string? AppContainerSid, bool InJob) InspectConfinement(SafeFileHandle process, SafeFileHandle job)
+ {
+ if (!PInvoke.IsProcessInJob(process, job, out BOOL inJob)) throw LastError("IsProcessInJob");
+ if (!PInvoke.OpenProcessToken(process, TOKEN_ACCESS_MASK.TOKEN_QUERY, out SafeFileHandle token)) throw LastError("OpenProcessToken");
+ using (token)
+ {
+ Span<byte> flag = stackalloc byte[sizeof(uint)];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenIsAppContainer, flag, out _))
+ throw LastError("GetTokenInformation(TokenIsAppContainer)");
+ bool isAppContainer = MemoryMarshal.Read<uint>(flag) != 0;
+ _ = PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, Span<byte>.Empty, out uint sidInformationLength);
+ if (sidInformationLength < IntPtr.Size) throw LastError("GetTokenInformation(TokenAppContainerSid size)");
+ Span<byte> sidInformation = sidInformationLength <= 256 ? stackalloc byte[(int)sidInformationLength] : new byte[sidInformationLength];
+ if (!PInvoke.GetTokenInformation(token, TOKEN_INFORMATION_CLASS.TokenAppContainerSid, sidInformation, out _))
+ throw LastError("GetTokenInformation(TokenAppContainerSid)");
+ nint sidPointer = MemoryMarshal.Read<nint>(sidInformation);
+ string? sid = sidPointer == 0 ? null : new SecurityIdentifier(sidPointer).Value;
+ return (isAppContainer, sid, inJob);
+ }
+ }
private static FileStream CaptureFile()
{
string path = Path.Combine(Path.GetTempPath(), "agent-mcp-process-" + Guid.NewGuid().ToString("N") + ".tmp");
@@ -215,6 +280,20 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
Marshal.Copy(bytes, 0, (nint)buffer, bytes.Length);
return buffer;
}
+
+ private static SecurityIdentifier DeriveCapabilitySid(string name)
+ {
+ if (!PInvoke.DeriveCapabilitySidsFromName(name, out PSID* groupSids, out uint groupCount, out PSID* capabilitySids, out uint capabilityCount) || capabilityCount != 1)
+ throw LastError($"DeriveCapabilitySidsFromName({name})");
+ try { return new SecurityIdentifier((nint)capabilitySids[0].Value); }
+ finally
+ {
+ for (uint i = 0; i < groupCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids[i].Value));
+ for (uint i = 0; i < capabilityCount; i++) _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids[i].Value));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)groupSids));
+ _ = PInvoke.LocalFree(new HLOCAL((nint)capabilitySids));
+ }
+ }
private static void MakeInheritable(SafeFileHandle handle)
{
if (!PInvoke.SetHandleInformation(handle, (uint)HANDLE_FLAGS.HANDLE_FLAG_INHERIT, HANDLE_FLAGS.HANDLE_FLAG_INHERIT))
@@ -230,6 +309,45 @@ public sealed unsafe class RestrictedProcessLauncher(ExecutablePolicy executable
return count > maxCharacters ? new string(buffer, 0, maxCharacters) + "\n[output truncated]" : new string(buffer, 0, count);
}
private static string BuildCommandLine(string executable, IReadOnlyList<string> arguments) => string.Join(' ', [Quote(executable), .. arguments.Select(Quote)]);
+ private static char[] BuildEnvironmentBlock(string cwd)
+ {
+ string systemRoot = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ var values = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase)
+ {
+ ["ComSpec"] = Path.Combine(systemRoot, "System32", "cmd.exe"),
+ ["DOTNET_CLI_HOME"] = cwd,
+ ["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1",
+ ["DOTNET_CLI_USE_MSBUILD_SERVER"] = "0",
+ ["DOTNET_CLI_WORKLOAD_UPDATE_NOTIFY_DISABLE"] = "true",
+ ["DOTNET_NOLOGO"] = "1",
+ ["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1",
+ ["DOTNET_SKIP_WORKLOAD_INTEGRITY_CHECK"] = "true",
+ ["MSBuildEnableWorkloadResolver"] = "false",
+ ["MSBUILDDISABLENODEREUSE"] = "1",
+ ["NUMBER_OF_PROCESSORS"] = Environment.ProcessorCount.ToString(System.Globalization.CultureInfo.InvariantCulture),
+ ["OS"] = "Windows_NT",
+ ["PATH"] = Path.Combine(systemRoot, "System32"),
+ ["PATHEXT"] = ".COM;.EXE;.BAT;.CMD",
+ ["PROCESSOR_ARCHITECTURE"] = RuntimeInformation.ProcessArchitecture.ToString().ToUpperInvariant(),
+ ["SystemDrive"] = Path.GetPathRoot(systemRoot)!.TrimEnd(Path.DirectorySeparatorChar),
+ ["SystemRoot"] = systemRoot,
+ ["TEMP"] = cwd,
+ ["TMP"] = cwd,
+ ["windir"] = systemRoot
+ };
+ foreach (string name in new[]
+ {
+ "ALLUSERSPROFILE", "APPDATA", "CommonProgramFiles", "CommonProgramFiles(x86)", "CommonProgramW6432",
+ "HOMEDRIVE", "HOMEPATH", "LOCALAPPDATA", "ProgramData", "ProgramFiles", "ProgramFiles(x86)",
+ "ProgramW6432", "PUBLIC", "USERDOMAIN", "USERNAME", "USERPROFILE"
+ })
+ {
+ string? value = Environment.GetEnvironmentVariable(name);
+ if (!string.IsNullOrEmpty(value)) values[name] = value;
+ }
+ string block = string.Concat(values.Select(pair => $"{pair.Key}={pair.Value}\0")) + "\0";
+ return block.ToCharArray();
+ }
private static string Quote(string value)
{
if (value.Length > 0 && !value.Any(static character => char.IsWhiteSpace(character) || character == '"')) return value;