Public/Export-BloodHoundData.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Export-BloodHoundData { <# .SYNOPSIS Exports PSGuerrilla's collected Active Directory graph to a BloodHound OpenGraph file. .DESCRIPTION Turns the privileged-group membership and dangerous-ACL data PSGuerrilla already collects into a BloodHound CE OpenGraph import (nodes + edges) so an operator can pathfind the full attack surface in BloodHound. Edges use BloodHound's NATIVE kinds (GenericAll, WriteDacl, WriteOwner, GenericWrite, AllExtendedRights, GetChanges, GetChangesAll, MemberOf), and nodes carry their SID as objectid, so the import overlays cleanly with native SharpHound data and BloodHound's built-in path queries work over it. Unlike the in-report attack-path engine, this export includes the FULL graph (it does NOT drop by-design / default principals) — BloodHound performs its own reachability analysis, so it needs the complete edge set. Output is the OpenGraph shape: { "metadata": { "source_kind": "PSGuerrilla" }, "graph": { "nodes": [ { id, kinds, properties } ], "edges": [ { start:{value}, end:{value}, kind, properties } ] } } Note: depth/coverage tracks PSGuerrilla's ACL collection — today the six critical Tier-0 objects plus privileged-group membership. The full-domain ACL collector (roadmap) widens the exported edge set; this exporter consumes it unchanged. .PARAMETER AuditData The collected reconnaissance data (hashtable with ACLs.DangerousACEs and PrivilegedAccounts.PrivilegedGroups). .PARAMETER OutputPath Destination .json path. Default: ./PSGuerrilla-BloodHound.json. .EXAMPLE Export-BloodHoundData -AuditData $reconData -OutputPath .\corp-bh.json # Then in BloodHound CE: Administration > File Ingest > upload corp-bh.json #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData, [string]$OutputPath = (Join-Path (Get-Location) 'PSGuerrilla-BloodHound.json') ) $nodes = @{} # id -> node object (dedup by id) $edges = [System.Collections.Generic.List[object]]::new() $edgeSeen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $idFor = { param($name, $sid) if ($sid) { "$sid".ToUpper() } else { ("NAME:" + ("$name").Trim().ToUpper()) } } # Derive the domain SID from any domain-relative member/principal SID we hold, so well-known # privileged groups (whose own SID isn't on the membership key) can be keyed by their real SID # and overlay SharpHound's nodes instead of landing as parallel NAME:<group> nodes. $domainSid = $null $sidPool = @() if ($AuditData.PrivilegedAccounts.PrivilegedGroups) { foreach ($e in $AuditData.PrivilegedAccounts.PrivilegedGroups.GetEnumerator()) { foreach ($m in @($e.Value)) { if ($m.SID) { $sidPool += "$($m.SID)" } } } } foreach ($ace in @($AuditData.ACLs.DangerousACEs)) { if ($ace.IdentitySID) { $sidPool += "$($ace.IdentitySID)" }; if ($ace.ObjectSID) { $sidPool += "$($ace.ObjectSID)" } } foreach ($s in $sidPool) { if ("$s" -match '^(S-1-5-21-\d+-\d+-\d+)-\d+$') { $domainSid = $Matches[1]; break } } # Built-in alias SIDs (constant) + domain-relative well-known RIDs. $builtinAlias = @{ 'administrators' = 'S-1-5-32-544'; 'account operators' = 'S-1-5-32-548'; 'server operators' = 'S-1-5-32-549' 'print operators' = 'S-1-5-32-550'; 'backup operators' = 'S-1-5-32-551'; 'remote desktop users' = 'S-1-5-32-555' 'network configuration operators' = 'S-1-5-32-556'; 'pre-windows 2000 compatible access' = 'S-1-5-32-554' } $wellKnownRid = @{ 'domain admins' = 512; 'enterprise admins' = 519; 'schema admins' = 518; 'domain controllers' = 516 'cert publishers' = 517; 'group policy creator owners' = 520; 'read-only domain controllers' = 521 'enterprise read-only domain controllers' = 498; 'key admins' = 526; 'enterprise key admins' = 527 'administrator' = 500; 'krbtgt' = 502 } $resolveWellKnownSid = { param($name) $n = ("$name" -split '\\')[-1].Trim().ToLower() if ($builtinAlias.ContainsKey($n)) { return $builtinAlias[$n] } if ($domainSid -and $wellKnownRid.ContainsKey($n)) { return "$domainSid-$($wellKnownRid[$n])" } return $null } $kindForObject = { param($objClass, $objName) $oc = "$objClass".ToLower(); $on = "$objName".ToLower() if ($oc -match 'group') { return 'Group' } if ($oc -match 'computer') { return 'Computer' } if ($oc -match 'user') { return 'User' } if ($oc -match 'organizationalunit' -or $on -match ' ou$|controllers ou') { return 'OU' } if ($oc -match 'grouppolicy' -or $on -match 'gpo|policies') { return 'GPO' } if ($on -eq 'domain root' -or $oc -match 'domaindns') { return 'Domain' } return 'Base' } $kindForMember = { param($isGroup, $sam) if ($isGroup) { 'Group' } elseif ("$sam" -match '\$$') { 'Computer' } else { 'User' } } $addNode = { param($id, $name, $kind, $sid) if (-not $id) { return } if (-not $nodes.ContainsKey($id)) { $props = @{ name = ("$name").ToUpper() } if ($sid) { $props['objectid'] = "$sid".ToUpper() } $nodes[$id] = [PSCustomObject]@{ id = $id; kinds = @($kind, 'Base'); properties = $props } } } $addEdge = { param($startId, $endId, $kind) if (-not $startId -or -not $endId -or $startId -eq $endId) { return } $k = "$startId|$endId|$kind" if (-not $edgeSeen.Add($k)) { return } $edges.Add([PSCustomObject]@{ start = @{ value = $startId } end = @{ value = $endId } kind = $kind properties = @{ source = 'PSGuerrilla' } }) } # ── Membership edges (member -> group, kind MemberOf) ── if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.PrivilegedGroups) { foreach ($entry in $AuditData.PrivilegedAccounts.PrivilegedGroups.GetEnumerator()) { $gName = "$($entry.Key)" # Resolve the group's real SID where it's a well-known privileged group, so the node # overlays SharpHound's SID-keyed equivalent instead of a parallel NAME: node. $gSid = (& $resolveWellKnownSid $gName) $gId = (& $idFor $gName $gSid) & $addNode $gId $gName 'Group' $gSid foreach ($m in @($entry.Value)) { $sam = "$($m.SamAccountName)" if (-not $sam) { continue } # Member SID if present, else resolve well-known (covers nested built-in groups). $mSid = if ($m.SID) { "$($m.SID)" } else { (& $resolveWellKnownSid $sam) } $mId = (& $idFor $sam $mSid) & $addNode $mId $sam (& $kindForMember $m.IsGroup $sam) $mSid & $addEdge $mId $gId 'MemberOf' } } } # ── Control edges from dangerous ACEs (principal -> object) ── $acl = $AuditData.ACLs $haveAcl = [bool]($acl -and (-not ($acl -is [System.Collections.IDictionary]) -or $acl.Contains('DangerousACEs'))) if ($haveAcl) { foreach ($ace in @($acl.DangerousACEs)) { $principal = "$($ace.IdentityReference)" $pSid = "$($ace.IdentitySID)" if (-not $principal -and -not $pSid) { continue } $pName = ($principal -split '\\')[-1] $pId = (& $idFor $pName $pSid) & $addNode $pId $pName 'Base' $pSid $objName = "$($ace.ObjectName)" $objSid = "$($ace.ObjectSID)" $oId = (& $idFor $objName $objSid) & $addNode $oId $objName (& $kindForObject $ace.ObjectClass $objName) $objSid # Map the AD right to a native BloodHound edge kind. $r = "$($ace.ActiveDirectoryRights)"; $ot = "$($ace.ObjectType)" $kind = if ($ot -match '1131f6ad' -or $r -match 'Get-Changes-All|GetChangesAll') { 'GetChangesAll' } elseif ($ot -match '1131f6aa' -or $r -match 'Get-Changes|GetChanges') { 'GetChanges' } elseif ($r -match '(?i)GenericAll') { 'GenericAll' } elseif ($r -match '(?i)WriteDacl') { 'WriteDacl' } elseif ($r -match '(?i)WriteOwner') { 'WriteOwner' } elseif ($r -match '(?i)GenericWrite') { 'GenericWrite' } elseif ($r -match '(?i)ExtendedRight') { 'AllExtendedRights' } elseif ($r -match '(?i)WriteProperty') { 'GenericWrite' } else { 'GenericAll' } & $addEdge $pId $oId $kind } } $payload = [PSCustomObject]@{ metadata = @{ source_kind = 'PSGuerrilla'; generated_by = 'PSGuerrilla'; version = 1 } graph = [PSCustomObject]@{ nodes = @($nodes.Values) edges = @($edges) } } $dir = Split-Path -Parent $OutputPath if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } $payload | ConvertTo-Json -Depth 8 | Set-Content -Path $OutputPath -Encoding UTF8 return [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.BloodHoundExport' Path = $OutputPath NodeCount = @($nodes.Values).Count EdgeCount = @($edges).Count Format = 'BloodHound OpenGraph' Message = "BloodHound OpenGraph written to $OutputPath ($(@($nodes.Values).Count) nodes, $(@($edges).Count) edges). Import via BloodHound CE > Administration > File Ingest." } } |