diff --git a/Cli/AttackSurfaceAnalyzerClient.cs b/Cli/AttackSurfaceAnalyzerClient.cs
index 64d2f9184..7c9d93faa 100644
--- a/Cli/AttackSurfaceAnalyzerClient.cs
+++ b/Cli/AttackSurfaceAnalyzerClient.cs
@@ -1509,6 +1509,10 @@ public static ASA_ERROR RunCollectCommand(CollectCommandOptions opts)
case RESULT_TYPE.WIFI:
opts.EnableWifiCollector = true;
break;
+
+ case RESULT_TYPE.LOADPOINT:
+ opts.EnableLoadPointCollector = true;
+ break;
}
}
}
@@ -1588,6 +1592,11 @@ public static ASA_ERROR RunCollectCommand(CollectCommandOptions opts)
collectors.Add(new WifiCollector(opts, defaultChangeHandler));
dict.Add(RESULT_TYPE.WIFI);
}
+ if (opts.EnableLoadPointCollector || (opts.EnableAllCollectors && RuntimeInformation.IsOSPlatform(OSPlatform.Windows)))
+ {
+ collectors.Add(new LoadPointCollector(opts, defaultChangeHandler));
+ dict.Add(RESULT_TYPE.LOADPOINT);
+ }
if (collectors.Count == 0)
{
diff --git a/Cli/Components/CollectorOptions/LoadPointCollectorOptions.razor b/Cli/Components/CollectorOptions/LoadPointCollectorOptions.razor
new file mode 100644
index 000000000..6b864f83a
--- /dev/null
+++ b/Cli/Components/CollectorOptions/LoadPointCollectorOptions.razor
@@ -0,0 +1,14 @@
+@inject Microsoft.CST.AttackSurfaceAnalyzer.Cli.AppData appData
+
+
diff --git a/Cli/Components/CollectorOptionsRazor.razor b/Cli/Components/CollectorOptionsRazor.razor
index 1f321ab35..0ac296108 100644
--- a/Cli/Components/CollectorOptionsRazor.razor
+++ b/Cli/Components/CollectorOptionsRazor.razor
@@ -23,6 +23,7 @@
+
diff --git a/Lib/Collectors/ComObjectCollector.cs b/Lib/Collectors/ComObjectCollector.cs
index 0d0462192..7a1e19cd5 100644
--- a/Lib/Collectors/ComObjectCollector.cs
+++ b/Lib/Collectors/ComObjectCollector.cs
@@ -26,7 +26,12 @@ public ComObjectCollector(CollectorOptions? opts = null, Action
?
///
/// The Registry Key to search
/// The View of the registry to use
- public static IEnumerable ParseComObjects(RegistryKey SearchKey, RegistryView View, bool SingleThreaded = false)
+ /// Whether to parse subkeys serially
+ ///
+ /// Whether to collect metadata for servers that resolve to another machine. Off by default, since
+ /// reaching one connects to a host named by whoever could write the CLSID.
+ ///
+ public static IEnumerable ParseComObjects(RegistryKey SearchKey, RegistryView View, bool SingleThreaded = false, bool FollowNetworkPaths = false)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { return new List(); }
if (SearchKey == null) { return new List(); }
@@ -45,70 +50,21 @@ public static IEnumerable ParseComObjects(RegistryKey SearchKey,
if (RegObj != null)
{
ComObject comObject = new(RegObj);
+ var binary = ResolveServerBinary(CurrentKey, View, fsc, FollowNetworkPaths);
- foreach (string ComDetails in CurrentKey.GetSubKeyNames())
+ if (binary is not null)
{
- if (ComDetails.Contains("InprocServer32"))
+ // Which view the object came from is what determines the bitness of the
+ // server it registers; the key name does not. InprocServer32 holds the
+ // 64-bit server in the 64-bit view and the 32-bit server in the 32-bit view
+ // (where it is redirected to Wow6432Node).
+ if (View == RegistryView.Registry32)
{
- var ComKey = CurrentKey.OpenSubKey(ComDetails);
- if (ComKey is not null)
- {
- var obj = RegistryWalker.RegistryKeyToRegistryObject(ComKey, View);
- string? BinaryPath32 = null;
-
- if (obj != null && obj.Values?.TryGetValue("", out BinaryPath32) is bool successful)
- {
- if (successful && BinaryPath32 != null)
- {
- // Clean up cases where some extra spaces are thrown into the start
- // (breaks our permission checker)
- BinaryPath32 = BinaryPath32.Trim();
- // Clean up cases where the binary is quoted (also breaks permission checker)
- if (BinaryPath32.StartsWith("\"") && BinaryPath32.EndsWith("\""))
- {
- BinaryPath32 = BinaryPath32.AsSpan().Slice(1, BinaryPath32.Length - 2).ToString();
- }
- // Unqualified binary name probably comes from Windows\System32
- if (!BinaryPath32.Contains("\\") && !BinaryPath32.Contains("%"))
- {
- BinaryPath32 = Path.Combine(Environment.SystemDirectory, BinaryPath32.Trim());
- }
-
- comObject.x86_Binary = fsc.FilePathToFileSystemObject(BinaryPath32.Trim());
- }
- }
- }
+ comObject.x86_Binary = binary;
}
- if (ComDetails.Contains("InprocServer64"))
+ else
{
- var ComKey = CurrentKey.OpenSubKey(ComDetails);
- if (ComKey is not null)
- {
- var obj = RegistryWalker.RegistryKeyToRegistryObject(ComKey, View);
- string? BinaryPath64 = null;
-
- if (obj != null && obj.Values?.TryGetValue("", out BinaryPath64) is bool successful)
- {
- if (successful && BinaryPath64 != null)
- {
- // Clean up cases where some extra spaces are thrown into the start
- // (breaks our permission checker)
- BinaryPath64 = BinaryPath64.Trim();
- // Clean up cases where the binary is quoted (also breaks permission checker)
- if (BinaryPath64.StartsWith("\"") && BinaryPath64.EndsWith("\""))
- {
- BinaryPath64 = BinaryPath64.AsSpan().Slice(1, BinaryPath64.Length - 2).ToString();
- }
- // Unqualified binary name probably comes from Windows\System32
- if (!BinaryPath64.Contains("\\") && !BinaryPath64.Contains("%"))
- {
- BinaryPath64 = Path.Combine(Environment.SystemDirectory, BinaryPath64.Trim());
- }
-
- comObject.x64_Binary = fsc.FilePathToFileSystemObject(BinaryPath64.Trim());
- }
- }
- }
+ comObject.x64_Binary = binary;
}
}
@@ -157,6 +113,81 @@ public override bool CanRunOnPlatform()
return RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
}
+ ///
+ /// The subkeys of a CLSID that name the server implementing it, in the order they are preferred.
+ /// An in-process server is listed first because a DLL loaded into the calling process is the more
+ /// interesting load point.
+ ///
+ ///
+ /// There is no InprocServer64 key; bitness is selected by the registry view.
+ ///
+ private static readonly string[] ServerSubKeyNames = { "InprocServer32", "LocalServer32", "LocalServer" };
+
+ ///
+ /// Reads the default value of the first server subkey present under a CLSID and resolves it to a
+ /// file on disk.
+ ///
+ ///
+ /// A server registered on another machine is reported by path only unless is set. Collecting its metadata would connect to a host named by
+ /// whoever could write the CLSID, as the account running the collection.
+ ///
+ private static FileSystemObject? ResolveServerBinary(RegistryKey clsidKey, RegistryView view, FileSystemCollector fsc, bool followNetworkPaths)
+ {
+ string[] subKeyNames;
+
+ try
+ {
+ subKeyNames = clsidKey.GetSubKeyNames();
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to enumerate subkeys of {0} ({1}:{2})", clsidKey.Name, e.GetType(), e.Message);
+ return null;
+ }
+
+ foreach (var serverName in ServerSubKeyNames)
+ {
+ var match = Array.Find(subKeyNames, name => name.Equals(serverName, StringComparison.OrdinalIgnoreCase));
+ if (match is null)
+ {
+ continue;
+ }
+
+ using var serverKey = clsidKey.OpenSubKey(match);
+ if (serverKey is null)
+ {
+ continue;
+ }
+
+ var serverObj = RegistryWalker.RegistryKeyToRegistryObject(serverKey, view);
+ if (serverObj?.Values is null
+ || !serverObj.Values.TryGetValue(string.Empty, out var raw)
+ || string.IsNullOrWhiteSpace(raw))
+ {
+ continue;
+ }
+
+ // LocalServer values are command lines, not bare paths.
+ var path = serverName.StartsWith("LocalServer", StringComparison.OrdinalIgnoreCase)
+ ? RegistryReferenceParser.ExtractExecutablePath(raw)
+ : RegistryReferenceParser.NormalizePath(raw);
+
+ if (path is not null)
+ {
+ if (!followNetworkPaths && PathUtils.IsNetworkPath(path))
+ {
+ Log.Verbose("Not resolving network COM server path {0} for {1}. Pass --follow-network-paths to include it.", path, clsidKey.Name);
+ return new FileSystemObject(path);
+ }
+
+ return fsc.FilePathToFileSystemObject(path);
+ }
+ }
+
+ return null;
+ }
+
///
/// Execute the Com Collector. We collect the list of Com Objects registered in the registry and
/// then examine each binary on the disk they point to.
@@ -183,7 +214,7 @@ internal void ParseView(RegistryView view, CancellationToken cancellationToken)
var CLSIDs = SearchKey.OpenSubKey("SOFTWARE\\Classes\\CLSID");
if (CLSIDs is not null)
{
- foreach (var comObj in ParseComObjects(CLSIDs, view, opts.SingleThread))
+ foreach (var comObj in ParseComObjects(CLSIDs, view, opts.SingleThread, opts.FollowNetworkPaths))
{
if (cancellationToken.IsCancellationRequested) { return; }
HandleChange(comObj);
@@ -212,7 +243,7 @@ e is ArgumentException
using var ComKey = SearchKey.OpenSubKey(subkeyName)?.OpenSubKey("CLSID");
if (ComKey is not null)
{
- foreach (var comObj in ParseComObjects(ComKey, view, opts.SingleThread))
+ foreach (var comObj in ParseComObjects(ComKey, view, opts.SingleThread, opts.FollowNetworkPaths))
{
HandleChange(comObj);
}
diff --git a/Lib/Collectors/LoadPointCollector.cs b/Lib/Collectors/LoadPointCollector.cs
new file mode 100644
index 000000000..9b1b5e327
--- /dev/null
+++ b/Lib/Collectors/LoadPointCollector.cs
@@ -0,0 +1,559 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using Microsoft.CST.AttackSurfaceAnalyzer.Objects;
+using Microsoft.CST.AttackSurfaceAnalyzer.Utils;
+using Microsoft.Win32;
+using Serilog;
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Threading;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Collectors
+{
+ ///
+ /// How the data stored in a load point's registry value should be read.
+ ///
+ public enum LoadPointTargetKind
+ {
+ ///
+ /// The value holds one or more file paths.
+ ///
+ Path,
+
+ ///
+ /// The value holds a command line; the executable is taken from the front of it.
+ ///
+ CommandLine,
+
+ ///
+ /// The value references CLSIDs, which are resolved through Classes\CLSID to a binary.
+ ///
+ Clsid,
+
+ ///
+ /// CLSIDs if the value contains any, otherwise file paths.
+ ///
+ Auto
+ }
+
+ ///
+ /// A value, or set of values, under a load point key that names code to load.
+ ///
+ public sealed class LoadPointValueSource
+ {
+ public LoadPointValueSource(string? subKey, string? valueName, LoadPointTargetKind kind)
+ {
+ SubKey = subKey;
+ ValueName = valueName;
+ Kind = kind;
+ }
+
+ ///
+ /// Subkey holding the value, relative to the unit being examined. Null means the unit key itself.
+ ///
+ public string? SubKey { get; }
+
+ ///
+ /// The value to read. Empty string is the key's default value; null means every value.
+ ///
+ public string? ValueName { get; }
+
+ public LoadPointTargetKind Kind { get; }
+ }
+
+ ///
+ /// A place in the registry the operating system reads to decide what code to load.
+ ///
+ public sealed class LoadPointDefinition
+ {
+ public LoadPointDefinition(string name, RegistryHive hive, string keyPath, bool enumerateSubKeys, params LoadPointValueSource[] sources)
+ {
+ Name = name;
+ Hive = hive;
+ KeyPath = keyPath;
+ EnumerateSubKeys = enumerateSubKeys;
+ Sources = new ReadOnlyCollection(sources);
+ }
+
+ ///
+ /// Reported as .
+ ///
+ public string Name { get; }
+
+ public RegistryHive Hive { get; }
+
+ public string KeyPath { get; }
+
+ ///
+ /// When true each immediate subkey of is a load point in its own right,
+ /// as with a CLSID or a service. When false the key itself is the load point.
+ ///
+ public bool EnumerateSubKeys { get; }
+
+ public IReadOnlyList Sources { get; }
+ }
+
+ ///
+ /// Collects registry load points joined to the binaries they resolve to, so that an unprivileged
+ /// user's ability to modify either end can be asserted by a single analysis rule.
+ ///
+ public class LoadPointCollector : BaseCollector
+ {
+ public LoadPointCollector(CollectorOptions? opts = null, Action? changeHandler = null, IEnumerable? definitions = null)
+ : base(opts, changeHandler)
+ {
+ _definitions = definitions?.ToList() ?? DefaultDefinitions.ToList();
+ }
+
+ ///
+ /// The load points scanned unless the caller supplies its own set. Adding coverage is a matter of
+ /// adding an entry here rather than adding a branch to the collector.
+ ///
+ public static IReadOnlyList DefaultDefinitions { get; } = new ReadOnlyCollection(new[]
+ {
+ new LoadPointDefinition("ComServer", RegistryHive.LocalMachine, ClsidKeyPath, true,
+ new LoadPointValueSource("InprocServer32", string.Empty, LoadPointTargetKind.Path),
+ new LoadPointValueSource("LocalServer32", string.Empty, LoadPointTargetKind.CommandLine),
+ new LoadPointValueSource("LocalServer", string.Empty, LoadPointTargetKind.CommandLine)),
+
+ // The key that CVE-2026-50343 abuses: its DACL grants INTERACTIVE SetValue and CreateSubKey, and
+ // the plugin IDs it maps are CoCreateInstance'd by a SYSTEM svchost.
+ new LoadPointDefinition("StaticPluginMap", RegistryHive.LocalMachine, @"SOFTWARE\Microsoft\Windows\CurrentVersion\InstallService\State", false,
+ new LoadPointValueSource(null, null, LoadPointTargetKind.Auto)),
+
+ new LoadPointDefinition("AppInit_DLLs", RegistryHive.LocalMachine, @"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows", false,
+ new LoadPointValueSource(null, "AppInit_DLLs", LoadPointTargetKind.Path)),
+
+ new LoadPointDefinition("Service", RegistryHive.LocalMachine, @"SYSTEM\CurrentControlSet\Services", true,
+ new LoadPointValueSource(null, "ImagePath", LoadPointTargetKind.CommandLine),
+ new LoadPointValueSource("Parameters", "ServiceDll", LoadPointTargetKind.Path)),
+ });
+
+ ///
+ /// Registry load points only exist on Windows.
+ ///
+ public override bool CanRunOnPlatform() => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+
+ internal override void ExecuteInternal(CancellationToken cancellationToken)
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
+ {
+ foreach (var definition in _definitions)
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ foreach (var loadPoint in ParseDefinition(definition, view, cancellationToken))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return;
+ }
+
+ HandleChange(loadPoint);
+ }
+ }
+ }
+ }
+
+ ///
+ /// Expands a single load point definition in one registry view.
+ ///
+ [SupportedOSPlatform("windows")]
+ public IEnumerable ParseDefinition(LoadPointDefinition definition, RegistryView view, CancellationToken cancellationToken = default)
+ {
+ if (definition is null)
+ {
+ throw new ArgumentNullException(nameof(definition));
+ }
+
+ RegistryKey? baseKey = null;
+ RegistryKey? rootKey = null;
+
+ try
+ {
+ baseKey = RegistryKey.OpenBaseKey(definition.Hive, view);
+ rootKey = baseKey.OpenSubKey(definition.KeyPath);
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to open {0}\\{1} ({2}:{3})", definition.Hive, definition.KeyPath, e.GetType(), e.Message);
+ }
+
+ if (rootKey is null)
+ {
+ baseKey?.Dispose();
+ return Array.Empty();
+ }
+
+ List results = new();
+ var fsc = new FileSystemCollector(new CollectorOptions() { SingleThread = true });
+
+ try
+ {
+ if (definition.EnumerateSubKeys)
+ {
+ foreach (var subKeyName in SafeGetSubKeyNames(rootKey))
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ break;
+ }
+
+ using var unit = SafeOpenSubKey(rootKey, subKeyName);
+ if (unit is not null)
+ {
+ results.AddRange(ParseUnit(definition, unit, view, baseKey, fsc));
+ }
+ }
+ }
+ else
+ {
+ results.AddRange(ParseUnit(definition, rootKey, view, baseKey, fsc));
+ }
+ }
+ finally
+ {
+ rootKey.Dispose();
+ baseKey?.Dispose();
+ }
+
+ return results;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private IEnumerable ParseUnit(LoadPointDefinition definition, RegistryKey unit, RegistryView view, RegistryKey? hiveRoot, FileSystemCollector fsc)
+ {
+ List results = new();
+
+ foreach (var source in definition.Sources)
+ {
+ RegistryKey? valueKey = unit;
+ RegistryKey? opened = null;
+
+ if (source.SubKey is not null)
+ {
+ opened = SafeOpenSubKey(unit, source.SubKey);
+ valueKey = opened;
+ }
+
+ if (valueKey is null)
+ {
+ continue;
+ }
+
+ try
+ {
+ var sourceObj = RegistryWalker.RegistryKeyToRegistryObject(valueKey, view);
+ if (sourceObj?.Values is null || sourceObj.Values.Count == 0)
+ {
+ continue;
+ }
+
+ var sourceWritable = PermissionUtils.IsUserWritable(sourceObj.Permissions);
+
+ foreach (var entry in SelectValues(sourceObj.Values, source.ValueName))
+ {
+ foreach (var loadPoint in ResolveReferences(definition, source, sourceObj, sourceWritable, entry.Key, entry.Value, view, hiveRoot, fsc))
+ {
+ results.Add(loadPoint);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to parse load point {0} under {1} ({2}:{3})", definition.Name, unit.Name, e.GetType(), e.Message);
+ }
+ finally
+ {
+ opened?.Dispose();
+ }
+ }
+
+ return results;
+ }
+
+ private static IEnumerable> SelectValues(Dictionary values, string? valueName)
+ {
+ if (valueName is null)
+ {
+ return values;
+ }
+
+ return values.TryGetValue(valueName, out var data)
+ ? new[] { new KeyValuePair(valueName, data) }
+ : Array.Empty>();
+ }
+
+ [SupportedOSPlatform("windows")]
+ private IEnumerable ResolveReferences(
+ LoadPointDefinition definition,
+ LoadPointValueSource source,
+ RegistryObject sourceObj,
+ bool sourceWritable,
+ string valueName,
+ string valueData,
+ RegistryView view,
+ RegistryKey? hiveRoot,
+ FileSystemCollector fsc)
+ {
+ if (string.IsNullOrWhiteSpace(valueData))
+ {
+ yield break;
+ }
+
+ var kind = source.Kind;
+ IReadOnlyList clsids = Array.Empty();
+
+ if (kind is LoadPointTargetKind.Clsid or LoadPointTargetKind.Auto)
+ {
+ clsids = RegistryReferenceParser.ExtractClsids(valueData).ToList();
+ if (clsids.Count > 0)
+ {
+ kind = LoadPointTargetKind.Clsid;
+ }
+ else if (kind == LoadPointTargetKind.Auto)
+ {
+ kind = LoadPointTargetKind.Path;
+ }
+ }
+
+ var origin = $"{sourceObj.Key}:{(string.IsNullOrEmpty(valueName) ? "(default)" : valueName)}";
+
+ if (kind == LoadPointTargetKind.Clsid)
+ {
+ foreach (var clsid in clsids)
+ {
+ var (path, chain) = ResolveClsid(clsid, hiveRoot, view);
+ var loadPoint = NewLoadPoint(definition, sourceObj, sourceWritable, valueName, valueData, view);
+ loadPoint.TargetClsid = clsid;
+ loadPoint.ResolutionChain.Add(origin);
+ loadPoint.ResolutionChain.AddRange(chain);
+ PopulateTarget(loadPoint, path, fsc);
+ yield return loadPoint;
+ }
+
+ yield break;
+ }
+
+ IEnumerable paths = kind == LoadPointTargetKind.CommandLine
+ ? new[] { RegistryReferenceParser.ExtractExecutablePath(valueData) }
+ : RegistryReferenceParser.ExtractPaths(valueData);
+
+ var any = false;
+
+ foreach (var path in paths)
+ {
+ if (path is null)
+ {
+ continue;
+ }
+
+ any = true;
+ var loadPoint = NewLoadPoint(definition, sourceObj, sourceWritable, valueName, valueData, view);
+ loadPoint.ResolutionChain.Add(origin);
+ PopulateTarget(loadPoint, path, fsc);
+ yield return loadPoint;
+ }
+
+ // A value that is a bare, unrooted binary name still names something the loader will find.
+ if (!any && kind == LoadPointTargetKind.Path)
+ {
+ var fallback = RegistryReferenceParser.NormalizePath(valueData);
+ if (fallback is not null)
+ {
+ var loadPoint = NewLoadPoint(definition, sourceObj, sourceWritable, valueName, valueData, view);
+ loadPoint.ResolutionChain.Add(origin);
+ PopulateTarget(loadPoint, fallback, fsc);
+ yield return loadPoint;
+ }
+ }
+ }
+
+ ///
+ /// Follows a CLSID to the binary that implements it. This indirection is the point of the
+ /// collector: the key an attacker can write names a CLSID, and only the CLSID names the DLL.
+ ///
+ [SupportedOSPlatform("windows")]
+ private static (string? Path, List Chain) ResolveClsid(string clsid, RegistryKey? hiveRoot, RegistryView view)
+ {
+ List chain = new();
+
+ if (hiveRoot is null)
+ {
+ return (null, chain);
+ }
+
+ foreach (var serverName in ClsidServerSubKeys)
+ {
+ var keyPath = $@"{ClsidKeyPath}\{clsid}\{serverName}";
+ using var serverKey = SafeOpenSubKey(hiveRoot, keyPath);
+ if (serverKey is null)
+ {
+ continue;
+ }
+
+ var serverObj = RegistryWalker.RegistryKeyToRegistryObject(serverKey, view);
+ if (serverObj?.Values is null
+ || !serverObj.Values.TryGetValue(string.Empty, out var raw)
+ || string.IsNullOrWhiteSpace(raw))
+ {
+ continue;
+ }
+
+ var path = serverName.StartsWith("LocalServer", StringComparison.OrdinalIgnoreCase)
+ ? RegistryReferenceParser.ExtractExecutablePath(raw)
+ : RegistryReferenceParser.NormalizePath(raw);
+
+ chain.Add($@"{hiveRoot.Name}\{keyPath}:(default)");
+ return (path, chain);
+ }
+
+ chain.Add($@"{hiveRoot.Name}\{ClsidKeyPath}\{clsid} (unresolved)");
+ return (null, chain);
+ }
+
+ private static LoadPointObject NewLoadPoint(LoadPointDefinition definition, RegistryObject sourceObj, bool sourceWritable, string valueName, string valueData, RegistryView view)
+ => new(definition.Name, sourceObj)
+ {
+ SourceValueName = valueName,
+ SourceValueData = valueData.Length > MaxRetainedValueLength
+ ? valueData.Substring(0, MaxRetainedValueLength)
+ : valueData,
+ SourceKeyUserWritable = sourceWritable,
+ View = view,
+ };
+
+ ///
+ /// Records what the load point resolves to and who can write it. A target that does not exist is
+ /// reported as such and judged by the ACL of the directory that would receive it, which is the
+ /// exploitable shape this collector exists to find.
+ ///
+ [SupportedOSPlatform("windows")]
+ private void PopulateTarget(LoadPointObject loadPoint, string? path, FileSystemCollector fsc)
+ {
+ loadPoint.TargetPath = path;
+
+ if (string.IsNullOrEmpty(path))
+ {
+ loadPoint.TargetAclSource = "None";
+ return;
+ }
+
+ loadPoint.TargetIsNetworkPath = PathUtils.IsNetworkPath(path);
+
+ // A load point can name a share on another machine, and whoever can write the value picks which
+ // machine. Resolving it would connect to that host as the account running the collection, so the
+ // path is recorded and left alone unless it was asked for.
+ if (loadPoint.TargetIsNetworkPath && !opts.FollowNetworkPaths)
+ {
+ Log.Verbose("Not resolving network load point target {0}. Pass --follow-network-paths to include it.", path);
+ loadPoint.TargetAclSource = "None";
+ loadPoint.TargetAclUnavailable = true;
+ return;
+ }
+
+ try
+ {
+ loadPoint.TargetExists = File.Exists(path) || Directory.Exists(path);
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to test existence of {0} ({1}:{2})", path, e.GetType(), e.Message);
+ }
+
+ if (loadPoint.TargetExists)
+ {
+ loadPoint.Target = fsc.FilePathToFileSystemObject(path!);
+ loadPoint.TargetAclSource = "Target";
+ loadPoint.TargetUserWritable = TryIsUserWritable(path!, out var unavailable);
+ loadPoint.TargetAclUnavailable = unavailable;
+ return;
+ }
+
+ var parent = PermissionUtils.NearestExistingParent(path);
+ if (parent is null)
+ {
+ loadPoint.TargetAclSource = "None";
+ loadPoint.TargetAclUnavailable = true;
+ return;
+ }
+
+ loadPoint.NearestExistingParentPath = parent;
+ loadPoint.NearestExistingParent = fsc.FilePathToFileSystemObject(parent);
+ loadPoint.TargetAclSource = "NearestExistingParent";
+ loadPoint.TargetUserWritable = TryIsUserWritable(parent, out var parentUnavailable);
+ loadPoint.TargetAclUnavailable = parentUnavailable;
+ }
+
+ [SupportedOSPlatform("windows")]
+ private static bool TryIsUserWritable(string path, out bool unavailable)
+ {
+ try
+ {
+ FileSystemSecurity security = Directory.Exists(path)
+ ? new DirectoryInfo(path).GetAccessControl(AccessControlSections.Access)
+ : new FileInfo(path).GetAccessControl(AccessControlSections.Access);
+
+ unavailable = false;
+ return PermissionUtils.IsUserWritable(security);
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to read ACL of {0} ({1}:{2})", path, e.GetType(), e.Message);
+ unavailable = true;
+ return false;
+ }
+ }
+
+ private static string[] SafeGetSubKeyNames(RegistryKey key)
+ {
+ try
+ {
+ return key.GetSubKeyNames();
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to enumerate subkeys of {0} ({1}:{2})", key.Name, e.GetType(), e.Message);
+ return Array.Empty();
+ }
+ }
+
+ private static RegistryKey? SafeOpenSubKey(RegistryKey key, string name)
+ {
+ try
+ {
+ return key.OpenSubKey(name);
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to open {0}\\{1} ({2}:{3})", key.Name, name, e.GetType(), e.Message);
+ return null;
+ }
+ }
+
+ private const string ClsidKeyPath = @"SOFTWARE\Classes\CLSID";
+
+ private static readonly string[] ClsidServerSubKeys = { "InprocServer32", "LocalServer32", "LocalServer" };
+
+ ///
+ /// Load point values such as StaticPluginMap can be large; only enough to identify what was
+ /// written is retained.
+ ///
+ private const int MaxRetainedValueLength = 512;
+
+ private readonly List _definitions;
+ }
+}
diff --git a/Lib/Objects/AsaRule.cs b/Lib/Objects/AsaRule.cs
index 7fb8abc7b..36e2bb0df 100644
--- a/Lib/Objects/AsaRule.cs
+++ b/Lib/Objects/AsaRule.cs
@@ -70,6 +70,9 @@ public RESULT_TYPE ResultType
case RESULT_TYPE.KEY:
return typeof(CryptographicKeyObject).Name;
+ case RESULT_TYPE.LOADPOINT:
+ return typeof(LoadPointObject).Name;
+
case RESULT_TYPE.LOG:
return typeof(EventLogObject).Name;
diff --git a/Lib/Objects/CommandOptions.cs b/Lib/Objects/CommandOptions.cs
index b9df8eb54..e2fbf1f7d 100644
--- a/Lib/Objects/CommandOptions.cs
+++ b/Lib/Objects/CommandOptions.cs
@@ -32,6 +32,7 @@ public static CollectCommandOptions FromCollectorOptions(CollectorOptions opts)
EnableFileSystemCollector = opts.EnableFileSystemCollector,
EnableFirewallCollector = opts.EnableFirewallCollector,
EnableKeyCollector = opts.EnableKeyCollector,
+ EnableLoadPointCollector = opts.EnableLoadPointCollector,
EnableNetworkPortCollector = opts.EnableNetworkPortCollector,
EnableProcessCollector = opts.EnableProcessCollector,
EnableRegistryCollector = opts.EnableRegistryCollector,
@@ -39,6 +40,7 @@ public static CollectCommandOptions FromCollectorOptions(CollectorOptions opts)
EnableTpmCollector = opts.EnableTpmCollector,
EnableUserCollector = opts.EnableUserCollector,
EnableWifiCollector = opts.EnableWifiCollector,
+ FollowNetworkPaths = opts.FollowNetworkPaths,
GatherHashes = opts.GatherHashes,
GatherVerboseLogs = opts.GatherVerboseLogs,
GatherWifiPasswords = opts.GatherWifiPasswords,
@@ -85,6 +87,9 @@ public class CollectorOptions : CommandOptions
[Option('k', "keys", Required = false, HelpText = "Gather information about the cryptographic keys on the system.")]
public bool EnableKeyCollector { get; set; }
+ [Option('L', "load-points", Required = false, HelpText = "Enable the load point collector, which joins registry load points to the binaries they resolve to")]
+ public bool EnableLoadPointCollector { get; set; }
+
[Option('p', "network-port", Required = false, HelpText = "Enable the network port collector")]
public bool EnableNetworkPortCollector { get; set; }
@@ -106,6 +111,9 @@ public class CollectorOptions : CommandOptions
[Option('w', "wifi", Required = false, HelpText = "Enable the saved Wifi information collector")]
public bool EnableWifiCollector { get; set; }
+ [Option("follow-network-paths", Required = false, HelpText = "Resolve load point and COM server paths that name network locations (UNC paths or mapped network drives). Off by default: reaching one connects to a host named by whoever could write the registry value and authenticates as the account running the collection.")]
+ public bool FollowNetworkPaths { get; set; }
+
[Option('h', "gather-hashes", Required = false, HelpText = "Hashes every file when using the File Collector. May dramatically increase run time of the scan.")]
public bool GatherHashes { get; set; }
diff --git a/Lib/Objects/LoadPointObject.cs b/Lib/Objects/LoadPointObject.cs
new file mode 100644
index 000000000..68bd07220
--- /dev/null
+++ b/Lib/Objects/LoadPointObject.cs
@@ -0,0 +1,139 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using Microsoft.CST.AttackSurfaceAnalyzer.Types;
+using Microsoft.Win32;
+using System.Collections.Generic;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Objects
+{
+ ///
+ /// A place where the operating system is told to load code, joined at collection time to the binary
+ /// it resolves to.
+ ///
+ ///
+ ///
+ /// Analysis rules are evaluated against the before and after states of a single object and cannot
+ /// reach a second one, so a rule over a can never ask about the
+ /// file that key points at. This object performs that join during collection: it carries the
+ /// source key and its ACL, the resolved target path, the target's ACL, and flat summary flags a
+ /// rule can test directly.
+ ///
+ ///
+ /// Summary fields are deliberately booleans and strings. Dotted field navigation in rules is
+ /// shallow and regex cannot match dictionary-typed fields, so nested structures are not
+ /// interrogable.
+ ///
+ ///
+ public class LoadPointObject : CollectObject
+ {
+ public LoadPointObject(string LoadPointType, RegistryObject SourceKey)
+ {
+ this.LoadPointType = LoadPointType;
+ this.SourceKey = SourceKey;
+ }
+
+ public override RESULT_TYPE ResultType => RESULT_TYPE.LOADPOINT;
+
+ ///
+ /// A load point is identified by the key and value it came from together with what that resolved
+ /// to, so that two plugins registered under one key remain distinct objects.
+ ///
+ public override string Identity => $"{LoadPointType}_{SourceKey.Identity}_{SourceValueName}_{TargetClsid}_{TargetPath}";
+
+ ///
+ /// Which kind of load point this is, e.g. ComServer, StaticPluginMap, AppInit_DLLs, Service.
+ ///
+ public string LoadPointType { get; set; }
+
+ ///
+ /// The registry key that names the code to load, including its ACL.
+ ///
+ public RegistryObject SourceKey { get; set; }
+
+ ///
+ /// The value under this load point came from. Empty for a key's default
+ /// value.
+ ///
+ public string? SourceValueName { get; set; }
+
+ ///
+ /// The raw value data before expansion, retained so a rule can see what was actually written.
+ ///
+ public string? SourceValueData { get; set; }
+
+ ///
+ /// True when can be modified by an unprivileged user, meaning an
+ /// attacker can repoint this load point at code of their choosing.
+ ///
+ public bool SourceKeyUserWritable { get; set; }
+
+ ///
+ /// The steps taken to get from the source key to the target binary, for triage.
+ ///
+ public List ResolutionChain { get; set; } = new List();
+
+ ///
+ /// The CLSID this load point resolved through, if it referenced one.
+ ///
+ public string? TargetClsid { get; set; }
+
+ ///
+ /// The resolved path of the binary that will be loaded.
+ ///
+ public string? TargetPath { get; set; }
+
+ ///
+ /// The target binary, including its ACL. Null when the target does not exist.
+ ///
+ public FileSystemObject? Target { get; set; }
+
+ ///
+ /// Whether the target binary is present on disk. A missing target at a path an unprivileged user
+ /// can write is the exploitable case, so this is asserted directly rather than inferred from a
+ /// null Target, which is also what a failed collection produces.
+ ///
+ public bool TargetExists { get; set; }
+
+ ///
+ /// True when the target's ACL could not be read at all, so is
+ /// not a statement about the target's security.
+ ///
+ public bool TargetAclUnavailable { get; set; }
+
+ ///
+ /// Whether describes the target itself or the directory that
+ /// would receive it: Target, NearestExistingParent, or None.
+ ///
+ public string TargetAclSource { get; set; } = "None";
+
+ ///
+ /// True when the target names a location on another machine, either a UNC path or a path through
+ /// a mapped network drive. The target is reported but is not resolved unless collection was asked
+ /// to follow network paths, because reaching it connects to a host named by whoever could write
+ /// the source key and authenticates as the account running the collection.
+ ///
+ public bool TargetIsNetworkPath { get; set; }
+
+ ///
+ /// True when an unprivileged user can write the target binary, or when the target is missing and
+ /// an unprivileged user can create it in the nearest existing parent directory.
+ ///
+ public bool TargetUserWritable { get; set; }
+
+ ///
+ /// The closest ancestor directory of that exists on disk. Populated
+ /// when the target is missing, because that directory's ACL is what decides whether an attacker
+ /// can supply the file.
+ ///
+ public string? NearestExistingParentPath { get; set; }
+
+ ///
+ /// The nearest existing parent directory, including its ACL.
+ ///
+ public FileSystemObject? NearestExistingParent { get; set; }
+
+ ///
+ /// The registry view this load point was collected from.
+ ///
+ public RegistryView View { get; set; }
+ }
+}
diff --git a/Lib/Objects/RegistryObject.cs b/Lib/Objects/RegistryObject.cs
index f7f56a116..adb286692 100644
--- a/Lib/Objects/RegistryObject.cs
+++ b/Lib/Objects/RegistryObject.cs
@@ -31,8 +31,32 @@ public override string Identity
public string Key { get; set; }
public Dictionary> Permissions { get; set; } = new Dictionary>();
+
+ ///
+ /// The key's security descriptor in SDDL form.
+ ///
+ ///
+ /// A flat string, so analysis rules can match ACE patterns against it with Regex. The
+ /// Permissions dictionary cannot be matched that way: OAT's regex operation discards the
+ /// dictionary half of a field's values.
+ ///
public string? PermissionsString { get; set; }
+ ///
+ /// CLSID-shaped GUIDs referenced by this key's values, in braced uppercase form.
+ ///
+ ///
+ /// Pre-parsed at collection time because analysis rules cannot follow a reference from one
+ /// object to another. A List<string> so that Regex, Contains, StartsWith, and EndsWith all
+ /// work against it.
+ ///
+ public List ReferencedClsids { get; set; } = new List();
+
+ ///
+ /// File paths referenced by this key's values, environment-expanded and normalized.
+ ///
+ public List ReferencedPaths { get; set; } = new List();
+
public int SubkeyCount
{
get { return Subkeys?.Count ?? 0; }
diff --git a/Lib/Objects/Types.cs b/Lib/Objects/Types.cs
index 1c05dd55d..ea8e834c3 100644
--- a/Lib/Objects/Types.cs
+++ b/Lib/Objects/Types.cs
@@ -303,7 +303,12 @@ public enum RESULT_TYPE
///
/// A wifi network
///
- WIFI
+ WIFI,
+
+ ///
+ /// See LoadPointObject
+ ///
+ LOADPOINT
};
///
diff --git a/Lib/Utils/JsonUtils.cs b/Lib/Utils/JsonUtils.cs
index 9e66e6282..e1613892a 100644
--- a/Lib/Utils/JsonUtils.cs
+++ b/Lib/Utils/JsonUtils.cs
@@ -87,6 +87,10 @@ public static string Dehydrate(CollectObject colObj)
return JsonConvert.DeserializeObject(serialized, jsonSettings);
case RESULT_TYPE.FILEMONITOR:
return JsonConvert.DeserializeObject(serialized, jsonSettings);
+
+ case RESULT_TYPE.LOADPOINT:
+ return JsonConvert.DeserializeObject(serialized, jsonSettings);
+
default:
return null;
}
diff --git a/Lib/Utils/PathUtils.cs b/Lib/Utils/PathUtils.cs
new file mode 100644
index 000000000..bbdbc328a
--- /dev/null
+++ b/Lib/Utils/PathUtils.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using Serilog;
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Utils
+{
+ ///
+ /// Decides whether a path names something on this machine.
+ ///
+ ///
+ ///
+ /// Paths recovered from the registry are not necessarily local, and whoever can write the value
+ /// chooses which machine they name. Resolving one is not a passive read: it opens a session to
+ /// that host and authenticates as the account running the collection, which for most of these
+ /// collectors is an administrator, and then reads content that host controls. That is a surprise
+ /// for anyone who asked only for a local snapshot.
+ ///
+ ///
+ /// Collectors therefore ask this before they touch a path, in the same way the file system
+ /// collector asks whether a file is a cloud placeholder before hydrating it.
+ ///
+ ///
+ public static class PathUtils
+ {
+ ///
+ /// Whether resolving this path would reach off the machine, through either a UNC path or a drive
+ /// letter mapped to a network share. Answered from the path itself and the local mount table, so
+ /// asking never touches the network.
+ ///
+ public static bool IsNetworkPath(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return false;
+ }
+
+ var candidate = path!.Trim();
+
+ // \\?\ and \\.\ turn off path parsing; what follows the prefix is what is named. \\?\UNC\server\share
+ // is the extended-length spelling of \\server\share.
+ if (candidate.StartsWith(@"\\?\", StringComparison.Ordinal)
+ || candidate.StartsWith(@"\\.\", StringComparison.Ordinal))
+ {
+ candidate = candidate.Substring(4);
+
+ if (candidate.StartsWith(@"UNC\", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+ else if (candidate.StartsWith(@"\\", StringComparison.Ordinal))
+ {
+ return true;
+ }
+
+ return IsNetworkDrive(candidate);
+ }
+
+ ///
+ /// Whether a drive-letter rooted path resolves through a mapped network drive. A UNC path reached
+ /// this way is indistinguishable from a local one by inspection, so the drive itself is asked.
+ ///
+ private static bool IsNetworkDrive(string path)
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || path.Length < 2 || path[1] != ':')
+ {
+ return false;
+ }
+
+ try
+ {
+ return new DriveInfo(path.Substring(0, 1)).DriveType == DriveType.Network;
+ }
+ catch (Exception e)
+ {
+ // An unusable drive letter is not evidence that the path is remote. The caller's own error
+ // handling deals with it when the path fails to resolve.
+ Log.Verbose("Failed to determine the drive type of {0} ({1}:{2})", path, e.GetType(), e.Message);
+ return false;
+ }
+ }
+ }
+}
diff --git a/Lib/Utils/PermissionUtils.cs b/Lib/Utils/PermissionUtils.cs
new file mode 100644
index 000000000..33c0ed223
--- /dev/null
+++ b/Lib/Utils/PermissionUtils.cs
@@ -0,0 +1,311 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Security.AccessControl;
+using System.Security.Principal;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Utils
+{
+ ///
+ /// The single definition of "writable by an unprivileged user" used by the load point analysis.
+ ///
+ ///
+ ///
+ /// A securable object counts as user-writable when a principal that every unprivileged
+ /// interactive user belongs to is granted a right that permits planting or replacing content:
+ ///
+ ///
+ /// - S-1-1-0 Everyone
+ /// - S-1-5-4 NT AUTHORITY\INTERACTIVE
+ /// - S-1-5-11 NT AUTHORITY\Authenticated Users
+ /// - S-1-5-32-545 BUILTIN\Users
+ ///
+ ///
+ /// Deny takes precedence over Allow, per principal. This is the canonical simplification of
+ /// Windows access evaluation; it ignores ACE ordering, which cannot change the outcome for a
+ /// canonical ACL.
+ ///
+ ///
+ /// Principals are matched by SID and by translated account name because resolves S-1-5 SIDs to NTAccount form but leaves others (such
+ /// as Everyone, S-1-1-0) as raw SID strings.
+ ///
+ ///
+ public static class PermissionUtils
+ {
+ ///
+ /// Whether the principal is one every unprivileged interactive user belongs to.
+ ///
+ public static bool IsUnprivilegedPrincipal(string? principal)
+ {
+ if (string.IsNullOrWhiteSpace(principal))
+ {
+ return false;
+ }
+
+ var trimmed = principal.Trim();
+
+ if (UnprivilegedPrincipals.Contains(trimmed))
+ {
+ return true;
+ }
+
+ // Tolerate a domain-qualified form we do not have an exact entry for.
+ var separator = trimmed.LastIndexOf('\\');
+ return separator >= 0 && UnprivilegedPrincipals.Contains(trimmed.Substring(separator + 1));
+ }
+
+ ///
+ /// Whether a registry right name permits writing to the key.
+ ///
+ public static bool IsRegistryWriteRight(string? right)
+ => right is not null && RegistryWriteRights.Contains(right.Trim());
+
+ ///
+ /// Whether a file system right name permits creating or replacing content.
+ ///
+ public static bool IsFileWriteRight(string? right)
+ => right is not null && FileWriteRights.Contains(right.Trim());
+
+ ///
+ /// Evaluates a live registry ACL.
+ ///
+ [SupportedOSPlatform("windows")]
+ public static bool IsUserWritable(RegistrySecurity security)
+ {
+ if (security is null)
+ {
+ throw new ArgumentNullException(nameof(security));
+ }
+
+ return Evaluate(security.GetAccessRules(true, true, typeof(SecurityIdentifier))
+ .OfType()
+ .Select(rule => (
+ AsaHelpers.SidToName(rule.IdentityReference),
+ rule.AccessControlType,
+ SplitRights(rule.RegistryRights.ToString()))),
+ IsRegistryWriteRight);
+ }
+
+ ///
+ /// Evaluates a live file system ACL.
+ ///
+ [SupportedOSPlatform("windows")]
+ public static bool IsUserWritable(FileSystemSecurity security)
+ {
+ if (security is null)
+ {
+ throw new ArgumentNullException(nameof(security));
+ }
+
+ return Evaluate(security.GetAccessRules(true, true, typeof(SecurityIdentifier))
+ .OfType()
+ .Select(rule => (
+ AsaHelpers.SidToName(rule.IdentityReference),
+ rule.AccessControlType,
+ SplitRights(rule.FileSystemRights.ToString()))),
+ IsFileWriteRight);
+ }
+
+ ///
+ /// Evaluates the permissions stored on a collected , which
+ /// encode the access control type as an "Allow:Right" / "Deny:Right" prefix.
+ ///
+ public static bool IsUserWritable(Dictionary>? permissions)
+ {
+ if (permissions is null)
+ {
+ return false;
+ }
+
+ return Evaluate(permissions.SelectMany(entry => entry.Value.Select(right =>
+ {
+ var (type, name) = SplitAccessControlType(right);
+ return (entry.Key, type, (IEnumerable)new[] { name });
+ })), IsRegistryWriteRight);
+ }
+
+ ///
+ /// Evaluates the permissions stored on a collected .
+ ///
+ ///
+ /// The file system collector does not record the access control type, so every entry is treated
+ /// as an Allow. Prefer the overload where the live ACL is
+ /// available; it honors Deny.
+ ///
+ public static bool IsUserWritable(Dictionary? permissions)
+ {
+ if (permissions is null)
+ {
+ return false;
+ }
+
+ return Evaluate(permissions.Select(entry =>
+ (entry.Key, AccessControlType.Allow, SplitRights(entry.Value))), IsFileWriteRight);
+ }
+
+ ///
+ /// Walks up from to the closest ancestor that exists on disk. Returns
+ /// null when nothing along the chain exists.
+ ///
+ ///
+ /// When a load point target is missing, the security-relevant ACL is the one on the directory
+ /// that would receive the file.
+ ///
+ public static string? NearestExistingParent(string? path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ {
+ return null;
+ }
+
+ string? current;
+
+ try
+ {
+ current = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(path));
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+
+ while (!string.IsNullOrEmpty(current))
+ {
+ try
+ {
+ if (System.IO.Directory.Exists(current))
+ {
+ return current;
+ }
+
+ var parent = System.IO.Path.GetDirectoryName(current);
+ if (string.Equals(parent, current, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ current = parent;
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ /// Splits a comma-joined combined rights mask, as produced by RegistryRights.ToString() and
+ /// FileSystemRights.ToString(), into individual right names.
+ ///
+ public static IEnumerable SplitRights(string? rights)
+ => string.IsNullOrEmpty(rights)
+ ? Array.Empty()
+ : rights!.Split(',').Select(right => right.Trim());
+
+ ///
+ /// Encodes an access control type and a right name into the form stored on a RegistryObject.
+ ///
+ public static string EncodeRight(AccessControlType type, string right) => $"{type}:{right.Trim()}";
+
+ ///
+ /// Reverses . Entries written before the access control type was
+ /// recorded have no prefix and are read as Allow, which is what they were assumed to be.
+ ///
+ public static (AccessControlType Type, string Right) SplitAccessControlType(string right)
+ {
+ if (right is null)
+ {
+ throw new ArgumentNullException(nameof(right));
+ }
+
+ var separator = right.IndexOf(':');
+ if (separator > 0)
+ {
+ var prefix = right.Substring(0, separator);
+ if (Enum.TryParse(prefix, out var type))
+ {
+ return (type, right.Substring(separator + 1).Trim());
+ }
+ }
+
+ return (AccessControlType.Allow, right.Trim());
+ }
+
+ private static bool Evaluate(
+ IEnumerable<(string Principal, AccessControlType Type, IEnumerable Rights)> aces,
+ Func isWriteRight)
+ {
+ HashSet allowed = new(StringComparer.OrdinalIgnoreCase);
+ HashSet denied = new(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var (principal, type, rights) in aces)
+ {
+ if (!IsUnprivilegedPrincipal(principal) || !rights.Any(isWriteRight))
+ {
+ continue;
+ }
+
+ _ = type == AccessControlType.Deny ? denied.Add(principal) : allowed.Add(principal);
+ }
+
+ allowed.ExceptWith(denied);
+ return allowed.Count > 0;
+ }
+
+ private static readonly HashSet UnprivilegedPrincipals = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "S-1-1-0",
+ "Everyone",
+ "S-1-5-4",
+ "NT AUTHORITY\\INTERACTIVE",
+ "INTERACTIVE",
+ "S-1-5-11",
+ "NT AUTHORITY\\Authenticated Users",
+ "Authenticated Users",
+ "S-1-5-32-545",
+ "BUILTIN\\Users",
+ "Users",
+ };
+
+ ///
+ /// RegistryRights members that allow an attacker to change what a key resolves to.
+ ///
+ private static readonly HashSet RegistryWriteRights = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "SetValue",
+ "CreateSubKey",
+ "CreateLink",
+ "Delete",
+ "WriteKey",
+ "ChangePermissions",
+ "TakeOwnership",
+ "FullControl",
+ };
+
+ ///
+ /// FileSystemRights members that allow an attacker to plant or replace a binary. Aliased members
+ /// that share a value (WriteData/CreateFiles, AppendData/CreateDirectories) are both listed
+ /// because which name ToString() produces is an implementation detail.
+ ///
+ private static readonly HashSet FileWriteRights = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "WriteData",
+ "CreateFiles",
+ "AppendData",
+ "CreateDirectories",
+ "Write",
+ "Modify",
+ "Delete",
+ "DeleteSubdirectoriesAndFiles",
+ "ChangePermissions",
+ "TakeOwnership",
+ "FullControl",
+ };
+ }
+}
diff --git a/Lib/Utils/RegistryReferenceParser.cs b/Lib/Utils/RegistryReferenceParser.cs
new file mode 100644
index 000000000..9eae5bd86
--- /dev/null
+++ b/Lib/Utils/RegistryReferenceParser.cs
@@ -0,0 +1,249 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Utils
+{
+ ///
+ /// Cracks file paths and CLSIDs out of raw registry value data and normalizes registry-stored binary
+ /// references into paths on disk.
+ ///
+ ///
+ /// Extraction runs for every value of every key of every hive in both registry views, so it is pure
+ /// string manipulation over compiled expressions and never touches the disk. Cheap substring
+ /// pre-filters keep the regexes from running at all for the overwhelming majority of values
+ /// (base64-encoded REG_BINARY blobs, numbers, and plain words contain none of the trigger
+ /// characters).
+ ///
+ public static class RegistryReferenceParser
+ {
+ ///
+ /// Values longer than this are not scanned. Registry values can hold megabytes of binary data and
+ /// no load point reference lives past this bound.
+ ///
+ public const int MaxScannedValueLength = 8192;
+
+ ///
+ /// The most references of each kind taken from a single value.
+ ///
+ public const int MaxReferencesPerValue = 32;
+
+ ///
+ /// Extracts CLSID-shaped GUIDs from raw registry value data, normalized to braced uppercase form.
+ ///
+ public static IEnumerable ExtractClsids(string? value)
+ {
+ if (value is null || value.Length == 0 || value.Length > MaxScannedValueLength)
+ {
+ return Array.Empty();
+ }
+
+ // Every GUID shape we accept contains hyphens; bail before touching the regex engine if there
+ // are none.
+ if (value.IndexOf('-') < 0)
+ {
+ return Array.Empty();
+ }
+
+ List results = new();
+ HashSet seen = new(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ for (var match = ClsidRegex.Match(value); match.Success; match = match.NextMatch())
+ {
+ var clsid = $"{{{match.Groups["guid"].Value.ToUpperInvariant()}}}";
+ if (seen.Add(clsid))
+ {
+ results.Add(clsid);
+ if (results.Count >= MaxReferencesPerValue)
+ {
+ break;
+ }
+ }
+ }
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return results;
+ }
+
+ return results;
+ }
+
+ ///
+ /// Extracts rooted file paths (drive-qualified, UNC, or environment-variable rooted) from raw
+ /// registry value data. Results are environment-expanded and normalized.
+ ///
+ public static IEnumerable ExtractPaths(string? value)
+ {
+ if (value is null || value.Length == 0 || value.Length > MaxScannedValueLength)
+ {
+ return Array.Empty();
+ }
+
+ // Every path shape we accept is rooted by a drive letter, a UNC prefix, or an environment
+ // variable, so it must contain a backslash or a percent sign.
+ if (value.IndexOf('\\') < 0 && value.IndexOf('%') < 0)
+ {
+ return Array.Empty();
+ }
+
+ List results = new();
+ HashSet seen = new(StringComparer.OrdinalIgnoreCase);
+
+ try
+ {
+ for (var match = PathRegex.Match(value); match.Success; match = match.NextMatch())
+ {
+ var path = NormalizePath(match.Value);
+ if (path is not null && seen.Add(path))
+ {
+ results.Add(path);
+ if (results.Count >= MaxReferencesPerValue)
+ {
+ break;
+ }
+ }
+ }
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ return results;
+ }
+
+ return results;
+ }
+
+ ///
+ /// Pulls the executable out of a command line, as stored in LocalServer32 or a service ImagePath.
+ ///
+ ///
+ /// An unquoted path containing spaces is genuinely ambiguous to the loader as well, so the first
+ /// token ending in .exe is preferred before falling back to splitting on whitespace.
+ ///
+ public static string? ExtractExecutablePath(string? commandLine)
+ {
+ if (string.IsNullOrWhiteSpace(commandLine))
+ {
+ return null;
+ }
+
+ var value = commandLine.Trim();
+
+ if (value[0] == '"')
+ {
+ var end = value.IndexOf('"', 1);
+ return NormalizePath(end > 1 ? value.Substring(1, end - 1) : value.Trim('"'));
+ }
+
+ try
+ {
+ var match = ExecutableExtensionRegex.Match(value);
+ if (match.Success)
+ {
+ return NormalizePath(value.Substring(0, match.Index + match.Length));
+ }
+ }
+ catch (RegexMatchTimeoutException)
+ {
+ // Fall through to whitespace splitting.
+ }
+
+ var space = value.IndexOf(' ');
+ return NormalizePath(space > 0 ? value.Substring(0, space) : value);
+ }
+
+ ///
+ /// Normalizes a registry-stored binary reference into a path on disk: strips surrounding quotes,
+ /// expands environment variables, resolves native object-manager prefixes, and qualifies bare
+ /// binary names against System32 the way the loader would.
+ ///
+ public static string? NormalizePath(string? raw)
+ {
+ if (string.IsNullOrWhiteSpace(raw))
+ {
+ return null;
+ }
+
+ var path = raw.Trim();
+
+ // Quoted paths break permission lookups downstream.
+ if (path.Length > 1 && path[0] == '"' && path[path.Length - 1] == '"')
+ {
+ path = path.Substring(1, path.Length - 2).Trim();
+ }
+
+ if (path.Length == 0)
+ {
+ return null;
+ }
+
+ try
+ {
+ path = Environment.ExpandEnvironmentVariables(path);
+ }
+ catch (Exception)
+ {
+ // A malformed value is left as-is rather than dropped.
+ }
+
+ if (path.StartsWith(@"\??\", StringComparison.Ordinal))
+ {
+ path = path.Substring(4);
+ }
+ else if (path.StartsWith(@"\SystemRoot\", StringComparison.OrdinalIgnoreCase))
+ {
+ path = Path.Combine(SystemRoot, path.Substring(@"\SystemRoot\".Length));
+ }
+
+ path = path.Trim();
+
+ if (path.Length == 0)
+ {
+ return null;
+ }
+
+ // An unqualified binary name is resolved by the loader out of System32.
+ if (path.IndexOf('\\') < 0 && path.IndexOf('/') < 0 && path.IndexOf('%') < 0)
+ {
+ path = Path.Combine(Environment.SystemDirectory, path);
+ }
+
+ return path;
+ }
+
+ private static string SystemRoot
+ {
+ get
+ {
+ var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows);
+ return string.IsNullOrEmpty(windows) ? @"C:\Windows" : windows;
+ }
+ }
+
+ private static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(100);
+
+ private static readonly Regex ClsidRegex = new(
+ @"\{?(?[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12})\}?",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant,
+ RegexTimeout);
+
+ private static readonly Regex ExecutableExtensionRegex = new(
+ @"\.exe\b",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
+ RegexTimeout);
+
+ ///
+ /// A drive-letter, UNC, or environment-variable rooted path running lazily up to a file
+ /// extension. The trailing lookahead forces the extension to end at a natural delimiter so that
+ /// "C:\a\b.check" is not truncated to "C:\a\b.c".
+ ///
+ private static readonly Regex PathRegex = new(
+ @"(?:[A-Za-z]:\\|\\\\|%[A-Za-z_][A-Za-z0-9_()]{0,63}%\\)[^""<>|\r\n\t]*?\.[A-Za-z0-9]{1,8}(?![^\s""',;)\]])",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant,
+ RegexTimeout);
+ }
+}
diff --git a/Lib/Utils/RegistryWalker.cs b/Lib/Utils/RegistryWalker.cs
index 8a2bdff2f..0e190f301 100644
--- a/Lib/Utils/RegistryWalker.cs
+++ b/Lib/Utils/RegistryWalker.cs
@@ -39,19 +39,39 @@ public static class RegistryWalker
try
{
- foreach (RegistryAccessRule? rule in key.GetAccessControl().GetAccessRules(true, true, typeof(SecurityIdentifier)))
+ var security = key.GetAccessControl();
+
+ try
+ {
+ regObj.PermissionsString = security.GetSecurityDescriptorSddlForm(AccessControlSections.All);
+ }
+ catch (Exception e)
+ {
+ Log.Verbose("Failed to get SDDL for {0} ({1}:{2})", regObj.Key, e.GetType(), e.Message);
+ }
+
+ foreach (RegistryAccessRule? rule in security.GetAccessRules(true, true, typeof(SecurityIdentifier)))
{
if (rule != null)
{
string name = AsaHelpers.SidToName(rule.IdentityReference);
- if (regObj.Permissions.ContainsKey(name))
+ if (!regObj.Permissions.TryGetValue(name, out List? rights))
{
- regObj.Permissions[name].Add(rule.RegistryRights.ToString());
+ rights = new List();
+ regObj.Permissions.Add(name, rights);
}
- else
+
+ // RegistryRights.ToString() returns a comma joined combined mask. Split it so
+ // individual rights are matchable, and prefix each with the access control type so
+ // Allow and Deny are distinguishable.
+ foreach (var right in PermissionUtils.SplitRights(rule.RegistryRights.ToString()))
{
- regObj.Permissions.Add(name, new List() { rule.RegistryRights.ToString() });
+ var entry = PermissionUtils.EncodeRight(rule.AccessControlType, right);
+ if (!rights.Contains(entry))
+ {
+ rights.Add(entry);
+ }
}
}
}
@@ -62,10 +82,62 @@ public static class RegistryWalker
}
regObj.Values = RegistryObject.GetValues(key);
+ PopulateReferences(regObj);
return regObj;
}
+ ///
+ /// Cracks file paths and CLSIDs out of the key's values so that analysis rules, which cannot
+ /// follow a reference from one collected object to another, can interrogate them directly.
+ ///
+ private static void PopulateReferences(RegistryObject regObj)
+ {
+ if (regObj.Values is null || regObj.Values.Count == 0)
+ {
+ return;
+ }
+
+ HashSet paths = new(StringComparer.OrdinalIgnoreCase);
+ HashSet clsids = new(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var value in regObj.Values.Values)
+ {
+ if (paths.Count >= MaxReferencesPerKey && clsids.Count >= MaxReferencesPerKey)
+ {
+ break;
+ }
+
+ if (paths.Count < MaxReferencesPerKey)
+ {
+ foreach (var path in RegistryReferenceParser.ExtractPaths(value))
+ {
+ if (paths.Add(path))
+ {
+ regObj.ReferencedPaths.Add(path);
+ }
+ }
+ }
+
+ if (clsids.Count < MaxReferencesPerKey)
+ {
+ foreach (var clsid in RegistryReferenceParser.ExtractClsids(value))
+ {
+ if (clsids.Add(clsid))
+ {
+ regObj.ReferencedClsids.Add(clsid);
+ }
+ }
+ }
+ }
+ }
+
+ ///
+ /// Caps the references retained for a single key so that a pathological key cannot blow up the
+ /// collected object.
+ ///
+ private const int MaxReferencesPerKey = 128;
+
public static IEnumerable WalkHive(RegistryHive Hive, RegistryView View, string startingKey = "")
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
diff --git a/Tests/AsaAnalyzerTests.cs b/Tests/AsaAnalyzerTests.cs
index 27dbea6fd..1eb288716 100644
--- a/Tests/AsaAnalyzerTests.cs
+++ b/Tests/AsaAnalyzerTests.cs
@@ -4,6 +4,7 @@
using Microsoft.CST.AttackSurfaceAnalyzer.Utils;
using Microsoft.CST.OAT;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Microsoft.Win32;
using System.Collections.Generic;
using System.Linq;
@@ -76,5 +77,128 @@ public void VerifyFileMonitorAsFile()
private const string TestPathOne = "TestPath1";
private readonly FileMonitorObject testPathOneObject = new(TestPathOne) { FileSystemObject = new FileSystemObject(TestPathOne) { IsExecutable = true } };
+
+ ///
+ /// The CVE-2026-50343 shape: a load point key an unprivileged user can write, pointing through a
+ /// CLSID at a DLL that does not exist in a directory they can also write.
+ ///
+ [TestMethod]
+ public void VerifyLoadPointRulesFlagLayAndWaitConfiguration()
+ {
+ var sourceKey = new RegistryObject(
+ @"HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\InstallService\State",
+ RegistryView.Registry64);
+ sourceKey.Permissions.Add("NT AUTHORITY\\INTERACTIVE", new List { "Allow:SetValue", "Allow:CreateSubKey" });
+
+ var loadPoint = new LoadPointObject("StaticPluginMap", sourceKey)
+ {
+ SourceValueName = "StaticPluginMap",
+ SourceKeyUserWritable = true,
+ TargetClsid = "{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}",
+ TargetPath = @"C:\ProgramData\CrossDevice\CrossDevice.Streaming.Source.dll",
+ TargetExists = false,
+ TargetUserWritable = true,
+ TargetAclSource = "NearestExistingParent",
+ NearestExistingParentPath = @"C:\ProgramData",
+ };
+
+ var matched = AnalyzeLoadPoint(loadPoint);
+
+ Assert.IsTrue(matched.Contains("Load Point Writable by Unprivileged Users"));
+ Assert.IsTrue(matched.Contains("Load Point Target Missing from Unprivileged Writable Directory"));
+ Assert.IsTrue(matched.Contains("Privilege Escalation via Unprivileged Load Point"));
+
+ // The DLL does not exist, so the rule about replacing an existing binary must not claim it.
+ Assert.IsFalse(matched.Contains("Load Point Target Writable by Unprivileged Users"));
+ }
+
+ ///
+ /// A correctly ACLed COM registration must not flag. A rule that fires on every COM object is
+ /// worthless.
+ ///
+ [TestMethod]
+ public void VerifyLoadPointRulesDoNotFlagBenignComRegistration()
+ {
+ var sourceKey = new RegistryObject(
+ @"HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{20D04FE0-3AEA-1069-A2D8-08002B30309D}\InprocServer32",
+ RegistryView.Registry64);
+ sourceKey.Permissions.Add("NT AUTHORITY\\SYSTEM", new List { "Allow:FullControl" });
+ sourceKey.Permissions.Add("BUILTIN\\Administrators", new List { "Allow:FullControl" });
+ sourceKey.Permissions.Add("BUILTIN\\Users", new List { "Allow:ReadKey", "Allow:QueryValues" });
+
+ var loadPoint = new LoadPointObject("ComServer", sourceKey)
+ {
+ SourceValueName = string.Empty,
+ SourceKeyUserWritable = false,
+ TargetPath = @"C:\Windows\System32\shell32.dll",
+ TargetExists = true,
+ TargetUserWritable = false,
+ TargetAclSource = "Target",
+ };
+
+ Assert.AreEqual(0, AnalyzeLoadPoint(loadPoint).Count);
+ }
+
+ ///
+ /// A target whose ACL could not be read is not evidence of anything and must not flag.
+ ///
+ [TestMethod]
+ public void VerifyLoadPointRulesDoNotFlagUnreadableTargetAcl()
+ {
+ var sourceKey = new RegistryObject(@"HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{1}\InprocServer32", RegistryView.Registry64);
+ sourceKey.Permissions.Add("NT AUTHORITY\\SYSTEM", new List { "Allow:FullControl" });
+
+ var loadPoint = new LoadPointObject("ComServer", sourceKey)
+ {
+ SourceKeyUserWritable = false,
+ TargetPath = @"C:\Windows\System32\protected.dll",
+ TargetExists = true,
+ TargetUserWritable = false,
+ TargetAclUnavailable = true,
+ TargetAclSource = "Target",
+ };
+
+ Assert.AreEqual(0, AnalyzeLoadPoint(loadPoint).Count);
+ }
+
+ ///
+ /// An existing binary an unprivileged user can replace is flagged, but not by the
+ /// missing-target rule.
+ ///
+ [TestMethod]
+ public void VerifyLoadPointRulesFlagWritableExistingTarget()
+ {
+ var sourceKey = new RegistryObject(@"HKEY_LOCAL_MACHINE\SOFTWARE\Classes\CLSID\{2}\InprocServer32", RegistryView.Registry64);
+ sourceKey.Permissions.Add("NT AUTHORITY\\SYSTEM", new List { "Allow:FullControl" });
+
+ var loadPoint = new LoadPointObject("ComServer", sourceKey)
+ {
+ SourceKeyUserWritable = false,
+ TargetPath = @"C:\ProgramData\Contoso\plugin.dll",
+ TargetExists = true,
+ TargetUserWritable = true,
+ TargetAclSource = "Target",
+ };
+
+ var matched = AnalyzeLoadPoint(loadPoint);
+
+ Assert.IsTrue(matched.Contains("Load Point Target Writable by Unprivileged Users"));
+ Assert.IsFalse(matched.Contains("Load Point Target Missing from Unprivileged Writable Directory"));
+ Assert.IsFalse(matched.Contains("Load Point Writable by Unprivileged Users"));
+ }
+
+ private static HashSet AnalyzeLoadPoint(LoadPointObject loadPoint)
+ {
+ var analyzer = new AsaAnalyzer();
+ var rules = RuleFile.LoadEmbeddedFilters().Rules
+ .Where(rule => rule.ResultType == RESULT_TYPE.LOADPOINT)
+ .ToList();
+
+ Assert.IsTrue(rules.Count > 0, "No load point rules are present in the embedded rule file.");
+
+ return analyzer.Analyze(rules, new CompareResult() { Compare = loadPoint })
+ .Select(rule => rule.Name)
+ .ToHashSet();
+ }
}
}
\ No newline at end of file
diff --git a/Tests/CollectorTests.cs b/Tests/CollectorTests.cs
index 207d12d43..26699219d 100644
--- a/Tests/CollectorTests.cs
+++ b/Tests/CollectorTests.cs
@@ -67,6 +67,101 @@ public void TestComObjectCollector()
coc.TryExecute();
Assert.IsTrue(results.Any(x => x is ComObject y && y.x86_Binary != null));
+
+ // The 64-bit view is parsed too, and no resolved path may still contain an unexpanded
+ // environment variable.
+ Assert.IsTrue(results.Any(x => x is ComObject y && y.x64_Binary != null));
+ Assert.IsFalse(results.Any(x => x is ComObject y
+ && ((y.x86_Binary?.Path.Contains('%') ?? false) || (y.x64_Binary?.Path.Contains('%') ?? false))));
+ }
+ }
+
+ ///
+ /// Requires admin. Load points are Windows only; off Windows only the platform gate is asserted.
+ ///
+ [TestMethod]
+ public void TestLoadPointCollector()
+ {
+ var lpc = new LoadPointCollector(new CollectorOptions() { SingleThread = true });
+
+ Assert.AreEqual(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), lpc.CanRunOnPlatform());
+
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ ConcurrentStack results = new();
+ lpc = new LoadPointCollector(new CollectorOptions() { SingleThread = true }, x => results.Push(x));
+ lpc.TryExecute();
+
+ Assert.IsTrue(results.Any(x => x is LoadPointObject y && !string.IsNullOrEmpty(y.TargetPath)));
+
+ // Services are one of the default definitions and always resolve to something on a real system.
+ Assert.IsTrue(results.Any(x => x is LoadPointObject y && y.LoadPointType == "Service"));
+
+ // Every object must say whether its target exists and where its verdict came from.
+ Assert.IsTrue(results.OfType().All(y =>
+ y.TargetAclSource is "Target" or "NearestExistingParent" or "None"));
+
+ // System32 binaries must never be reported as writable by unprivileged users.
+ Assert.IsFalse(results.OfType().Any(y =>
+ y.TargetUserWritable
+ && (y.TargetPath?.StartsWith(Environment.SystemDirectory, StringComparison.OrdinalIgnoreCase) ?? false)));
+
+ // Nothing off this machine may be touched without being asked for.
+ Assert.IsFalse(results.OfType().Any(y => y.TargetIsNetworkPath && y.Target is not null));
+ }
+
+ ///
+ /// A load point naming a share on another machine is reported, but resolving it would connect to a
+ /// host chosen by whoever could write the key, so it is left alone by default. Does not require
+ /// administrator, and does not touch the network.
+ ///
+ [TestMethod]
+ public void TestLoadPointCollectorDoesNotFollowNetworkPaths()
+ {
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ return;
+ }
+
+ const string NetworkTarget = @"\\asa-test-invalid-host\share\planted.dll";
+
+ var name = Guid.NewGuid().ToString();
+ var key = Registry.CurrentUser.CreateSubKey(name);
+ key.SetValue("ServiceDll", NetworkTarget);
+ key.Close();
+
+ try
+ {
+ var definition = new LoadPointDefinition("Test", RegistryHive.CurrentUser, name, false,
+ new LoadPointValueSource(null, "ServiceDll", LoadPointTargetKind.Path));
+
+ var lpc = new LoadPointCollector(new CollectorOptions() { SingleThread = true }, definitions: new[] { definition });
+ var loadPoints = lpc.ParseDefinition(definition, RegistryView.Default).ToList();
+
+ Assert.AreEqual(1, loadPoints.Count);
+
+ var loadPoint = loadPoints[0];
+
+ // The path is still reported, so a rule can see where the load point points.
+ Assert.AreEqual(NetworkTarget, loadPoint.TargetPath);
+ Assert.IsTrue(loadPoint.TargetIsNetworkPath);
+
+ // Nothing about the remote end was read, and the object says so rather than implying the
+ // target is absent or safe.
+ Assert.IsNull(loadPoint.Target);
+ Assert.IsNull(loadPoint.NearestExistingParent);
+ Assert.IsNull(loadPoint.NearestExistingParentPath);
+ Assert.IsFalse(loadPoint.TargetExists);
+ Assert.IsFalse(loadPoint.TargetUserWritable);
+ Assert.IsTrue(loadPoint.TargetAclUnavailable);
+ Assert.AreEqual("None", loadPoint.TargetAclSource);
+ }
+ finally
+ {
+ Registry.CurrentUser.DeleteSubKey(name);
}
}
diff --git a/Tests/HydrationTests.cs b/Tests/HydrationTests.cs
index 741334dfd..dfb109b74 100644
--- a/Tests/HydrationTests.cs
+++ b/Tests/HydrationTests.cs
@@ -116,6 +116,74 @@ public void TestSerializeAndDeserializeRegistryObject()
Assert.IsTrue(ro.RowKey.Equals(JsonUtils.Hydrate(JsonUtils.Dehydrate(ro), RESULT_TYPE.REGISTRY)?.RowKey));
}
+ [TestMethod]
+ public void TestSerializeAndDeserializeRegistryObjectReferences()
+ {
+ var ro = new RegistryObject("Test Key", Microsoft.Win32.RegistryView.Default)
+ {
+ PermissionsString = "O:BAG:SYD:(A;;KA;;;IU)",
+ ReferencedPaths = { @"C:\ProgramData\Contoso\plugin.dll" },
+ ReferencedClsids = { "{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}" },
+ };
+ ro.Permissions.Add("NT AUTHORITY\\INTERACTIVE", new System.Collections.Generic.List { "Allow:SetValue" });
+
+ if (JsonUtils.Hydrate(JsonUtils.Dehydrate(ro), RESULT_TYPE.REGISTRY) is RegistryObject ro2)
+ {
+ Assert.AreEqual(ro.RowKey, ro2.RowKey);
+ Assert.AreEqual(ro.PermissionsString, ro2.PermissionsString);
+ CollectionAssert.AreEqual(ro.ReferencedPaths, ro2.ReferencedPaths);
+ CollectionAssert.AreEqual(ro.ReferencedClsids, ro2.ReferencedClsids);
+ CollectionAssert.AreEqual(ro.Permissions["NT AUTHORITY\\INTERACTIVE"], ro2.Permissions["NT AUTHORITY\\INTERACTIVE"]);
+ }
+ else
+ {
+ Assert.Fail();
+ }
+ }
+
+ [TestMethod]
+ public void TestSerializeAndDeserializeLoadPointObject()
+ {
+ var sourceKey = new RegistryObject(@"HKEY_LOCAL_MACHINE\SOFTWARE\Test", Microsoft.Win32.RegistryView.Registry64);
+ sourceKey.Permissions.Add("NT AUTHORITY\\INTERACTIVE", new System.Collections.Generic.List { "Allow:SetValue" });
+
+ var lp = new LoadPointObject("StaticPluginMap", sourceKey)
+ {
+ SourceValueName = "StaticPluginMap",
+ SourceValueData = "1:{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}",
+ SourceKeyUserWritable = true,
+ TargetClsid = "{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}",
+ TargetPath = @"C:\ProgramData\CrossDevice\CrossDevice.Streaming.Source.dll",
+ TargetExists = false,
+ TargetUserWritable = true,
+ TargetAclSource = "NearestExistingParent",
+ NearestExistingParentPath = @"C:\ProgramData",
+ TargetIsNetworkPath = false,
+ View = Microsoft.Win32.RegistryView.Registry64,
+ ResolutionChain = { "step one", "step two" },
+ };
+
+ if (JsonUtils.Hydrate(JsonUtils.Dehydrate(lp), RESULT_TYPE.LOADPOINT) is LoadPointObject lp2)
+ {
+ Assert.AreEqual(lp.RowKey, lp2.RowKey);
+ Assert.AreEqual(lp.Identity, lp2.Identity);
+ Assert.AreEqual(lp.LoadPointType, lp2.LoadPointType);
+ Assert.AreEqual(lp.SourceKeyUserWritable, lp2.SourceKeyUserWritable);
+ Assert.AreEqual(lp.TargetUserWritable, lp2.TargetUserWritable);
+ Assert.AreEqual(lp.TargetExists, lp2.TargetExists);
+ Assert.AreEqual(lp.TargetAclSource, lp2.TargetAclSource);
+ Assert.AreEqual(lp.TargetIsNetworkPath, lp2.TargetIsNetworkPath);
+ Assert.AreEqual(lp.TargetPath, lp2.TargetPath);
+ Assert.AreEqual(lp.NearestExistingParentPath, lp2.NearestExistingParentPath);
+ Assert.AreEqual(lp.SourceKey.Key, lp2.SourceKey.Key);
+ CollectionAssert.AreEqual(lp.ResolutionChain, lp2.ResolutionChain);
+ }
+ else
+ {
+ Assert.Fail();
+ }
+ }
+
[TestMethod]
public void TestSerializeAndDeserializeServiceObject()
{
diff --git a/Tests/LoadPointUtilsTests.cs b/Tests/LoadPointUtilsTests.cs
new file mode 100644
index 000000000..0c742a9db
--- /dev/null
+++ b/Tests/LoadPointUtilsTests.cs
@@ -0,0 +1,266 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License.
+using Microsoft.CST.AttackSurfaceAnalyzer.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.CST.AttackSurfaceAnalyzer.Tests
+{
+ ///
+ /// Coverage for the pieces of the load point analysis that are platform independent: cracking
+ /// references out of registry values, and deciding whether a principal can write something.
+ ///
+ [TestClass, TestCategory("PipelineSafeTests")]
+ public class LoadPointUtilsTests
+ {
+ [ClassInitialize]
+ public static void ClassSetup(TestContext _)
+ {
+ Logger.Setup(false, true);
+ Strings.Setup();
+ }
+
+ [TestMethod]
+ public void ExtractPathsFindsDriveQualifiedPaths()
+ {
+ var paths = RegistryReferenceParser.ExtractPaths(@"C:\Windows\System32\shell32.dll").ToList();
+
+ Assert.AreEqual(1, paths.Count);
+ Assert.AreEqual(@"C:\Windows\System32\shell32.dll", paths[0]);
+ }
+
+ [TestMethod]
+ public void ExtractPathsExpandsEnvironmentVariables()
+ {
+ Environment.SetEnvironmentVariable(TestVariable, @"C:\ProgramData");
+
+ try
+ {
+ var paths = RegistryReferenceParser
+ .ExtractPaths($@"%{TestVariable}%\CrossDevice\CrossDevice.Streaming.Source.dll")
+ .ToList();
+
+ Assert.AreEqual(1, paths.Count);
+ Assert.AreEqual(@"C:\ProgramData\CrossDevice\CrossDevice.Streaming.Source.dll", paths[0]);
+ }
+ finally
+ {
+ Environment.SetEnvironmentVariable(TestVariable, null);
+ }
+ }
+
+ [TestMethod]
+ public void ExtractPathsKeepsSpacesAndTrimsTrailingArguments()
+ {
+ var paths = RegistryReferenceParser.ExtractPaths(@"C:\Program Files\Contoso\app.dll,-100").ToList();
+
+ Assert.AreEqual(1, paths.Count);
+ Assert.AreEqual(@"C:\Program Files\Contoso\app.dll", paths[0]);
+ }
+
+ [TestMethod]
+ public void ExtractPathsDoesNotTruncateLongExtensions()
+ {
+ var paths = RegistryReferenceParser.ExtractPaths(@"C:\a.b\c.config").ToList();
+
+ Assert.AreEqual(1, paths.Count);
+ Assert.AreEqual(@"C:\a.b\c.config", paths[0]);
+ }
+
+ [TestMethod]
+ public void ExtractPathsIgnoresValuesWithoutReferences()
+ {
+ Assert.AreEqual(0, RegistryReferenceParser.ExtractPaths("1").Count());
+ Assert.AreEqual(0, RegistryReferenceParser.ExtractPaths("SomeDisplayName").Count());
+ Assert.AreEqual(0, RegistryReferenceParser.ExtractPaths(null).Count());
+ }
+
+ [TestMethod]
+ public void ExtractPathsSkipsPathologicallyLongValues()
+ {
+ var value = new string('A', RegistryReferenceParser.MaxScannedValueLength + 1);
+
+ Assert.AreEqual(0, RegistryReferenceParser.ExtractPaths(value).Count());
+ Assert.AreEqual(0, RegistryReferenceParser.ExtractClsids(value).Count());
+ }
+
+ [TestMethod]
+ public void ExtractClsidsNormalizesToBracedUppercase()
+ {
+ var clsids = RegistryReferenceParser
+ .ExtractClsids("PluginId=1;Clsid=e9f83cf2-e0c0-4ca7-af01-e90c70bef496")
+ .ToList();
+
+ Assert.AreEqual(1, clsids.Count);
+ Assert.AreEqual("{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496}", clsids[0]);
+ }
+
+ [TestMethod]
+ public void ExtractClsidsDeduplicates()
+ {
+ var clsids = RegistryReferenceParser
+ .ExtractClsids("{E9F83CF2-E0C0-4CA7-AF01-E90C70BEF496} {e9f83cf2-e0c0-4ca7-af01-e90c70bef496}")
+ .ToList();
+
+ Assert.AreEqual(1, clsids.Count);
+ }
+
+ [TestMethod]
+ public void ExtractExecutablePathHandlesQuotedCommandLines()
+ {
+ Assert.AreEqual(
+ @"C:\Program Files\Contoso\server.exe",
+ RegistryReferenceParser.ExtractExecutablePath(@"""C:\Program Files\Contoso\server.exe"" -Embedding"));
+ }
+
+ [TestMethod]
+ public void ExtractExecutablePathHandlesUnquotedCommandLines()
+ {
+ Assert.AreEqual(
+ @"C:\Windows\System32\svchost.exe",
+ RegistryReferenceParser.ExtractExecutablePath(@"C:\Windows\System32\svchost.exe -k netsvcs"));
+ }
+
+ [TestMethod]
+ public void NormalizePathStripsQuotesAndNativePrefixes()
+ {
+ Assert.AreEqual(@"C:\Windows\System32\drivers\x.sys", RegistryReferenceParser.NormalizePath(@"\??\C:\Windows\System32\drivers\x.sys"));
+ Assert.AreEqual(@"C:\Windows\System32\x.dll", RegistryReferenceParser.NormalizePath(@" ""C:\Windows\System32\x.dll"" "));
+ }
+
+ [TestMethod]
+ public void NormalizePathQualifiesBareBinaryNames()
+ {
+ // Environment.SystemDirectory is empty off Windows, so only the file name is asserted here.
+ var normalized = RegistryReferenceParser.NormalizePath("shell32.dll");
+
+ Assert.IsNotNull(normalized);
+ Assert.AreEqual("shell32.dll", Path.GetFileName(normalized));
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ Assert.IsTrue(normalized!.StartsWith(Environment.SystemDirectory, StringComparison.OrdinalIgnoreCase));
+ }
+ }
+
+ [TestMethod]
+ public void IsNetworkPathDetectsUncPaths()
+ {
+ Assert.IsTrue(PathUtils.IsNetworkPath(@"\\attacker\share\planted.dll"));
+ Assert.IsTrue(PathUtils.IsNetworkPath(@" \\attacker\share\planted.dll "));
+ Assert.IsTrue(PathUtils.IsNetworkPath(@"\\?\UNC\attacker\share\planted.dll"));
+ Assert.IsTrue(PathUtils.IsNetworkPath(@"\\?\unc\attacker\share\planted.dll"));
+ }
+
+ [TestMethod]
+ public void IsNetworkPathAcceptsLocalPaths()
+ {
+ Assert.IsFalse(PathUtils.IsNetworkPath(@"C:\Windows\System32\shell32.dll"));
+ // The extended-length prefix does not by itself mean the path is remote.
+ Assert.IsFalse(PathUtils.IsNetworkPath(@"\\?\C:\Windows\System32\shell32.dll"));
+ Assert.IsFalse(PathUtils.IsNetworkPath("shell32.dll"));
+ Assert.IsFalse(PathUtils.IsNetworkPath(null));
+ Assert.IsFalse(PathUtils.IsNetworkPath(" "));
+ }
+
+ [TestMethod]
+ public void RegistryPermissionsAreUserWritableWhenInteractiveMaySetValue()
+ {
+ var permissions = new Dictionary>
+ {
+ { "NT AUTHORITY\\SYSTEM", new List { "Allow:FullControl" } },
+ { "NT AUTHORITY\\INTERACTIVE", new List { "Allow:SetValue", "Allow:CreateSubKey" } },
+ };
+
+ Assert.IsTrue(PermissionUtils.IsUserWritable(permissions));
+ }
+
+ [TestMethod]
+ public void RegistryPermissionsHonorDenyAces()
+ {
+ var permissions = new Dictionary>
+ {
+ { "NT AUTHORITY\\INTERACTIVE", new List { "Allow:SetValue", "Deny:SetValue" } },
+ };
+
+ Assert.IsFalse(PermissionUtils.IsUserWritable(permissions));
+ }
+
+ [TestMethod]
+ public void RegistryPermissionsIgnorePrivilegedPrincipalsAndReadRights()
+ {
+ Assert.IsFalse(PermissionUtils.IsUserWritable(new Dictionary>
+ {
+ { "BUILTIN\\Administrators", new List { "Allow:FullControl" } },
+ { "NT AUTHORITY\\SYSTEM", new List { "Allow:FullControl" } },
+ { "BUILTIN\\Users", new List { "Allow:ReadKey", "Allow:QueryValues" } },
+ }));
+ }
+
+ [TestMethod]
+ public void RegistryPermissionsWithoutAccessTypePrefixAreReadAsAllow()
+ {
+ // Databases collected before the access control type was recorded have unprefixed rights.
+ Assert.IsTrue(PermissionUtils.IsUserWritable(new Dictionary>
+ {
+ { "BUILTIN\\Users", new List { "WriteKey" } },
+ }));
+ }
+
+ [TestMethod]
+ public void EveryoneIsRecognizedByRawSidAndByName()
+ {
+ Assert.IsTrue(PermissionUtils.IsUnprivilegedPrincipal("S-1-1-0"));
+ Assert.IsTrue(PermissionUtils.IsUnprivilegedPrincipal("Everyone"));
+ Assert.IsTrue(PermissionUtils.IsUnprivilegedPrincipal("NT AUTHORITY\\INTERACTIVE"));
+ Assert.IsTrue(PermissionUtils.IsUnprivilegedPrincipal("BUILTIN\\Users"));
+ Assert.IsTrue(PermissionUtils.IsUnprivilegedPrincipal("S-1-5-32-545"));
+ Assert.IsFalse(PermissionUtils.IsUnprivilegedPrincipal("NT AUTHORITY\\SYSTEM"));
+ Assert.IsFalse(PermissionUtils.IsUnprivilegedPrincipal("BUILTIN\\Administrators"));
+ Assert.IsFalse(PermissionUtils.IsUnprivilegedPrincipal(null));
+ }
+
+ [TestMethod]
+ public void FilePermissionsAreUserWritableWhenUsersMayCreateFiles()
+ {
+ Assert.IsTrue(PermissionUtils.IsUserWritable(new Dictionary
+ {
+ { "BUILTIN\\Users", "CreateFiles,AppendData,ReadAndExecute" },
+ }));
+
+ Assert.IsFalse(PermissionUtils.IsUserWritable(new Dictionary
+ {
+ { "BUILTIN\\Users", "ReadAndExecute,Synchronize" },
+ }));
+ }
+
+ [TestMethod]
+ public void NearestExistingParentWalksUpToTheDirectoryThatWouldReceiveTheFile()
+ {
+ var root = Directory.CreateTempSubdirectory("asa-loadpoint-").FullName;
+
+ try
+ {
+ var missing = Path.Combine(root, "Contoso", "Nested", "planted.dll");
+
+ Assert.AreEqual(root, PermissionUtils.NearestExistingParent(missing));
+ }
+ finally
+ {
+ Directory.Delete(root, true);
+ }
+ }
+
+ [TestMethod]
+ public void NearestExistingParentReturnsNullForEmptyInput()
+ {
+ Assert.IsNull(PermissionUtils.NearestExistingParent(null));
+ Assert.IsNull(PermissionUtils.NearestExistingParent(" "));
+ }
+
+ private const string TestVariable = "ASA_LOADPOINT_TEST_DIR";
+ }
+}
diff --git a/analyses.json b/analyses.json
index 2fb3f2076..ccf179805 100644
--- a/analyses.json
+++ b/analyses.json
@@ -2059,6 +2059,110 @@
]
}
]
+ },
+ {
+ "Name": "Load Point Writable by Unprivileged Users",
+ "Description": "The registry key that tells Windows what code to load here can be modified by unprivileged users, who can therefore redirect it at code of their choosing. If the code is then loaded by a privileged process this is a privilege escalation.",
+ "Flag": "WARNING",
+ "ResultType": "LOADPOINT",
+ "Platforms": [
+ "WINDOWS"
+ ],
+ "Clauses": [
+ {
+ "Field": "SourceKeyUserWritable",
+ "Operation": "IsTrue"
+ }
+ ]
+ },
+ {
+ "Name": "Load Point Target Writable by Unprivileged Users",
+ "Description": "The binary this load point resolves to can be replaced by unprivileged users. Any privileged process that loads it will run their code.",
+ "Flag": "WARNING",
+ "ResultType": "LOADPOINT",
+ "Platforms": [
+ "WINDOWS"
+ ],
+ "Expression": "TARGET_WRITABLE AND TARGET_EXISTS AND NOT ACL_UNAVAILABLE",
+ "Clauses": [
+ {
+ "Label": "TARGET_WRITABLE",
+ "Field": "TargetUserWritable",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "TARGET_EXISTS",
+ "Field": "TargetExists",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "ACL_UNAVAILABLE",
+ "Field": "TargetAclUnavailable",
+ "Operation": "IsTrue"
+ }
+ ]
+ },
+ {
+ "Name": "Load Point Target Missing from Unprivileged Writable Directory",
+ "Description": "This load point resolves to a binary that does not exist, in a directory unprivileged users can write to. An attacker can simply create the file and wait for a privileged process to load it.",
+ "Flag": "WARNING",
+ "ResultType": "LOADPOINT",
+ "Platforms": [
+ "WINDOWS"
+ ],
+ "Expression": "PARENT_WRITABLE AND PARENT_ACL AND NOT TARGET_EXISTS AND NOT ACL_UNAVAILABLE",
+ "Clauses": [
+ {
+ "Label": "PARENT_WRITABLE",
+ "Field": "TargetUserWritable",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "PARENT_ACL",
+ "Field": "TargetAclSource",
+ "Operation": "Equals",
+ "Data": [
+ "NearestExistingParent"
+ ]
+ },
+ {
+ "Label": "TARGET_EXISTS",
+ "Field": "TargetExists",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "ACL_UNAVAILABLE",
+ "Field": "TargetAclUnavailable",
+ "Operation": "IsTrue"
+ }
+ ]
+ },
+ {
+ "Name": "Privilege Escalation via Unprivileged Load Point",
+ "Description": "Both ends of this load point are under unprivileged control: the registry key naming the code can be modified, and the binary it resolves to is writable or absent from a writable directory. This is the shape of a lay-and-wait DLL load privilege escalation.",
+ "Flag": "WARNING",
+ "ResultType": "LOADPOINT",
+ "Platforms": [
+ "WINDOWS"
+ ],
+ "Expression": "SOURCE_WRITABLE AND TARGET_WRITABLE AND NOT ACL_UNAVAILABLE",
+ "Clauses": [
+ {
+ "Label": "SOURCE_WRITABLE",
+ "Field": "SourceKeyUserWritable",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "TARGET_WRITABLE",
+ "Field": "TargetUserWritable",
+ "Operation": "IsTrue"
+ },
+ {
+ "Label": "ACL_UNAVAILABLE",
+ "Field": "TargetAclUnavailable",
+ "Operation": "IsTrue"
+ }
+ ]
}
],
"DefaultLevels": {
@@ -2078,6 +2182,7 @@
"KEY": "INFORMATION",
"PROCESS": "INFORMATION",
"DRIVER": "INFORMATION",
- "WIFI": "INFORMATION"
+ "WIFI": "INFORMATION",
+ "LOADPOINT": "INFORMATION"
}
}
\ No newline at end of file