Deetz.psm1
|
function Add-CustomType { <# .SYNOPSIS Add a custom type to an object .DESCRIPTION Adds specified custom type to PowerShell object. .PARAMETER Object The Object to add the type to .PARAMETER Type The name of the type to add. .EXAMPLE $Object | Add-CustomType -type 'Some.Custom.Type' Adds 'Some.Custom.Type' to $Object #> [CmdletBinding()] Param ( [Parameter( Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $false )] [psobject]$Object, [Parameter( Mandatory = $true, ValueFromPipeline = $false, ValueFromPipelineByPropertyName = $false )] [string]$Type ) Process { [Void]$Object.PSObject.TypeNames.Insert(0, $Type) $Object } } function ConvertTo-TierZeroAccount { <# .SYNOPSIS Formats a registry entry as a Tier Zero account output object .DESCRIPTION Returns a Deetz.TierZeroAccount object holding every Deetz.TierZeroReason recorded for the account, with the distinct sources and Tier Zero groups of those reasons. Foreign security principals are translated to DOMAIN\name where the SID resolves, and given the DNS and NetBIOS names of the trusted domain which issued the SID. .PARAMETER Entry A registry entry created by Register-TierZeroAccount .PARAMETER Forest Forest details, as returned by Get-ForestInfo .EXAMPLE ConvertTo-TierZeroAccount -Entry $Entry -Forest $Forest #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [psobject]$Entry, [Parameter( Mandatory = $true )] [psobject]$Forest ) $Object = $Entry.Object $SID = "$($Object.objectSid)" $SamAccountName = $Object.sAMAccountName $DomainName = $null $NetBIOSDomain = $null if ($Object.ObjectClass -eq 'foreignSecurityPrincipal') { $TrustedDomain = Get-SidDomain -SID $SID -Forest $Forest -TrustOnly $DomainName = $TrustedDomain.DNSRoot $NetBIOSDomain = $TrustedDomain.NetBIOSName try { $NTAccount = ([System.Security.Principal.SecurityIdentifier]$SID).Translate([System.Security.Principal.NTAccount]).Value $NetBIOSDomain, $SamAccountName = $NTAccount -split '\\', 2 } catch { Write-Verbose "Unable to translate foreign security principal $SID" } } else { $DomainName = Get-DomainName -DistinguishedName $Object.DistinguishedName $NetBIOSDomain = (@($Forest.Domains) + @($Forest.Trusts) | Where-Object { $PSItem.DNSRoot -eq $DomainName } | Select-Object -First 1).NetBIOSName } $UserAccountControl = $Object.userAccountControl [pscustomobject]@{ SamAccountName = $SamAccountName Domain = $DomainName NetBIOSDomain = $NetBIOSDomain UserPrincipalName = $Object.userPrincipalName ObjectClass = $Object.ObjectClass Enabled = $(if ($null -ne $UserAccountControl) { -not ($UserAccountControl -band 2) }) AdminCount = $Object.adminCount PasswordLastSet = $(if ($Object.pwdLastSet -gt 0) { [datetime]::FromFileTime($Object.pwdLastSet) }) LastLogonDate = $(if ($Object.lastLogonTimestamp -gt 0) { [datetime]::FromFileTime($Object.lastLogonTimestamp) }) ServicePrincipalName = [string[]]@($Object.servicePrincipalName | Where-Object { $PSItem }) Source = [string[]]@($Entry.Reason.Source | Select-Object -Unique) TierZeroGroup = [string[]]@($Entry.Reason.TierZeroGroup | Where-Object { $PSItem } | Select-Object -Unique) Reason = [psobject[]]@($Entry.Reason) SID = $SID DistinguishedName = $Object.DistinguishedName } | Add-CustomType -Type 'Deetz.TierZeroAccount' } function Expand-GroupMember { <# .SYNOPSIS Recursively expands the membership of a group .DESCRIPTION Outputs every direct and nested member of a group, including groups, as an object holding the member and the chain of group names, from the outermost group down, through which it is a member. Members which hold the group as their primary group are included for domain groups. Groups already expanded in the same call are not expanded again, so circular nesting terminates. Foreign security principals for groups in a trusted domain are expanded in that domain when Forest is supplied. .PARAMETER Group The group directory object, with member and objectSid properties .PARAMETER Path The chain of group names leading to this group .PARAMETER Visited Distinguished names of groups already expanded .PARAMETER Forest Forest details, as returned by Get-ForestInfo, used to find the trusted domain of a foreign security principal .EXAMPLE Expand-GroupMember -Group $Group -Path 'Administrators' #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [psobject]$Group, [Parameter( Mandatory = $true )] [string[]]$Path, [Parameter( Mandatory = $false )] [System.Collections.Generic.HashSet[string]]$Visited = (New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)), [Parameter( Mandatory = $false )] [psobject]$Forest ) if (-not $Visited.Add($Group.DistinguishedName)) { return } $GroupSid = "$($Group.objectSid)" $Members = @( foreach ($MemberDN in $Group.member) { Get-DirectoryObject -Identity $MemberDN } if ($GroupSid -match '^S-1-5-21-.+-(\d+)$') { Get-DirectoryObject -LDAPFilter "(primaryGroupID=$($Matches[1]))" -Server (Get-DomainName -DistinguishedName $Group.DistinguishedName) } ) foreach ($Member in $Members) { [pscustomobject]@{ Object = $Member Path = $Path } $NestedGroup = $null if ($Member.ObjectClass -eq 'group') { $NestedGroup = $Member } elseif ($Member.ObjectClass -eq 'foreignSecurityPrincipal' -and $Forest) { $MemberSid = "$($Member.objectSid)" $TrustedDomain = Get-SidDomain -SID $MemberSid -Forest $Forest -TrustOnly if ($TrustedDomain) { $Foreign = Get-DirectoryObject -LDAPFilter "(objectSid=$MemberSid)" -Server $TrustedDomain.DNSRoot | Select-Object -First 1 if ($Foreign.ObjectClass -eq 'group') { $NestedGroup = $Foreign } } } if ($NestedGroup) { Write-Verbose "`tChecking Nested Group: $($NestedGroup.'msDS-PrincipalName')" Expand-GroupMember -Group $NestedGroup -Path (@($Path) + $NestedGroup.'msDS-PrincipalName') -Visited $Visited -Forest $Forest } } } function Get-CredentialParameter { <# .SYNOPSIS Returns a splattable hashtable holding the session credential .DESCRIPTION Returns @{ Credential = <credential> } when a credential is held in the module session, otherwise an empty hashtable. .EXAMPLE $Credential = Get-CredentialParameter Get-ADDomain @Credential #> [CmdletBinding()] [OutputType([hashtable])] param() $Parameter = @{} if ($Script:DeetzSession.Credential) { $Parameter['Credential'] = $Script:DeetzSession.Credential } $Parameter } function Get-DirectoryObject { <# .SYNOPSIS Gets directory objects, caching results for the current discovery run .DESCRIPTION Wraps Get-ADObject. An Identity lookup targets the domain named by the DC= components of the distinguished name. Results are cached in the module session, keyed on server, query and properties. Lookups of an identity which does not exist return nothing and write a warning. .PARAMETER Identity The distinguished name of the object to get .PARAMETER LDAPFilter An LDAP filter to search with .PARAMETER Server The domain to search with the LDAP filter .PARAMETER Properties The properties to return .EXAMPLE Get-DirectoryObject -Identity 'CN=Domain Admins,CN=Users,DC=corp,DC=local' .EXAMPLE Get-DirectoryObject -LDAPFilter '(objectSid=S-1-5-32-544)' -Server corp.local #> [CmdletBinding(DefaultParameterSetName = 'Identity')] param( [Parameter( Mandatory = $true, ParameterSetName = 'Identity' )] [string]$Identity, [Parameter( Mandatory = $true, ParameterSetName = 'LDAPFilter' )] [string]$LDAPFilter, [Parameter( Mandatory = $true, ParameterSetName = 'LDAPFilter' )] [string]$Server, [Parameter( Mandatory = $false )] [string[]]$Properties = @( 'msDS-PrincipalName', 'sAMAccountName', 'userPrincipalName', 'objectSid', 'userAccountControl', 'pwdLastSet', 'lastLogonTimestamp', 'servicePrincipalName', 'adminCount', 'member' ) ) $Parameter = @{ Properties = $Properties } + (Get-CredentialParameter) switch ($PSCmdlet.ParameterSetName) { 'Identity' { $Parameter['Identity'] = $Identity $Parameter['Server'] = Get-DomainName -DistinguishedName $Identity } 'LDAPFilter' { $Parameter['LDAPFilter'] = $LDAPFilter $Parameter['Server'] = $Server } } $Key = '{0}|{1}{2}|{3}' -f $Parameter['Server'], $Identity, $LDAPFilter, ($Properties -join ',') if (-not $Script:DeetzSession.Cache.ContainsKey($Key)) { try { $Script:DeetzSession.Cache[$Key] = @(Get-ADObject @Parameter -ErrorAction Stop) } catch { Write-Warning "Unable to get directory object $Identity$LDAPFilter from $($Parameter['Server']): $($PSItem.Exception.Message)" $Script:DeetzSession.Cache[$Key] = @() } } $Script:DeetzSession.Cache[$Key] } function Get-DomainName { <# .SYNOPSIS Returns the DNS domain name of a distinguished name .DESCRIPTION Joins the DC= components of a distinguished name into a DNS domain name. .PARAMETER DistinguishedName The distinguished name of a directory object .EXAMPLE Get-DomainName -DistinguishedName 'CN=Administrator,CN=Users,DC=corp,DC=local' Returns corp.local #> [CmdletBinding()] [OutputType([string])] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [string]$DistinguishedName ) process { (($DistinguishedName -split '(?<!\\),' | Where-Object { $PSItem -match '^DC=' }) -replace '^DC=') -join '.' } } function Get-ForestInfo { <# .SYNOPSIS Gets details of the target domain and the domains of its forest .DESCRIPTION Returns the target domain, the forest root domain and every reachable domain of the forest, each with DNSRoot, NetBIOSName, DistinguishedName and DomainSID. Throws when the target domain or its forest cannot be queried. A forest domain which cannot be queried is reported with a warning and left out. Domains outside the forest which any forest domain trusts are returned as Trusts, with the same properties. .PARAMETER Domain The target domain. Defaults to the domain of the current computer. .EXAMPLE Get-ForestInfo -Domain corp.local #> [CmdletBinding()] param( [Parameter( Mandatory = $false )] [string]$Domain ) $Credential = Get-CredentialParameter $DomainParameter = @{} + $Credential if ($Domain) { $DomainParameter['Server'] = $Domain } try { $TargetDomain = Get-ADDomain @DomainParameter -ErrorAction Stop $Forest = Get-ADForest -Identity $TargetDomain.Forest -Server $TargetDomain.DNSRoot @Credential -ErrorAction Stop } catch { throw "Unable to query domain $Domain`: $($PSItem.Exception.Message)" } $Domains = @( foreach ($ForestDomain in $Forest.Domains) { try { Get-ADDomain -Identity $ForestDomain -Server $ForestDomain @Credential -ErrorAction Stop | Select-Object -Property DNSRoot, NetBIOSName, DistinguishedName, @{Name = 'DomainSID'; Expression = { "$($PSItem.DomainSID)" } } } catch { Write-Warning "Unable to query domain $ForestDomain`: $($PSItem.Exception.Message)" } } ) $ForestRoot = $Domains | Where-Object { $PSItem.DNSRoot -eq $Forest.RootDomain } if (-not $ForestRoot) { Write-Warning "Forest root domain $($Forest.RootDomain) unavailable; forest level groups will not be checked" } $ForestSid = @($Domains | ForEach-Object { $PSItem.DomainSID }) $Trusts = [ordered]@{} foreach ($ForestDomain in $Domains) { try { $TrustedDomain = Get-ADObject -LDAPFilter '(objectClass=trustedDomain)' -Server $ForestDomain.DNSRoot ` -Properties trustPartner, flatName, securityIdentifier @Credential -ErrorAction Stop } catch { Write-Warning "Unable to query trusts of $($ForestDomain.DNSRoot): $($PSItem.Exception.Message)" continue } foreach ($Trust in $TrustedDomain) { if (-not $Trust.securityIdentifier) { continue } $TrustSid = if ($Trust.securityIdentifier -is [byte[]]) { "$(New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList $Trust.securityIdentifier, 0)" } else { "$($Trust.securityIdentifier)" } if ($ForestSid -contains $TrustSid -or $Trusts.Contains($TrustSid)) { continue } $Trusts[$TrustSid] = [pscustomobject]@{ DNSRoot = $Trust.trustPartner NetBIOSName = $Trust.flatName DistinguishedName = 'DC=' + ($Trust.trustPartner -replace '\.', ',DC=') DomainSID = $TrustSid } } } [pscustomobject]@{ Domain = $Domains | Where-Object { $PSItem.DNSRoot -eq $TargetDomain.DNSRoot } ForestRoot = $ForestRoot Domains = $Domains Trusts = @($Trusts.Values) } } function Get-ParentDN { <# .SYNOPSIS Returns the distinguished name of the parent container of a directory object .DESCRIPTION Removes the first relative distinguished name from a distinguished name, honouring escaped commas. .PARAMETER DistinguishedName The distinguished name of a directory object .EXAMPLE Get-ParentDN -DistinguishedName 'CN=Smith\, John,OU=Admins,DC=corp,DC=local' Returns OU=Admins,DC=corp,DC=local #> [CmdletBinding()] [OutputType([string])] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [string]$DistinguishedName ) process { $DistinguishedName -replace '^.+?(?<!\\),' } } function Get-PrivilegedAce { <# .SYNOPSIS Returns the access control entries of a security descriptor which grant control of an object .DESCRIPTION Returns each Allow entry granting GenericAll, GenericWrite, WriteDacl or WriteOwner, and each Allow entry granting WriteProperty, Self or ExtendedRight either on all properties and rights or on one which allows takeover of the object or its domain (replication, password reset, group membership, key credentials, delegation, logon script). The owner of the object is returned with Rights Owner, as the owner can always change the permissions. .PARAMETER SecurityDescriptor The nTSecurityDescriptor of a directory object .PARAMETER ExcludeInherited Only return entries set on the object itself, not those inherited from its parent containers .EXAMPLE Get-PrivilegedAce -SecurityDescriptor $Object.nTSecurityDescriptor .EXAMPLE Get-PrivilegedAce -SecurityDescriptor $Object.nTSecurityDescriptor -ExcludeInherited #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [psobject]$SecurityDescriptor, [Parameter( Mandatory = $false )] [switch]$ExcludeInherited ) $ObjectType = @{ '00000000-0000-0000-0000-000000000000' = 'All' '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' = 'DS-Replication-Get-Changes' '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2' = 'DS-Replication-Get-Changes-All' '89e95b76-444d-4c62-991a-0facbeda640c' = 'DS-Replication-Get-Changes-In-Filtered-Set' '00299570-246d-11d0-a768-00aa006e0529' = 'User-Force-Change-Password' 'bf9679c0-0de6-11d0-a285-00aa003049e2' = 'member' '5b47d60f-6090-40b2-9f37-2a4de88f3063' = 'msDS-KeyCredentialLink' 'f3a64788-5306-11d1-a9c5-0000f80367c1' = 'servicePrincipalName' 'f30e3bbe-9ff0-11d1-b603-0000f80367c1' = 'gPLink' '3f78c3e5-f79a-46bd-a0b8-9d18116ddc79' = 'msDS-AllowedToActOnBehalfOfOtherIdentity' 'bf9679a8-0de6-11d0-a285-00aa003049e2' = 'scriptPath' 'bf967a68-0de6-11d0-a285-00aa003049e2' = 'userAccountControl' } $Owner = $SecurityDescriptor.GetOwner([System.Security.Principal.SecurityIdentifier]) if ($Owner) { [pscustomobject]@{ SID = "$Owner" Rights = 'Owner' ObjectType = 'All' } } $AccessRules = $SecurityDescriptor.GetAccessRules($true, -not $ExcludeInherited, [System.Security.Principal.SecurityIdentifier]) foreach ($Ace in $AccessRules) { if ("$($Ace.AccessControlType)" -ne 'Allow') { continue } $Rights = "$($Ace.ActiveDirectoryRights)" $AceObjectType = "$($Ace.ObjectType)" if ($Rights -match 'GenericAll|GenericWrite|WriteDacl|WriteOwner') { $Target = 'All' } elseif (($Rights -match 'WriteProperty|Self|ExtendedRight') -and $ObjectType.ContainsKey($AceObjectType)) { $Target = $ObjectType[$AceObjectType] } else { continue } [pscustomobject]@{ SID = "$($Ace.IdentityReference)" Rights = $Rights ObjectType = $Target } } } function Get-SidDomain { <# .SYNOPSIS Returns the forest or trusted domain which issued a SID .DESCRIPTION Matches the SID against the domain SIDs of the forest domains and the trusted domains returned by Get-ForestInfo; with TrustOnly, only the trusted domains. Returns nothing for builtin, well-known or unknown SIDs. .PARAMETER SID The security identifier to look up .PARAMETER Forest Forest details, as returned by Get-ForestInfo .PARAMETER TrustOnly Only match trusted domains outside the forest .EXAMPLE Get-SidDomain -SID 'S-1-5-21-1-2-3-1105' -Forest $Forest #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [string]$SID, [Parameter( Mandatory = $true )] [psobject]$Forest, [Parameter( Mandatory = $false )] [switch]$TrustOnly ) $Candidates = @( if (-not $TrustOnly) { $Forest.Domains } $Forest.Trusts ) $Candidates | Where-Object { $PSItem.DomainSID -and $SID -like "$($PSItem.DomainSID)-*" } | Select-Object -First 1 } function Get-TierZeroGroupSeed { <# .SYNOPSIS Returns the SIDs of the default Tier Zero groups .DESCRIPTION Returns the SID of each default Tier Zero group, with the domain to query it from. The groups are those rated Tier Zero by the SpecterOps Tier Zero Table which can be identified by SID. Builtin and domain groups come from the target domain, forest groups from the forest root domain. .PARAMETER Forest Forest details, as returned by Get-ForestInfo .EXAMPLE Get-ForestInfo | Get-TierZeroGroupSeed #> [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [psobject]$Forest ) process { $Domain = $Forest.Domain $BuiltinRid = @( 544, # Administrators 548, # Account Operators 549, # Server Operators 550, # Print Operators 551, # Backup Operators 557, # Incoming Forest Trust Builders 559, # Performance Log Users 562, # Distributed COM Users 569 # Cryptographic Operators ) $DomainRid = @( 512, # Domain Admins 516, # Domain Controllers 517, # Cert Publishers 526 # Key Admins ) $ForestRid = @( 518, # Schema Admins 519, # Enterprise Admins 527 # Enterprise Key Admins ) foreach ($Rid in $BuiltinRid) { [pscustomobject]@{ SID = "S-1-5-32-$Rid"; Server = $Domain.DNSRoot } } foreach ($Rid in $DomainRid) { [pscustomobject]@{ SID = "$($Domain.DomainSID)-$Rid"; Server = $Domain.DNSRoot } } if ($Forest.ForestRoot) { foreach ($Rid in $ForestRid) { [pscustomobject]@{ SID = "$($Forest.ForestRoot.DomainSID)-$Rid"; Server = $Forest.ForestRoot.DNSRoot } } } } } function Register-TierZeroAccount { <# .SYNOPSIS Records a Tier Zero account and the reason it was found .DESCRIPTION Adds the account to the registry keyed on its SID (or distinguished name, where it has no SID), then adds a Deetz.TierZeroReason object describing how it was found to its entry, ignoring duplicate reasons. .PARAMETER Registry The dictionary of accounts found so far .PARAMETER Object The account directory object .PARAMETER Source How the account was found: GroupMembership, WellKnownAccount, ContainerACL or ObjectACL .PARAMETER TierZeroGroup The Tier Zero group, or group holding Tier Zero rights, through which the account was found .PARAMETER Path The chain of groups, from TierZeroGroup down to the group holding the account .PARAMETER Right The right held over the Target, such as GenericAll, Owner or managedBy .PARAMETER ObjectType The property, property set or extended right the Right applies to .PARAMETER Target Distinguished name of the Tier Zero container or object the Right is held over .EXAMPLE Register-TierZeroAccount -Registry $Registry -Object $User -Source GroupMembership -TierZeroGroup 'CORP\Domain Admins' -Path 'CORP\Domain Admins' .EXAMPLE Register-TierZeroAccount -Registry $Registry -Object $User -Source ContainerACL -Right GenericAll -ObjectType All -Target 'OU=Admins,DC=corp,DC=local' #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [System.Collections.IDictionary]$Registry, [Parameter( Mandatory = $true )] [psobject]$Object, [Parameter( Mandatory = $true )] [ValidateSet('GroupMembership', 'WellKnownAccount', 'ContainerACL', 'ObjectACL')] [string]$Source, [Parameter( Mandatory = $false )] [string]$TierZeroGroup, [Parameter( Mandatory = $false )] [string[]]$Path, [Parameter( Mandatory = $false )] [string]$Right, [Parameter( Mandatory = $false )] [string]$ObjectType, [Parameter( Mandatory = $false )] [string]$Target ) $Key = "$($Object.objectSid)" if (-not $Key) { $Key = $Object.DistinguishedName } if (-not $Registry.Contains($Key)) { $Registry[$Key] = [pscustomobject]@{ Object = $Object Reason = New-Object -TypeName 'System.Collections.Generic.List[psobject]' ReasonKey = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) } } $Entry = $Registry[$Key] if ($Entry.ReasonKey.Add(($Source, $TierZeroGroup, ($Path -join ' > '), $Right, $ObjectType, $Target) -join '|')) { $Entry.Reason.Add( ([pscustomobject]@{ Source = $Source TierZeroGroup = $TierZeroGroup Path = [string[]]@($Path | Where-Object { $PSItem }) Right = $Right ObjectType = $ObjectType Target = $Target } | Add-CustomType -Type 'Deetz.TierZeroReason') ) } } # .ExternalHelp Deetz-help.xml function Compare-TierZeroAccount { [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [psobject]$InputObject, [Parameter( Mandatory = $true )] [AllowEmptyCollection()] [psobject[]]$PAMAccount, [Parameter( Mandatory = $false )] [string]$UserNameProperty = 'userName', [Parameter( Mandatory = $false )] [string]$AddressProperty = 'address' ) begin { $Keys = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) foreach ($Account in $PAMAccount) { $UserName = "$($Account.$UserNameProperty)" $Address = "$($Account.$AddressProperty)" if ($UserName -match '^(?<Domain>[^\\]+)\\(?<User>.+)$') { $null = $Keys.Add("$($Matches.User)|$($Matches.Domain)") } elseif ($UserName -match '^(?<User>[^@]+)@(?<Domain>.+)$') { $null = $Keys.Add("upn|$UserName") $null = $Keys.Add("$($Matches.User)|$($Matches.Domain)") } else { $null = $Keys.Add("$UserName|$Address") } } } process { $InPAM = $false foreach ($Key in @( "$($InputObject.SamAccountName)|$($InputObject.Domain)", "$($InputObject.SamAccountName)|$($InputObject.NetBIOSDomain)", "upn|$($InputObject.UserPrincipalName)" )) { if ($Key -notmatch '^\|' -and $Key -notmatch '\|$' -and $Keys.Contains($Key)) { $InPAM = $true break } } $InputObject | Add-Member -MemberType NoteProperty -Name InPAM -Value $InPAM -Force -PassThru } } # .ExternalHelp Deetz-help.xml function ConvertTo-PAMAccount { [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [AllowEmptyString()] [string]$SamAccountName, [Parameter( Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [AllowEmptyString()] [string]$Domain, [Parameter( Mandatory = $true, ValueFromPipelineByPropertyName = $true )] [AllowEmptyString()] [string]$ObjectClass, [Parameter( Mandatory = $true )] [string]$SafeName, [Parameter( Mandatory = $true )] [string]$PlatformID ) process { if ($ObjectClass -ne 'user' -or -not $Domain -or -not $SamAccountName) { Write-Verbose "Skipping $ObjectClass $Domain\$SamAccountName" return } [pscustomobject]@{ userName = $SamAccountName address = $Domain platformID = $PlatformID SafeName = $SafeName } } } # .ExternalHelp Deetz-help.xml function ConvertTo-TierZeroReport { [CmdletBinding()] param( [Parameter( Mandatory = $true, ValueFromPipeline = $true )] [psobject]$InputObject ) process { $Account = [ordered]@{} foreach ($Property in $InputObject.PSObject.Properties) { if ($Property.Name -notin 'Reason', 'Source', 'TierZeroGroup') { $Account[$Property.Name] = $(if ($Property.Value -is [array]) { $Property.Value -join '; ' } else { $Property.Value }) } } foreach ($Reason in $InputObject.Reason) { $Row = [ordered]@{} + $Account $Row['Source'] = $Reason.Source $Row['TierZeroGroup'] = $Reason.TierZeroGroup $Row['Path'] = @($Reason.Path) -join ' > ' $Row['Right'] = $Reason.Right $Row['ObjectType'] = $Reason.ObjectType $Row['Target'] = $Reason.Target [pscustomobject]$Row } } } # .ExternalHelp Deetz-help.xml function Get-TierZeroAccount { [CmdletBinding()] param( [Parameter( Mandatory = $false )] [string]$Domain, [Parameter( Mandatory = $false )] [pscredential]$Credential, [Parameter( Mandatory = $false )] [string[]]$AdditionalGroup, [Parameter( Mandatory = $false )] [switch]$SkipAclAnalysis ) $Script:DeetzSession.Credential = $Credential $Script:DeetzSession.Cache = @{} $Forest = Get-ForestInfo -Domain $Domain $TargetDomain = $Forest.Domain.DNSRoot $Registry = [ordered]@{} $Groups = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) $AddGroup = { param($Group, $Source, $Grant) $TierZeroGroup = $Group.'msDS-PrincipalName' $null = $Groups.Add($Group.DistinguishedName) Write-Verbose "Checking Group: $TierZeroGroup" foreach ($Item in (Expand-GroupMember -Group $Group -Path $TierZeroGroup -Forest $Forest)) { if ($Item.Object.ObjectClass -eq 'group') { $null = $Groups.Add($Item.Object.DistinguishedName) } else { Register-TierZeroAccount -Registry $Registry -Object $Item.Object -Source $Source -TierZeroGroup $TierZeroGroup -Path $Item.Path @Grant } } } #Default Tier Zero groups foreach ($Seed in (Get-TierZeroGroupSeed -Forest $Forest)) { $Group = Get-DirectoryObject -LDAPFilter "(objectSid=$($Seed.SID))" -Server $Seed.Server | Select-Object -First 1 if (-not $Group) { Write-Verbose "Group $($Seed.SID) not found in $($Seed.Server)" continue } & $AddGroup $Group GroupMembership @{} } #Groups named by the caller $GroupParameter = @{ Server = $TargetDomain } + (Get-CredentialParameter) foreach ($Name in $AdditionalGroup) { try { $ADGroup = Get-ADGroup -Identity $Name @GroupParameter -ErrorAction Stop } catch { Write-Warning "Unable to find group $Name`: $($PSItem.Exception.Message)" continue } $Group = Get-DirectoryObject -Identity $ADGroup.DistinguishedName | Select-Object -First 1 if ($Group) { & $AddGroup $Group GroupMembership @{} } } #Built-in Administrator, krbtgt and read-only domain controller accounts foreach ($Filter in @( "(objectSid=$($Forest.Domain.DomainSID)-500)", '(&(objectClass=user)(sAMAccountName=krbtgt*))', '(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=67108864))' )) { foreach ($Account in (Get-DirectoryObject -LDAPFilter $Filter -Server $TargetDomain)) { Register-TierZeroAccount -Registry $Registry -Object $Account -Source WellKnownAccount } } if (-not $SkipAclAnalysis) { $AddPrincipal = { param($Principal, $Source, $Grant) if (-not $Principal -or $Groups.Contains($Principal.DistinguishedName)) { return } if ($Principal.ObjectClass -eq 'group') { & $AddGroup $Principal $Source $Grant } else { Register-TierZeroAccount -Registry $Registry -Object $Principal -Source $Source @Grant } } $CheckAcl = { param($Target, $Source) Write-Verbose "Checking ACL: $Target" $TargetObject = Get-DirectoryObject -Identity $Target -Properties nTSecurityDescriptor, managedBy, userAccountControl | Select-Object -First 1 #The managedBy principal of a read-only domain controller is a local administrator of it if ($TargetObject.managedBy -and ($TargetObject.userAccountControl -band 67108864)) { $Manager = Get-DirectoryObject -Identity $TargetObject.managedBy | Select-Object -First 1 & $AddPrincipal $Manager $Source @{ Right = 'managedBy'; Target = $Target } } if (-not $TargetObject.nTSecurityDescriptor) { if ($TargetObject) { Write-Warning "Unable to read permissions of $Target" } return } $AceParameter = @{ SecurityDescriptor = $TargetObject.nTSecurityDescriptor ExcludeInherited = $Source -eq 'ObjectACL' } foreach ($Ace in (Get-PrivilegedAce @AceParameter)) { if ($Ace.SID -match '^(S-1-1-0|S-1-5-7|S-1-5-11|S-1-5-21-.+-(513|515)|S-1-5-32-(545|546|554))$') { $Principal = $Ace.SID try { $Principal = "$(([System.Security.Principal.SecurityIdentifier]$Ace.SID).Translate([System.Security.Principal.NTAccount]).Value) ($($Ace.SID))" } catch { Write-Verbose "Unable to translate $($Ace.SID)" } Write-Warning "Broad principal $Principal has $($Ace.Rights) ($($Ace.ObjectType)) on $Target" continue } if ($Ace.SID -match '^S-1-5-32-') { $Server = Get-DomainName -DistinguishedName $Target } elseif ($Ace.SID -match '^S-1-5-21-') { $Server = (Get-SidDomain -SID $Ace.SID -Forest $Forest).DNSRoot } else { continue } if (-not $Server) { Write-Verbose "No reachable domain for $($Ace.SID) on $Target" continue } $Principal = Get-DirectoryObject -LDAPFilter "(objectSid=$($Ace.SID))" -Server $Server | Select-Object -First 1 & $AddPrincipal $Principal $Source @{ Right = $Ace.Rights; ObjectType = $Ace.ObjectType; Target = $Target } } } #Principals with control of a Tier Zero object are Tier Zero in turn, so repeat until nothing new is found $Checked = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) do { $Accounts = @( $Registry.Values | Where-Object { $PSItem.Object.ObjectClass -ne 'foreignSecurityPrincipal' } | ForEach-Object { $PSItem.Object.DistinguishedName } ) $Containers = @( @( $Forest.Domain.DistinguishedName, "CN=Users,$($Forest.Domain.DistinguishedName)", "CN=AdminSDHolder,CN=System,$($Forest.Domain.DistinguishedName)", "OU=Domain Controllers,$($Forest.Domain.DistinguishedName)" @($Groups) | Get-ParentDN $Registry.Values | ForEach-Object { $PSItem.Object.DistinguishedName } | Get-ParentDN ) | Sort-Object -Unique | Where-Object { $Checked.Add($PSItem) } ) $Objects = @( @(@($Groups) + $Accounts) | Sort-Object -Unique | Where-Object { $PSItem -and $Checked.Add($PSItem) } ) foreach ($Container in $Containers) { & $CheckAcl $Container ContainerACL } foreach ($Object in $Objects) { & $CheckAcl $Object ObjectACL } } while ($Containers -or $Objects) } foreach ($Entry in $Registry.Values) { ConvertTo-TierZeroAccount -Entry $Entry -Forest $Forest } } # Script scope session object holding directory query state for the current discovery run $Script:DeetzSession = [ordered]@{ Credential = $null Cache = @{} } | Add-CustomType -Type Deetz.Session New-Variable -Name DeetzSession -Value $Script:DeetzSession -Scope Script -Force |