From a77f8cc1e1563998bba4e3a8d152244681370d70 Mon Sep 17 00:00:00 2001 From: chryzsh Date: Wed, 16 Sep 2026 09:52:55 +0200 Subject: [PATCH 1/3] fix: only count ACEs that actually grant Full Control on System Management _parse_sd_generic_all read the access mask and nothing else. It decoded the object-ACE flags purely to work out where the SID started, then threw the meaning away, and it never looked at AceFlags at all. Exchange's setup /PrepareAD writes ACEs at the domain root for Exchange Trusted Subsystem and Organization Management with a 0x000F01FF mask, and they inherit down onto CN=System Management. None of them grant anything on the container: - some are INHERIT_ONLY, present only so AD can copy them to child objects - the rest carry an ObjectType GUID, which scopes the mask to a single Exchange property set, or an InheritedObjectType GUID, which scopes it to one child object class Both got reported as GenericAll. _expand_group_targets then walked Exchange Trusted Subsystem and registered every Exchange server as a scan target. Checked against a real domain DACL: 10 principals reported, of which only the site server and the SCCM group were real. Now skip INHERIT_ONLY ACEs, skip object ACEs carrying either GUID, and skip LOCAL SYSTEM / BUILTIN\Administrators / CREATOR OWNER. Plain and container-inherit ACEs are unaffected. tests/ldap_smc_dacl_scope_test.py covers both directions with synthetic descriptors: five of the eight cases fail without this change. Co-Authored-By: Claude Opus 5 (1M context) --- src/openhound_sccm/collectors/ldap.py | 58 ++++++++++-- tests/ldap_smc_dacl_scope_test.py | 122 ++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 tests/ldap_smc_dacl_scope_test.py diff --git a/src/openhound_sccm/collectors/ldap.py b/src/openhound_sccm/collectors/ldap.py index 0e012a3..f96a978 100644 --- a/src/openhound_sccm/collectors/ldap.py +++ b/src/openhound_sccm/collectors/ldap.py @@ -785,6 +785,15 @@ def _expand_group_targets(ctx: SourceContext, group_obj: dict[str, Any], return rows +# Built-in principals that hold Full Control on essentially every object and say +# nothing about whether a host runs SCCM. +IGNORED_ACE_SIDS = frozenset({ + "S-1-5-18", # LOCAL SYSTEM + "S-1-5-32-544", # BUILTIN\\Administrators + "S-1-3-0", # CREATOR OWNER +}) + + def _parse_sd_generic_all(sd_bytes: bytes) -> list[str]: """ Parse a Windows SECURITY_DESCRIPTOR binary blob and return SIDs with GenericAll. @@ -794,6 +803,22 @@ def _parse_sd_generic_all(sd_bytes: bytes) -> list[str]: enum GenericAll = 0x000F01FF. We check for both, plus any mask that includes all the AD-specific rights. + The access mask on its own is not enough to call an ACE Full Control, so two + further checks are applied: + + - An ACE whose AceFlags carry INHERIT_ONLY (0x08) grants nothing on this + container. It sits here only so Active Directory can copy it down to child + objects. + - An object ACE (type 0x05) carrying an ObjectType GUID grants its mask over + that one property set or extended right, not over the whole object. One + carrying an InheritedObjectType GUID only ever applies to child objects of + that class. + + Exchange's `setup /PrepareAD` writes both kinds at the domain root with a + 0x000F01FF mask for Exchange Trusted Subsystem and Organization Management, + and they inherit down onto System Management. Counting them as Full Control + made every Exchange server look like an SCCM site server. + Structure reference: - SECURITY_DESCRIPTOR header (20 bytes for self-relative) - DACL at OffsetDacl @@ -827,6 +852,9 @@ def _parse_sd_generic_all(sd_bytes: bytes) -> list[str]: GENERIC_ALL = 0x10000000 ACCESS_ALLOWED_ACE_TYPE = 0x00 ACCESS_ALLOWED_OBJECT_ACE_TYPE = 0x05 + INHERIT_ONLY_ACE = 0x08 + ACE_OBJECT_TYPE_PRESENT = 0x01 + ACE_INHERITED_OBJECT_TYPE_PRESENT = 0x02 for _ in range(ace_count): if pos + 4 > len(sd_bytes): @@ -834,6 +862,7 @@ def _parse_sd_generic_all(sd_bytes: bytes) -> list[str]: break ace_type = sd_bytes[pos] + ace_flags = sd_bytes[pos + 1] ace_size = struct.unpack_from(" len(sd_bytes): @@ -850,7 +879,11 @@ def _parse_sd_generic_all(sd_bytes: bytes) -> list[str]: access_mask = struct.unpack_from(" list[str]: # [ObjectType(16)] + [InheritedObjectType(16)] + SID if pos + 12 <= len(sd_bytes): obj_flags = struct.unpack_from(" bytes: + """Pack a string SID back into its on-the-wire form.""" + parts = sid.split("-")[1:] + revision, authority = int(parts[0]), int(parts[1]) + subs = [int(p) for p in parts[2:]] + out = struct.pack(" bytes: + sid_b = _sid_bytes(sid) + size = 8 + len(sid_b) + return struct.pack(" bytes: + sid_b = _sid_bytes(sid) + guids = b"" + if obj_flags & ACE_OBJECT_TYPE_PRESENT: + guids += GUID + if obj_flags & ACE_INHERITED_OBJECT_TYPE_PRESENT: + guids += GUID + size = 12 + len(guids) + len(sid_b) + return (struct.pack(" bytes: + """Wrap ACEs in a self-relative SECURITY_DESCRIPTOR with the DACL at offset 20.""" + body = b"".join(aces) + acl = struct.pack(" Date: Tue, 6 Oct 2026 11:52:13 -0700 Subject: [PATCH 2/3] Fixing issue identified in PowerShell script --- powershell_deprecated/ConfigManBearPig.ps1 | 52 +++++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/powershell_deprecated/ConfigManBearPig.ps1 b/powershell_deprecated/ConfigManBearPig.ps1 index a06d33a..c076110 100644 --- a/powershell_deprecated/ConfigManBearPig.ps1 +++ b/powershell_deprecated/ConfigManBearPig.ps1 @@ -2960,6 +2960,50 @@ function Get-MssqlEpaSettingsViaRemoteRegistry { } } +function Test-SystemManagementFullControlRule { + param([System.DirectoryServices.ActiveDirectoryAccessRule]$Rule) + + if ($Rule.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { + Write-LogMessage Verbose "Skipping non-Allow System Management ACE for $($Rule.IdentityReference)" + return $false + } + + $rights = [int]$Rule.ActiveDirectoryRights + $genericAll = [int][System.DirectoryServices.ActiveDirectoryRights]::GenericAll + if ((($rights -band $genericAll) -ne $genericAll) -and (($rights -band 0x10000000) -eq 0)) { + Write-LogMessage Verbose "Skipping System Management ACE without Full Control for $($Rule.IdentityReference)" + return $false + } + + if (([int]$Rule.PropagationFlags -band [int][System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { + Write-LogMessage Verbose "Skipping inherit-only System Management ACE for $($Rule.IdentityReference)" + return $false + } + + if (([int]$Rule.ObjectFlags -band [int][System.Security.AccessControl.ObjectAceFlags]::ObjectAceTypePresent) -ne 0) { + Write-LogMessage Verbose "Skipping object-type-scoped System Management ACE for $($Rule.IdentityReference)" + return $false + } + + $identity = $Rule.IdentityReference.ToString() + if ($identity -like 'NT AUTHORITY\*') { + Write-LogMessage Verbose "Skipping built-in System Management ACE for $identity" + return $false + } + + try { + $sid = $Rule.IdentityReference.Translate([System.Security.Principal.SecurityIdentifier]).Value + if ($sid -in @('S-1-5-18', 'S-1-5-32-544', 'S-1-3-0')) { + Write-LogMessage Verbose "Skipping built-in System Management ACE for $identity" + return $false + } + } catch { + Write-LogMessage Verbose "Could not translate System Management ACE principal $identity to a SID: $_" + } + + return $true +} + function Invoke-LDAPCollection { Write-LogMessage Info "Starting LDAP collection..." @@ -3457,9 +3501,7 @@ function Invoke-LDAPCollection { if ($script:ADModuleAvailable) { $acl = Get-Acl -Path "AD:\$systemManagementDN" $genericAllAccounts = $acl.Access | Where-Object { - $_.AccessControlType -eq "Allow" -and - $_.ActiveDirectoryRights -eq "GenericAll" - $_.IdentityReference -notlike "NT AUTHORITY\*" + Test-SystemManagementFullControlRule -Rule $_ } foreach ($account in $genericAllAccounts) { @@ -3531,9 +3573,7 @@ function Invoke-LDAPCollection { $accessRules = $ntSecurityDescriptor.GetAccessRules($true, $true, [System.Security.Principal.NTAccount]) foreach ($rule in $accessRules) { - if ($rule.AccessControlType -eq "Allow" -and - $rule.ActiveDirectoryRights -match "GenericAll" -and - $rule.IdentityReference -notlike "NT AUTHORITY\*") { + if (Test-SystemManagementFullControlRule -Rule $rule) { Write-LogMessage Success "Found principal with GenericAll on System Management container: $($rule.IdentityReference)" From d2daf3bcfe50e49d1c5367a4c779c53d7e17120f Mon Sep 17 00:00:00 2001 From: Mayyhem Date: Tue, 6 Oct 2026 12:18:49 -0700 Subject: [PATCH 3/3] Bump version --- powershell_deprecated/ConfigManBearPig.ps1 | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/powershell_deprecated/ConfigManBearPig.ps1 b/powershell_deprecated/ConfigManBearPig.ps1 index c076110..bb38bb2 100644 --- a/powershell_deprecated/ConfigManBearPig.ps1 +++ b/powershell_deprecated/ConfigManBearPig.ps1 @@ -9987,7 +9987,7 @@ try { $script:Edges = @() # Script version information - $script:ScriptVersion = "1.2" + $script:ScriptVersion = "1.3" $script:ScriptName = "ConfigManBearPig" if ($Version) { diff --git a/pyproject.toml b/pyproject.toml index 1cdb4bf..155d0ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ name = "configmanbearpig" # Version comes from the git tag via hatch-vcs (see [tool.hatch.version] below). dynamic = ["version"] -description = "ConfigManBearPig 2.0 — OpenHound/BloodHound OpenGraph collector for Microsoft Configuration Manager (SCCM/MECM)" +description = "ConfigManBearPig 2.0.2 — OpenHound/BloodHound OpenGraph collector for Microsoft Configuration Manager (SCCM/MECM)" readme = "README.md" requires-python = "<3.15,>=3.13" authors = [