diff options
Diffstat (limited to 'src/Agent.Windows/Network')
| -rw-r--r-- | src/Agent.Windows/Network/LoopbackExemption.cs | 123 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/TaskNetworkPolicy.cs | 73 | ||||
| -rw-r--r-- | src/Agent.Windows/Network/WfpDynamicSession.cs | 208 |
3 files changed, 404 insertions, 0 deletions
diff --git a/src/Agent.Windows/Network/LoopbackExemption.cs b/src/Agent.Windows/Network/LoopbackExemption.cs new file mode 100644 index 0000000..a3c68ba --- /dev/null +++ b/src/Agent.Windows/Network/LoopbackExemption.cs @@ -0,0 +1,123 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.Security; +using Windows.Win32.System.Memory; + +namespace Agent.Windows.Network; + +internal static unsafe class LoopbackExemption +{ + private const string MutexName = "Local\\AgentSandboxMcpLoopbackExemption"; + + public static bool Contains(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + return Read().Any(item => item.Sid.Equals(sid)); + } + + public static bool Ensure(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List<Entry> entries = Read(); + if (entries.Any(item => item.Sid.Equals(sid))) return false; + entries.Add(new(sid, 0)); + Write(entries); + return true; + } + + public static void Remove(SecurityIdentifier sid) + { + using MutexLease mutex = AcquireMutex(); + List<Entry> entries = Read(); + if (entries.RemoveAll(item => item.Sid.Equals(sid)) == 0) return; + Write(entries); + } + + private static MutexLease AcquireMutex() + { + var mutex = new Mutex(false, MutexName); + try + { + try + { + if (!mutex.WaitOne(TimeSpan.FromSeconds(30))) throw new TimeoutException("Timed out waiting for the loopback exemption lock."); + } + catch (AbandonedMutexException) + { + // Ownership is granted to this thread when the prior owner died. + } + return new(mutex); + } + catch + { + mutex.Dispose(); + throw; + } + } + + private static List<Entry> Read() + { + uint result = PInvoke.NetworkIsolationGetAppContainerConfig(out uint count, out SID_AND_ATTRIBUTES* native); + ThrowIfError(result, "NetworkIsolationGetAppContainerConfig"); + try + { + var entries = new List<Entry>(checked((int)count)); + for (uint index = 0; index < count; index++) + { + SID_AND_ATTRIBUTES item = native[index]; + entries.Add(new(new SecurityIdentifier((nint)item.Sid.Value), item.Attributes)); + } + return entries; + } + finally + { + HANDLE heap = PInvoke.GetProcessHeap(); + for (uint index = 0; index < count; index++) + if (native[index].Sid.Value is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native[index].Sid.Value); + if (native is not null) _ = PInvoke.HeapFree(heap, HEAP_FLAGS.HEAP_NONE, native); + } + } + + private static void Write(IReadOnlyList<Entry> entries) + { + var native = new SID_AND_ATTRIBUTES[entries.Count]; + var allocations = new List<nint>(entries.Count); + try + { + for (int index = 0; index < entries.Count; index++) + { + byte[] bytes = new byte[entries[index].Sid.BinaryLength]; + entries[index].Sid.GetBinaryForm(bytes, 0); + void* allocation = NativeMemory.Alloc((nuint)bytes.Length); + Marshal.Copy(bytes, 0, (nint)allocation, bytes.Length); + allocations.Add((nint)allocation); + native[index].Sid = new PSID(allocation); + native[index].Attributes = entries[index].Attributes; + } + ThrowIfError(PInvoke.NetworkIsolationSetAppContainerConfig(native), "NetworkIsolationSetAppContainerConfig"); + } + finally + { + foreach (nint allocation in allocations) NativeMemory.Free((void*)allocation); + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with Win32 error 0x{result:X8}."); + } + + private sealed record Entry(SecurityIdentifier Sid, uint Attributes); + + private sealed class MutexLease(Mutex mutex) : IDisposable + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/src/Agent.Windows/Network/TaskNetworkPolicy.cs b/src/Agent.Windows/Network/TaskNetworkPolicy.cs new file mode 100644 index 0000000..59b2602 --- /dev/null +++ b/src/Agent.Windows/Network/TaskNetworkPolicy.cs @@ -0,0 +1,73 @@ +using System.Security.Principal; +using Agent.Windows.Recovery; +using Agent.Windows.Security; + +namespace Agent.Windows.Network; + +public sealed class TaskNetworkPolicy : IDisposable +{ + private readonly WfpDynamicSession _session; + private readonly TaskResourceJournal _journal; + private readonly ISecurityAuditSink _audit; + private readonly Guid _entryId; + private readonly SecurityIdentifier _appContainerSid; + private readonly Guid _loopbackEntryId; + private readonly bool _loopbackAdded; + private bool _disposed; + + private TaskNetworkPolicy(WfpDynamicSession session, TaskResourceJournal journal, ISecurityAuditSink audit, Guid entryId, + SecurityIdentifier appContainerSid, Guid loopbackEntryId, bool loopbackAdded) + { + _session = session; _journal = journal; _audit = audit; _entryId = entryId; + _appContainerSid = appContainerSid; _loopbackEntryId = loopbackEntryId; _loopbackAdded = loopbackAdded; + } + + public static TaskNetworkPolicy Apply(TaskResourceJournal journal, SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint> allowances, ISecurityAuditSink audit) + { + bool originallyExempt = LoopbackExemption.Contains(appContainerSid); + Guid loopbackEntryId = journal.Prepare("network.loopback_exemption", appContainerSid.Value, + new Dictionary<string, string> { ["originally_present"] = originallyExempt.ToString() }); + bool loopbackAdded = false; + Guid entryId = journal.Prepare("wfp.dynamic", appContainerSid.Value, new Dictionary<string, string> + { + ["session"] = "dynamic", ["allowances"] = string.Join(',', allowances.Select(static item => $"{item.Address}:{item.Port}")) + }); + WfpDynamicSession session = WfpDynamicSession.Open(); + try + { + loopbackAdded = LoopbackExemption.Ensure(appContainerSid); + journal.MarkApplied(loopbackEntryId, new Dictionary<string, string> { ["added"] = loopbackAdded.ToString() }); + IReadOnlyList<ulong> ids = session.ApplyDefaultBlock(appContainerSid, allowances); + journal.MarkApplied(entryId, new Dictionary<string, string> { ["filter_ids"] = string.Join(',', ids) }); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "wfp.apply", "applied", new Dictionary<string, string> + { + ["ipv4_ipv6_default_block"] = "true", ["filter_count"] = ids.Count.ToString(), ["session"] = "dynamic" + })); + audit.Write(new(DateTimeOffset.UtcNow, journal.TaskId, "network.loopback.apply", loopbackAdded ? "added" : "preserved", + new Dictionary<string, string> { ["app_container_sid"] = appContainerSid.Value })); + return new(session, journal, audit, entryId, appContainerSid, loopbackEntryId, loopbackAdded); + } + catch + { + session.Dispose(); + journal.MarkCleaned(entryId); + if (loopbackAdded) LoopbackExemption.Remove(appContainerSid); + journal.MarkCleaned(loopbackEntryId); + throw; + } + } + + public void Dispose() + { + if (_disposed) return; + _session.RemovePolicy(); + _session.Dispose(); + _journal.MarkCleaned(_entryId); + if (_loopbackAdded) LoopbackExemption.Remove(_appContainerSid); + _journal.MarkCleaned(_loopbackEntryId); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "wfp.remove", "removed", new Dictionary<string, string> { ["residual_filters"] = "0" })); + _audit.Write(new(DateTimeOffset.UtcNow, _journal.TaskId, "network.loopback.remove", "removed", + new Dictionary<string, string> { ["residual_exemptions"] = LoopbackExemption.Contains(_appContainerSid) ? "preexisting" : "0" })); + _disposed = true; + } +} diff --git a/src/Agent.Windows/Network/WfpDynamicSession.cs b/src/Agent.Windows/Network/WfpDynamicSession.cs new file mode 100644 index 0000000..ef8ba8e --- /dev/null +++ b/src/Agent.Windows/Network/WfpDynamicSession.cs @@ -0,0 +1,208 @@ +using System.ComponentModel; +using System.Buffers.Binary; +using System.Net; +using System.Runtime.InteropServices; +using System.Security.Principal; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.NetworkManagement.WindowsFilteringPlatform; +using Windows.Win32.Security; + +namespace Agent.Windows.Network; + +public sealed record NetworkEndpoint(IPAddress Address, ushort Port); + +public static class NetworkAllowanceSnapshot +{ + public static async Task<IReadOnlyList<NetworkEndpoint>> ResolveAsync(IEnumerable<(string Host, IReadOnlyList<int> Ports)> allowances, CancellationToken cancellationToken) + { + var endpoints = new HashSet<NetworkEndpoint>(); + foreach ((string host, IReadOnlyList<int> ports) in allowances) + { + IPAddress[] addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false); + foreach (int port in ports) + { + if (port is < 1 or > 65535) throw new ArgumentOutOfRangeException(nameof(allowances), $"Invalid network port {port}."); + foreach (IPAddress address in addresses.Where(static item => item.AddressFamily is System.Net.Sockets.AddressFamily.InterNetwork or System.Net.Sockets.AddressFamily.InterNetworkV6)) + endpoints.Add(new(address, (ushort)port)); + } + } + return endpoints.OrderBy(static item => item.Address.ToString(), StringComparer.Ordinal).ThenBy(static item => item.Port).ToArray(); + } +} + +public sealed unsafe class WfpDynamicSession : IDisposable +{ + private const FWPM_FILTER_FLAGS FwpmFilterFlagClearActionRight = (FWPM_FILTER_FLAGS)0x00000008; + private readonly FwpmEngineClose0SafeHandle _engine; + private Guid? _subLayerKey; + private readonly List<ulong> _filterIds = []; + + private WfpDynamicSession(FwpmEngineClose0SafeHandle engine) => _engine = engine; + public static WfpDynamicSession Open(string displayName = "Agent Sandbox MCP dynamic task session") + { + FWPM_SESSION0 session = default; + session.flags = PInvoke.FWPM_SESSION_FLAG_DYNAMIC; + uint result = PInvoke.FwpmEngineOpen0(10, null, session, out FwpmEngineClose0SafeHandle engine); + ThrowIfError(result, "FwpmEngineOpen0"); + return new(engine); + } + + public void ProbeWriteTransaction() + { + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + result = PInvoke.FwpmTransactionAbort0(_engine); + ThrowIfError(result, "FwpmTransactionAbort0"); + } + + public IReadOnlyList<ulong> ApplyDefaultBlock(SecurityIdentifier appContainerSid, IReadOnlyList<NetworkEndpoint>? allowedEndpoints = null) + { + if (_subLayerKey is not null) throw new InvalidOperationException("WFP policy is already applied in this session."); + byte[] sidBytes = new byte[appContainerSid.BinaryLength]; + appContainerSid.GetBinaryForm(sidBytes, 0); + uint result = PInvoke.FwpmTransactionBegin0(_engine, 0); + ThrowIfError(result, "FwpmTransactionBegin0"); + bool committed = false; + try + { + Guid subLayerKey = Guid.NewGuid(); + fixed (char* subLayerName = "Agent Sandbox MCP task sublayer") + { + FWPM_SUBLAYER0 subLayer = default; + subLayer.subLayerKey = subLayerKey; + subLayer.displayData.name = new PWSTR(subLayerName); + subLayer.weight = 0x7FFF; + ThrowIfError(PInvoke.FwpmSubLayerAdd0(_engine, in subLayer, default), "FwpmSubLayerAdd0"); + } + + fixed (byte* sidPointer = sidBytes) + { + foreach (NetworkEndpoint endpoint in allowedEndpoints ?? []) _filterIds.Add(AddPackagePermit(endpoint, subLayerKey, (SID*)sidPointer)); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv4 default block")); + _filterIds.Add(AddPackageBlock(PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6, subLayerKey, (SID*)sidPointer, "Agent Sandbox MCP IPv6 default block")); + } + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0"); + committed = true; + _subLayerKey = subLayerKey; + return _filterIds.ToArray(); + } + finally + { + if (!committed) + { + _filterIds.Clear(); + _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + } + + public void RemovePolicy() + { + if (_subLayerKey is null) return; + ThrowIfError(PInvoke.FwpmTransactionBegin0(_engine, 0), "FwpmTransactionBegin0(remove)"); + bool committed = false; + try + { + foreach (ulong filterId in _filterIds.AsEnumerable().Reverse()) ThrowIfError(PInvoke.FwpmFilterDeleteById0(_engine, filterId), "FwpmFilterDeleteById0"); + Guid subLayerKey = _subLayerKey.Value; + ThrowIfError(PInvoke.FwpmSubLayerDeleteByKey0(_engine, in subLayerKey), "FwpmSubLayerDeleteByKey0"); + ThrowIfError(PInvoke.FwpmTransactionCommit0(_engine), "FwpmTransactionCommit0(remove)"); + committed = true; + _filterIds.Clear(); + _subLayerKey = null; + } + finally + { + if (!committed) _ = PInvoke.FwpmTransactionAbort0(_engine); + } + } + + public void Dispose() + { + _engine.Dispose(); + } + + private ulong AddPackageBlock(Guid layerKey, Guid subLayerKey, SID* sid, string displayName) + { + FWPM_FILTER_CONDITION0 condition = default; + condition.fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + condition.matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + condition.conditionValue.type = FWP_DATA_TYPE.FWP_SID; + condition.conditionValue.sid = sid; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layerKey; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 8; + filter.numFilterConditions = 1; + filter.filterCondition = &condition; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_BLOCK; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0"); + return id; + } + } + + private ulong AddPackagePermit(NetworkEndpoint endpoint, Guid subLayerKey, SID* sid) + { + FWPM_FILTER_CONDITION0* conditions = stackalloc FWPM_FILTER_CONDITION0[3]; + conditions[0].fieldKey = PInvoke.FWPM_CONDITION_ALE_PACKAGE_ID; + conditions[0].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[0].conditionValue.type = FWP_DATA_TYPE.FWP_SID; + conditions[0].conditionValue.sid = sid; + conditions[1].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_ADDRESS; + conditions[1].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + Guid layer; + FWP_BYTE_ARRAY16 ipv6 = default; + if (endpoint.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V4; + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_UINT32; + // FWP_UINT32 uses host-order numeric values. For 127.0.0.1 the + // numeric value is 0x7F000001; parse the network-order octets into + // that value rather than reinterpreting their in-memory byte order. + conditions[1].conditionValue.uint32 = BinaryPrimitives.ReadUInt32BigEndian(endpoint.Address.GetAddressBytes()); + } + else + { + layer = PInvoke.FWPM_LAYER_ALE_AUTH_CONNECT_V6; + ipv6.byteArray16 = endpoint.Address.GetAddressBytes(); + conditions[1].conditionValue.type = FWP_DATA_TYPE.FWP_BYTE_ARRAY16_TYPE; + conditions[1].conditionValue.byteArray16 = &ipv6; + } + conditions[2].fieldKey = PInvoke.FWPM_CONDITION_IP_REMOTE_PORT; + conditions[2].matchType = FWP_MATCH_TYPE.FWP_MATCH_EQUAL; + conditions[2].conditionValue.type = FWP_DATA_TYPE.FWP_UINT16; + conditions[2].conditionValue.uint16 = endpoint.Port; + string displayName = $"Agent Sandbox MCP allow {endpoint.Address}:{endpoint.Port}"; + fixed (char* name = displayName) + { + FWPM_FILTER0 filter = default; + filter.filterKey = Guid.NewGuid(); + filter.displayData.name = new PWSTR(name); + filter.layerKey = layer; + filter.subLayerKey = subLayerKey; + filter.weight.type = FWP_DATA_TYPE.FWP_UINT8; + filter.weight.uint8 = 15; + filter.numFilterConditions = 3; + filter.filterCondition = conditions; + // A plain permit is a soft permit and can be overridden by a block + // from another sub-layer (for example Windows Firewall). The exact + // task allowance must be a hard permit; our higher-weight match in + // this sub-layer still prevents the task default-block from running. + filter.flags = FwpmFilterFlagClearActionRight; + filter.action.type = FWP_ACTION_TYPE.FWP_ACTION_PERMIT; + ThrowIfError(PInvoke.FwpmFilterAdd0(_engine, in filter, default, out ulong id), "FwpmFilterAdd0(permit)"); + return id; + } + } + + private static void ThrowIfError(uint result, string operation) + { + if (result != 0) throw new Win32Exception(unchecked((int)result), $"{operation} failed with WFP/Win32 error 0x{result:X8}."); + } +} |
