Skip to content

Enhance registry access logging and COM server resolution - #770

Open
Giulia Stocco (gfs) wants to merge 4 commits into
release/v2.3from
gfs/ExpandedComBehavior
Open

Enhance registry access logging and COM server resolution#770
Giulia Stocco (gfs) wants to merge 4 commits into
release/v2.3from
gfs/ExpandedComBehavior

Conversation

@gfs

Copy link
Copy Markdown
Contributor

This pull request introduces a new "Load Point Collector" feature, which identifies and analyzes registry-based load points and the binaries they reference. It also improves the handling of COM object server binary resolution and enhances the RegistryObject to expose referenced CLSIDs and paths for better rule analysis. Additionally, the UI and command-line options are updated to support the new collector.

Load Point Collector Feature:

  • Added a new LoadPointCollector (with corresponding UI, CLI, and object model support) that collects registry load points and resolves them to the binaries they reference, enabling more comprehensive analysis of potential attack surfaces. (LoadPointObject.cs, CommandOptions.cs, AttackSurfaceAnalyzerClient.cs, CollectorOptionsRazor.razor, LoadPointCollectorOptions.razor, Types.cs, AsaRule.cs, JsonUtils.cs) [1] [2] [3] [4] [5] [6] [7] [8] [9] [10]

COM Object Collector Improvements:

  • Refactored the logic for resolving server binaries for COM objects, consolidating and simplifying how the correct binary is determined for both 32-bit and 64-bit registry views. This improves accuracy and maintainability. (ComObjectCollector.cs) [1] [2]

Registry Object Enhancements:

  • Extended RegistryObject to include a flat SDDL permissions string, a list of referenced CLSIDs, and a list of referenced file paths. This supports more powerful and regex-friendly rule analysis. (RegistryObject.cs)

Command-line and Serialization Support:

  • Updated command-line options and serialization/deserialization logic to support the new LoadPointCollector and LoadPointObject. (CommandOptions.cs, JsonUtils.cs) [1] [2] [3]

UI Integration:

  • Integrated the Load Point Collector option into the collector options UI, allowing users to enable it via the interface. (CollectorOptionsRazor.razor, LoadPointCollectorOptions.razor) [1] [2]

RegistryKeyToRegistryObject discarded RegistryAccessRule.AccessControlType,
so Allow and Deny ACEs were indistinguishable in collected data and any
analysis of registry permissions was unsound.

Split the comma joined RegistryRights mask into individual rights, mirroring
how FileSystemCollector handles FileSystemRights, and prefix each with the
access control type. Entries are now "Allow:SetValue" / "Deny:SetValue", which
makes ContainsKey and Contains with DictData usable. The
Dictionary<string, List<string>> shape is unchanged, and unprefixed rights in
existing databases still hydrate.

Also populate RegistryObject.PermissionsString, which was declared but never
assigned, with the key's SDDL. It is a flat string so rules can match ACE
patterns against it with Regex; the Permissions dictionary cannot be matched
that way because OAT's regex operation discards dictionary valued fields.
ComObjectCollector never expanded environment variables. It explicitly skipped
the System32 prefixing branch when a path contained '%' and then passed the raw
string to FilePathToFileSystemObject, so every COM server registered under
%SystemRoot%, %ProgramData% or similar failed to resolve and silently produced a
FileSystemObject with no permissions.

Expand environment variables before resolving, and also resolve native object
manager prefixes (\??\, \SystemRoot\).

Handle LocalServer32 and LocalServer in addition to InprocServer32. Their values
are command lines rather than bare paths, so the executable is extracted from
the front, preferring a quoted path and then the first token ending in .exe.

Remove the InprocServer64 branch. There is no such registry key, so it was dead
code. Bitness is selected by the registry view, not the key name, so the
resolved binary is now assigned to x86_Binary in the 32-bit view and to
x64_Binary in the 64-bit view. Both views are already parsed, so both fields are
populated across a run; previously x64_Binary was never set at all.

The three near identical inline blocks are replaced by a single
ResolveServerBinary helper. Existing quote, whitespace and System32 handling is
preserved in RegistryReferenceParser.NormalizePath.
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 RegistryObject can never
ask about the file that key points at. Detecting a load point that unprivileged
users can repoint, or that resolves to a binary they can supply, therefore
requires the join to happen during collection.

Add LoadPointObject, which carries for one collected unit the source registry
key and its ACL, the CLSID it resolved through, the resolved target path, the
target's ACL, and flat boolean and string summaries a rule can test directly:
SourceKeyUserWritable, TargetUserWritable, TargetExists, TargetAclSource and
TargetAclUnavailable. Flat fields are deliberate; dotted field navigation is
shallow and regex cannot match dictionary valued fields.

Add LoadPointCollector, driven by a list of LoadPointDefinition rather than a
chain of ifs. It covers COM servers, StaticPluginMap under
InstallService\State, AppInit_DLLs and services; adding coverage means adding an
entry. A load point naming a CLSID is followed through
Classes\CLSID\{guid}\InprocServer32 to the binary, which is the indirection the
whole feature exists to resolve.

A missing target is represented explicitly and kept distinct from a failed ACL
read, because a nonexistent DLL at a path an unprivileged user can write is the
exploitable condition. Where the target is missing the ACL of the nearest
existing parent directory is captured instead, and TargetAclSource says which
was used.

PermissionUtils holds the single definition of user writable, evaluated against
Everyone, INTERACTIVE, Authenticated Users and BUILTIN\Users by both SID and
account name, with Deny taking precedence over Allow.

RegistryObject gains ReferencedPaths and ReferencedClsids, cracked out of the
key's values at collection time and environment expanded. They are List<string>
so Regex, Contains, StartsWith and EndsWith all work on them. Extraction is pure
string manipulation over compiled expressions with substring pre-filters, a
match timeout and length and count caps, since it runs for every value of every
key of every hive in both views.

Ship four rules over the new type covering a writable source key, a writable
target, a missing target in a writable directory, and both ends under
unprivileged control. Severities match the existing rules, which top out at
WARNING.

Tests cover reference extraction, Deny precedence, nearest existing parent
resolution, serialization round trips, and rule matching against a synthetic
object in the shape of CVE-2026-50343 plus benign and unreadable-ACL negatives.
False positives are the main risk to this feature, so the negative cases are
asserted explicitly. Registry and COM collection remain Windows only and are
gated accordingly.
@gfs

Copy link
Copy Markdown
Contributor Author

Note

The following is output from an automated COMPASS SDL Security review run against this PR's diff. It is machine-generated and does not represent my personal review or opinion. Findings should be triaged on their merits.


SDL Security Review

Scope: application source under Cli/ and Lib/, plus the new rules in analyses.json. Tests reviewed for context only.


🔴 Blocking

Lib/Utils/RegistryReferenceParser.cs + Lib/Collectors/LoadPointCollector.cs — SSRF / NTLM credential coercion via UNC paths from an attacker-writable registry value

The path extractor deliberately accepts UNC roots:

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""',;)\]])",

and every extracted path is then dereferenced against the filesystem with no locality check:

loadPoint.TargetExists = File.Exists(path) || Directory.Exists(path);
...
loadPoint.Target = fsc.FilePathToFileSystemObject(path!);
loadPoint.TargetUserWritable = TryIsUserWritable(path!, out var unavailable);

PermissionUtils.NearestExistingParent additionally probes each ancestor with Directory.Exists, and FilePathToFileSystemObject unconditionally does new FileSecurity(path, ...), new FileInfo(path), FileSystemUtils.GetExecutableType(path), WindowsFileSystemUtils.GetSignatureStatus(path) and GetDllCharacteristics(path).

Attack vector. One of the new default definitions is the key this PR itself documents as unprivileged-writable:

// The key that CVE-2026-50343 abuses: its DACL grants INTERACTIVE SetValue and CreateSubKey...
new LoadPointDefinition("StaticPluginMap", RegistryHive.LocalMachine,
    @"SOFTWARE\Microsoft\Windows\CurrentVersion\InstallService\State", false,
    new LoadPointValueSource(null, null, LoadPointTargetKind.Auto)),

Auto with no CLSID present falls back to Path, so a local unprivileged user writes \\attacker.example.com\share\x.dll into any value under that key and waits. When an administrator later runs asa collect -L (or --all on Windows), the elevated ASA process:

  1. opens an SMB session to the attacker's host, implicitly authenticating with the collecting account — the hash is captured for offline cracking or relayed to another service for lateral movement/escalation; and
  2. reads and parses attacker-supplied PE bytes (GetExecutableType, WinVerifyTrust, GetDllCharacteristics) inside that elevated process.

This turns the collector into an amplifier of exactly the escalation primitive it was written to detect. AppInit_DLLs, ImagePath, Parameters\ServiceDll and LocalServer32 are the same shape wherever their keys are weakly ACLed. ComObjectCollector has a narrower version of this today via InprocServer32, and the new ResolveServerBinary inherits it — so the guard belongs in shared code, not just in LoadPointCollector.

Fix. Gate dereferencing on the target being local. Keep extracting UNC paths (they are worth reporting), but when path is UNC (\\…, including \\?\UNC\…) or resolves to a DriveInfo with DriveType.Network, record TargetPath and set TargetAclSource = "None" / TargetAclUnavailable = true instead of calling File.Exists, NearestExistingParent, FilePathToFileSystemObject, or GetAccessControl. Apply the same check in ComObjectCollector.ResolveServerBinary before fsc.FilePathToFileSystemObject(path).

RegistryWalker.PopulateReferences is fine as-is — it only stores strings and never touches the disk.


🟡 Warning

Lib/Collectors/LoadPointCollector.cs — swallowed existence check silently flips the verdict

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);
}

On failure TargetExists stays false, so control falls into the "target is missing" branch, sets TargetAclSource = "NearestExistingParent", and can emit Load Point Target Missing from Unprivileged Writable Directory for a file that actually exists. TargetAclUnavailable is left false, so the rule's NOT ACL_UNAVAILABLE guard does not suppress it. TryIsUserWritable gets this right by setting unavailable = true; do the same here (or add a distinct existence-unknown flag) so an unreadable target is not reported as an exploitable one.

Lib/Utils/PermissionUtils.cs — Deny is only honored against the identical principal string

_ = type == AccessControlType.Deny ? denied.Add(principal) : allowed.Add(principal);
...
allowed.ExceptWith(denied);
return allowed.Count > 0;

Deny Everyone: WriteData does not cancel Allow BUILTIN\Users: WriteData, even though Windows denies any member of Users in that case. That ACL shape is common on hardened directories. Since these overloads are the sole input to SourceKeyUserWritable / TargetUserWritable, and those are the sole inputs to all four new rules, this yields spurious "Privilege Escalation via Unprivileged Load Point" findings. All four recognized principals are groups every unprivileged interactive user belongs to, so a deny on any of them should suppress an allow on all of them — if (denied.Count > 0) return false; matches the class's own stated definition better than the per-principal set difference. The XML remarks claim ACE ordering "cannot change the outcome"; that holds only if cross-principal deny is handled.

Lib/Utils/PermissionUtils.cs — rights matched by ToString() name, producing false negatives

public static bool IsRegistryWriteRight(string? right)
    => right is not null && RegistryWriteRights.Contains(right.Trim());

RegistryRights.ToString() / FileSystemRights.ToString() fall back to the raw decimal value when the mask contains bits with no named member — notably generic rights (GENERIC_ALL / GENERIC_WRITE, 0x10000000/0x40000000), which are routinely preserved verbatim in inherit-only ACEs. In that case IsRegistryWriteRight returns false and a genuinely user-writable key or file is reported as not writable. That is the dangerous direction for a detection tool: it silently drops the exact finding the collector exists to produce. Test the mask numerically for the live-ACL overloads ((rule.RegistryRights & WriteMask) != 0) and keep the string path only for the legacy persisted format.


Verified clean

  • ResolveClsid interpolates clsid into a registry key path, but ExtractClsids constrains it to [0-9A-Fa-f] and hyphens, so no key-path injection is reachable.
  • ResolveServerBinary matches GetSubKeyNames() against a fixed allowlist with Equals, not attacker-supplied names.
  • ReDoS is adequately bounded: 100 ms RegexTimeout on all three expressions, MaxScannedValueLength 8192, MaxReferencesPerValue 32, MaxReferencesPerKey 128, and RegexMatchTimeoutException caught at every call site. Neither regex can match zero-width, so NextMatch() cannot loop forever.
  • Registry handles are disposed on every path, including the early-return in ParseDefinition and the continue in ParseUnit.
  • No LoadLibrary/CreateProcess/Process.Start, no deserialization of untrusted input (JsonUtils adds a concrete LoadPointObject case, no TypeNameHandling change), no secrets, no crypto, no network endpoints.
  • The Permissions value format change ("SetValue""Allow:SetValue") does not break any embedded rule — no rule in analyses.json matches on registry Permissions. Note it will make every registry key show a Permissions delta when comparing a pre-change baseline against a post-change collection.

REQUEST CHANGES — the UNC dereference must be fixed before merge.

A path read out of the registry does not have to name this machine, and whoever
can write the value picks which machine it names. Resolving one was not a
passive read: File.Exists, the ACL lookup and FilePathToFileSystemObject each
open a session to that host and authenticate as the account running the
collection, which for these collectors is usually an administrator, and then
read content that host controls. The StaticPluginMap key the load point
collector was written for grants INTERACTIVE SetValue, so an unprivileged user
could choose the host.

Load point targets and COM server binaries that resolve through a UNC path or a
mapped network drive are now reported by path and left alone. The path is still
recorded, and LoadPointObject.TargetIsNetworkPath says why nothing else was
collected, with TargetAclUnavailable set so the existing rules do not read the
absence of an ACL as a verdict. --follow-network-paths opts back in, in the same
way --download-cloud opts into hydrating cloud placeholders.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d4d92e29-a96d-4ec9-91f7-fe7d4a350534
@gfs

Copy link
Copy Markdown
Contributor Author

Addressed the blocking finding in 020d19d.

Load point targets and COM server binaries that resolve through a UNC path or a mapped network drive are no longer touched. The path is still recorded so a rule can see where the load point points, LoadPointObject.TargetIsNetworkPath says why nothing else was collected, and TargetAclUnavailable is set so the existing rules do not read the absence of an ACL as a verdict. --follow-network-paths opts back in, mirroring how --download-cloud opts into hydrating cloud placeholders.

New PathUtils.IsNetworkPath answers from the path and the local mount table, so asking never touches the network. It handles bare UNC, \\?\UNC\, and drive letters mapped to a share, and does not treat \\?\C:\ as remote.

The three warnings are not addressed in this commit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant