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 ConvertTo-TierZeroGroup { <# .SYNOPSIS Formats a registry entry as a Tier Zero group output object .DESCRIPTION Returns a Deetz.TierZeroGroup object holding every Deetz.TierZeroReason recorded for the group, with the distinct sources and Tier Zero groups of those reasons. .PARAMETER Entry A registry entry created by Register-TierZeroAccount .PARAMETER Forest Forest details, as returned by Get-ForestInfo .EXAMPLE ConvertTo-TierZeroGroup -Entry $Entry -Forest $Forest #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [psobject]$Entry, [Parameter( Mandatory = $true )] [psobject]$Forest ) $Object = $Entry.Object $DomainName = Get-DomainName -DistinguishedName $Object.DistinguishedName [pscustomobject]@{ SamAccountName = $Object.sAMAccountName Domain = $DomainName NetBIOSDomain = (@($Forest.Domains) + @($Forest.Trusts) | Where-Object { $PSItem.DNSRoot -eq $DomainName } | Select-Object -First 1).NetBIOSName AdminCount = $Object.adminCount MemberCount = @($Object.member | Where-Object { $PSItem }).Count Source = [string[]]@($Entry.Reason.Source | Select-Object -Unique) TierZeroGroup = [string[]]@($Entry.Reason.TierZeroGroup | Where-Object { $PSItem } | Select-Object -Unique) Reason = [psobject[]]@($Entry.Reason) SID = "$($Object.objectSid)" DistinguishedName = $Object.DistinguishedName } | Add-CustomType -Type 'Deetz.TierZeroGroup' } 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, and the trusted domain group is output after the foreign security principal. .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 [pscustomobject]@{ Object = $Foreign Path = $Path } } } } if ($NestedGroup) { Write-Verbose "`tChecking Nested Group: $($NestedGroup.'msDS-PrincipalName')" Expand-GroupMember -Group $NestedGroup -Path (@($Path) + $NestedGroup.'msDS-PrincipalName') -Visited $Visited -Forest $Forest } } } function Find-TierZeroObject { <# .SYNOPSIS Finds the Tier Zero accounts and groups of a domain .DESCRIPTION Runs Tier Zero discovery by group membership, well known account, container ACL and object ACL, returning the forest details with separate registries of the accounts and groups found. Each registry is keyed on SID, with entries created by Register-TierZeroAccount. Default Tier Zero groups are not given ContainerACL or ObjectACL reasons, as they hold rights over most containers by default. .PARAMETER Domain DNS name of the domain to check .PARAMETER Credential Credential used for all directory queries .PARAMETER AdditionalGroup Names of further groups whose members are Tier Zero .PARAMETER SkipAclAnalysis Find accounts and groups by group membership and well known account only .EXAMPLE $Result = Find-TierZeroObject -Domain corp.local #> [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]@{} $GroupRegistry = [ordered]@{} $Groups = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) $DefaultGroups = 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) $Target = $GroupRegistry } else { $Target = $Registry } Register-TierZeroAccount -Registry $Target -Object $Item.Object -Source $Source -TierZeroGroup $TierZeroGroup -Path $Item.Path @Grant } } #Default Tier Zero groups $SeedGroups = @( 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 } $null = $DefaultGroups.Add($Group.DistinguishedName) Register-TierZeroAccount -Registry $GroupRegistry -Object $Group -Source DefaultGroup -TierZeroGroup $Group.'msDS-PrincipalName' $Group } ) foreach ($Group in $SeedGroups) { & $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) { Register-TierZeroAccount -Registry $GroupRegistry -Object $Group -Source AdditionalGroup -TierZeroGroup $Group.'msDS-PrincipalName' & $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) { return } if ($Principal.ObjectClass -eq 'group') { if (-not $DefaultGroups.Contains($Principal.DistinguishedName)) { Register-TierZeroAccount -Registry $GroupRegistry -Object $Principal -Source $Source @Grant } if (-not $Groups.Contains($Principal.DistinguishedName)) { & $AddGroup $Principal $Source $Grant } } else { Register-TierZeroAccount -Registry $Registry -Object $Principal -Source $Source @Grant } } $AddSid = { param($SID, $Source, $Grant) $Right = $Grant.Right if ($Grant.ObjectType) { $Right = "$Right ($($Grant.ObjectType))" } if ($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 = $SID try { $Principal = "$(([System.Security.Principal.SecurityIdentifier]$SID).Translate([System.Security.Principal.NTAccount]).Value) ($SID)" } catch { Write-Verbose "Unable to translate $SID" } Write-Warning "Broad principal $Principal has $Right on $($Grant.Target)" return } if ($SID -match '^S-1-5-32-') { $Server = Get-DomainName -DistinguishedName $Grant.Target } elseif ($SID -match '^S-1-5-21-') { $Server = (Get-SidDomain -SID $SID -Forest $Forest).DNSRoot } else { return } if (-not $Server) { Write-Verbose "No reachable domain for $SID on $($Grant.Target)" return } $Principal = Get-DirectoryObject -LDAPFilter "(objectSid=$SID)" -Server $Server | Select-Object -First 1 & $AddPrincipal $Principal $Source $Grant } $CheckAcl = { param($Target, $Source) Write-Verbose "Checking ACL: $Target" $Properties = 'nTSecurityDescriptor', 'managedBy', 'userAccountControl', 'msDS-AllowedToActOnBehalfOfOtherIdentity', 'msDS-GroupMSAMembership' $TargetObject = Get-DirectoryObject -Identity $Target -Properties $Properties | 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 } } #Principals allowed to delegate to the object can impersonate any user to it if ($TargetObject.'msDS-AllowedToActOnBehalfOfOtherIdentity') { foreach ($SID in (Get-AllowedPrincipal -SecurityDescriptor $TargetObject.'msDS-AllowedToActOnBehalfOfOtherIdentity')) { & $AddSid $SID $Source @{ Right = 'AllowedToAct'; Target = $Target } } } #Principals allowed to retrieve the password of a group managed service account control it if ($TargetObject.'msDS-GroupMSAMembership') { foreach ($SID in (Get-AllowedPrincipal -SecurityDescriptor $TargetObject.'msDS-GroupMSAMembership')) { & $AddSid $SID $Source @{ Right = 'ReadGMSAPassword'; 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)) { & $AddSid $Ace.SID $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) } [pscustomobject]@{ Forest = $Forest Account = $Registry Group = $GroupRegistry } } function Get-AllowedPrincipal { <# .SYNOPSIS Returns the SIDs granted access by a security descriptor attribute .DESCRIPTION Returns the distinct SIDs of the Allow entries of a security descriptor stored in an attribute, such as msDS-AllowedToActOnBehalfOfOtherIdentity or msDS-GroupMSAMembership. Accepts the attribute as returned by Get-ADObject, or in binary form. .PARAMETER SecurityDescriptor The attribute value .EXAMPLE Get-AllowedPrincipal -SecurityDescriptor $Computer.'msDS-AllowedToActOnBehalfOfOtherIdentity' #> [CmdletBinding()] param( [Parameter( Mandatory = $true )] [psobject]$SecurityDescriptor ) $Descriptor = $SecurityDescriptor if ($Descriptor -is [byte[]]) { $Descriptor = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity $Descriptor.SetSecurityDescriptorBinaryForm([byte[]]$SecurityDescriptor) } $Descriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) | Where-Object { "$($PSItem.AccessControlType)" -eq 'Allow' } | ForEach-Object { "$($PSItem.IdentityReference)" } | Select-Object -Unique } 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. Groups can also be DefaultGroup or AdditionalGroup. .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('DefaultGroup', 'AdditionalGroup', '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 ) $Result = Find-TierZeroObject @PSBoundParameters foreach ($Entry in $Result.Account.Values) { ConvertTo-TierZeroAccount -Entry $Entry -Forest $Result.Forest } } # .ExternalHelp Deetz-help.xml function Get-TierZeroGroup { [CmdletBinding()] param( [Parameter( Mandatory = $false )] [string]$Domain, [Parameter( Mandatory = $false )] [pscredential]$Credential, [Parameter( Mandatory = $false )] [string[]]$AdditionalGroup, [Parameter( Mandatory = $false )] [switch]$SkipAclAnalysis ) $Result = Find-TierZeroObject @PSBoundParameters foreach ($Entry in $Result.Group.Values) { ConvertTo-TierZeroGroup -Entry $Entry -Forest $Result.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 |