diff --git a/.tickets/_TICKETS-BY-STATUS.md b/.tickets/_TICKETS-BY-STATUS.md index b12c87c..b4ca776 100644 --- a/.tickets/_TICKETS-BY-STATUS.md +++ b/.tickets/_TICKETS-BY-STATUS.md @@ -5,7 +5,7 @@ Regenerate after any status change rather than editing by hand. `.gitattributes` marks this file `merge=ours`, which needs a one-time `git config merge.ours.driver true` per clone. -**150 tickets** — 6 in progress · 29 open · 115 closed +**151 tickets** — 6 in progress · 30 open · 115 closed ## In progress (6) @@ -18,7 +18,7 @@ Regenerate after any status change rather than editing by hand. | [`con-907c`](con-907c.md) | 2 | task | Rebaseline fixture expected values on the full lab + add host-level SEC assertions | testing, integration, fixtures, lab | | [`ope-1f0f`](ope-1f0f.md) | 2 | task | Code-Quality Pass: Conditional Logging, Exception Handling, Variable Scope, Linting & Cleanup -type chore -priority 2 -assignee Mayyhem -tags sccm,... | — | -## Open (29) +## Open (30) | Ticket | P | Type | Title | Tags | |---|---|---|---|---| @@ -31,6 +31,7 @@ Regenerate after any status change rather than editing by hand. | [`con-0289`](con-0289.md) | 2 | bug | Integration fixtures cannot be gated on possible-edges, so low-priv + --disable-possible-edges reports 4 false failures | sccm, integration, fixtures, possible-edges | | [`con-0394`](con-0394.md) | 2 | task | Positively identify a secondary site or site server as a plain domain user | sccm, lowpriv, secondary, discovery | | [`con-2ca2`](con-2ca2.md) | 2 | bug | _safe silently swallows BinderException, turning transform bugs into no-ops | preproc, diagnostics, transforms, testing | +| [`con-7b9e`](con-7b9e.md) | 2 | bug | Fix System Management ACL Full Control filtering in deprecated PowerShell collector | powershell, sccm, ldap, acl | | [`con-c542`](con-c542.md) | 2 | task | Tag fixture cases from measured privileged/unprivileged A/B | testing, integration, fixtures, lowpriv | | [`con-d857`](con-d857.md) | 2 | task | Add TAKEOVER from parent CAS to child primary as possible/assumed edge | — | | [`ope-1172`](ope-1172.md) | 2 | task | Test different port / named instance | — | diff --git a/.tickets/con-7b9e.md b/.tickets/con-7b9e.md new file mode 100644 index 0000000..a6bc43a --- /dev/null +++ b/.tickets/con-7b9e.md @@ -0,0 +1,20 @@ +--- +id: con-7b9e +status: open +deps: [] +links: [] +created: 2026-10-06T01:06:21Z +type: bug +priority: 2 +tags: [powershell, sccm, ldap, acl] +--- + +# Fix System Management ACL Full Control filtering in deprecated PowerShell collector + +`powershell_deprecated/ConfigManBearPig.ps1` incorrectly treats some System Management container ACEs as Full Control. In the AD-module path near line 3459, the `Where-Object` filter is missing `-and` before the `NT AUTHORITY` exclusion, so its last expression can admit Deny and non-GenericAll ACEs. Both the AD-module and DirectoryServices paths also need to exclude inherit-only ACEs and rights scoped by `ObjectType`; Exchange ACEs of these forms can otherwise make Exchange servers appear to be SCCM collection targets. See ConfigManBearPig PR #14 for the Python collector's related fix. + +## Acceptance Criteria + +- Both PowerShell ACL paths retain effective Allow Full Control on the container, including a container-inherit ACE and an object ACE whose only GUID is `InheritedObjectType` and that is not inherit-only. `InheritedObjectType` limits child inheritance; by itself it does not remove rights on the current container. +- Deny, non-Full-Control, inherit-only, and `ObjectType`-scoped ACEs do not produce Full Control principals or targets. Exclude LOCAL SYSTEM, BUILTIN\Administrators, and CREATOR OWNER from SCCM target inference. +- A regression test covers the AD-module filter's missing `-and` and the Exchange/SCCM mixed-ACL case. A lab check confirms Exchange servers are no longer added solely through this ACL while genuine SCCM site servers still are. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 450ee2c..e7eaf0e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -399,6 +399,11 @@ Two more pieces wire discovery into this loop: while the snapshot returns the target only once. If a RemoteRegistry worker has no registered target, it warns and emits the available rows without a host AD SID. +The System Management DACL parser supplies another gate before target registration: inherit-only and +`ObjectType`-scoped ACEs cannot establish Full Control on the container. `InheritedObjectType` alone only +limits which children inherit an ACE, so an otherwise effective Full Control ACE still feeds group +expansion and target discovery. + One discovery path recurses on its own, one level below `register_target`: [`_expand_group_targets`](src/openhound_sccm/collectors/ldap.py#L618-L661) (ope-e191). When `ldap_system_management_dacl` finds a **group** holding GenericAll on the System Management container, @@ -2112,6 +2117,7 @@ took a separate, manual step outside `openhound collect sccm`. | Date | Change | |---|---| +| 2026-10-05 | **System Management ACL scope preserves effective inheritance-type ACEs** (section 3). The DACL parser skips inherit-only and ObjectType-scoped grants, but an InheritedObjectType GUID alone limits child inheritance and does not remove Full Control on the container. A regression test distinguishes that case from inherit-only; a DC-only lab run with disposable Exchange-like and SCCM principals confirmed DACL rows, GenericAll edges, and target registration. | | 2026-09-30 | **Added opt-in `--http-proxy` on the PR #11 branch.** Direct HTTP remains the default. An explicit HTTP proxy or `auto` routes AdminService and HTTP role probes only; `auto` reads environment and static Windows registry proxy settings and honors bypasses while the Requests session keeps `trust_env=False` to avoid ambient `.netrc` credentials. The option cannot be combined with the process-wide SOCKS5 `--proxy`. Proxy 407 responses produce a visible warning, and a failed direct HTTP connection suggests `auto` when environment or static Windows proxy settings apply to that target. | | 2026-09-30 | **Bounded verbose output from local client logs without losing host discovery** (§7). The scraper keeps reading CCM/ccmsetup logs on Windows even when `root\CCM` is absent. It logs one full URL and UNC sample per host, counts suppressed matches in one summary, and leaves the target set unchanged. A test covers repeated references across both log directories and discovery from logs left by a former client. | | 2026-09-30 | **Preserved target identity across hostname upgrades and incomplete RemoteRegistry discovery** (§3). A queued short hostname remains an alias when its target is upgraded to an FQDN; snapshots deduplicate the aliases. Registry lookups use normalized hostnames and emit partial rows with a warning if a target is genuinely absent, instead of failing the phase. Regression tests cover mixed-case names, the queued-name upgrade, and missing targets. | diff --git a/README.md b/README.md index 964f0bf..aed48a9 100644 --- a/README.md +++ b/README.md @@ -1685,6 +1685,8 @@ Example (`SCCM_AdminsReplicatedTo`, abbreviated): Links each AD principal that holds Full Control over the System Management container to the [`Container`](#container) node for it. Wires up `ldap_system_management_dacl`, which the collector always parsed for its DACL but which fed no edge at all before the low-privilege work — this is "who can control SCCM via AD", the attack surface the container's ACL actually represents. +The LDAP parser excludes inherit-only and `ObjectType`-scoped ACEs. An `InheritedObjectType` GUID alone limits child inheritance, so an otherwise effective Full Control ACE still counts on this container. + - **Start:** the Full-Control principal (any AD SID — `Computer`, `User`, or `Group`) - **End:** [`Container`](#container) (the System Management container) - **Traversable:** yes — standard BloodHound base kind, in `TRAVERSABLE_EDGE_KINDS` alongside `GenericAll`'s usual SharpHound meaning diff --git a/powershell_deprecated/ConfigManBearPig.ps1 b/powershell_deprecated/ConfigManBearPig.ps1 index a06d33a..bb38bb2 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)" @@ -9947,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 = [ diff --git a/src/openhound_sccm/collectors/ldap.py b/src/openhound_sccm/collectors/ldap.py index 0e012a3..2edd815 100644 --- a/src/openhound_sccm/collectors/ldap.py +++ b/src/openhound_sccm/collectors/ldap.py @@ -678,6 +678,7 @@ def ldap_system_management_dacl(ctx: SourceContext) -> Iterable[Any]: obj_type = "unknown" if "computer" in [c.lower() for c in obj_class]: obj_type = "computer" + logger.info(f"Found {obj_type} with GenericAll on System Management container: {sam} ({sid_str})") # Add as collection target. register_target logs why it skipped # (filtered host or empty name), so we don't inspect the result. @@ -689,8 +690,10 @@ def ldap_system_management_dacl(ctx: SourceContext) -> Iterable[Any]: elif "user" in [c.lower() for c in obj_class]: obj_type = "user" + logger.info(f"Found {obj_type} with GenericAll on System Management container: {sam} ({sid_str})") elif "group" in [c.lower() for c in obj_class]: obj_type = "group" + logger.info(f"Found {obj_type} with GenericAll on System Management container: {sam} ({sid_str})") # Members effectively inherit Full Control on the container (ope-e191) — # recurse so member computers get registered as scan targets too, and # (Task 12) collect a MemberOf row for every member -> containing-group @@ -699,7 +702,6 @@ def ldap_system_management_dacl(ctx: SourceContext) -> Iterable[Any]: # feed both the DACL-principal table and the membership table. for member_row in _expand_group_targets(ctx, ad_obj, set()): yield dlt.mark.with_table_name(member_row, "ldap_smc_group_members") - logger.info(f"Found {obj_type} with GenericAll on System Management container: {sam} ({sid_str})") # Task 11: stamp the container's identity on every principal row so the # transform can build the Container node + GenericAll edge from this @@ -729,7 +731,7 @@ def _expand_group_targets(ctx: SourceContext, group_obj: dict[str, Any], group_dn = group_obj.get("distinguished_name") group_sid = group_obj.get("object_sid") if not group_dn or group_dn in visited: - logger.debug("System Management container ACL group expansion: skipping visited/empty group %s", group_dn) + logger.info("System Management container ACL group expansion (%s): skipping visited/empty group", group_obj.get("sam_account_name")) return rows visited.add(group_dn) grp = next(ctx.ad.paged_search("(objectClass=*)", ["member"], base=group_dn, scope=BASE), None) or {} @@ -742,17 +744,17 @@ def _expand_group_targets(ctx: SourceContext, group_obj: dict[str, Any], # knows membership may be incomplete rather than silently under-collecting. if any(str(k).lower().startswith("member;range=") for k in grp): logger.warning( - "System Management container ACL group expansion: group %s returned a range-limited member attribute " + "System Management container ACL group expansion (%s): group returned a range-limited member attribute " "(ldap3 auto_range did not complete); membership may be incomplete — some " "controlling principals could be undiscovered. Review manually.", group_dn) if not members: - logger.debug("System Management container ACL group expansion: group %s has no members", group_dn) + logger.info("System Management container ACL group expansion (%s): group has no members", group_obj.get("sam_account_name")) return rows for member_dn in members: member = ctx.resolve_principal(member_dn) if not member: - logger.warning("System Management container ACL group expansion: could not resolve member %s", member_dn) + logger.warning("System Management container ACL group expansion (%s): could not resolve member %s", group_obj.get("sam_account_name"), member_dn) continue member_sid = member.get("object_sid") oc = member.get("object_class", []) @@ -768,23 +770,32 @@ def _expand_group_targets(ctx: SourceContext, group_obj: dict[str, Any], # No SID on one side means no MemberOf row can be keyed -- log and move # on rather than emit an unusable row (mirrors the existing "could not # resolve member" skip just above). - logger.debug( - "System Management container ACL group expansion: %s has no resolvable " + logger.verbose( + "System Management container ACL group expansion (%s): %s has no resolvable " "SID pair (group=%s member=%s); skipping MemberOf row", - member_dn, group_sid, member_sid) + group_obj.get("sam_account_name"), member_dn, group_sid, member_sid) if "computer" in ocl: ctx.register_target(identifier=member.get("dns_host_name"), source="LDAP-GenericAllSystemManagement", ad_object=member) elif "group" in ocl: rows.extend(_expand_group_targets(ctx, member, visited)) elif "user" in ocl: - logger.info("System Management container ACL group expansion: user member %s controls the container (modeled, not a scan target)", - member.get("sam_account_name")) + logger.info("System Management container ACL group expansion (%s): user member %s controls the container (modeled, not a scan target)", + group_obj.get("sam_account_name"), member.get("sam_account_name")) else: - logger.warning("System Management container ACL group expansion: member %s has unhandled objectClass %s", member_dn, ocl) + logger.warning("System Management container ACL group expansion (%s): member %s has unhandled objectClass %s", group_obj.get("sam_account_name"), member_dn, ocl) 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 +805,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 limits which child classes + inherit the ACE; it can still grant access on this container. + + 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 +854,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 +864,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 +881,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("