-
-
Save thedavecarroll/ea2b4f0ba7527bd469e59e1aabf3e0b0 to your computer and use it in GitHub Desktop.
| #Requires -Version 5.1 | |
| #Requires -Module ActiveDirectory | |
| $script:ADRootDSE = Get-ADRootDSE | |
| class ADDnsNode { | |
| # AD Object Properties | |
| [String]$Name | |
| [String]$CanonicalName | |
| [System.DateTime]$Created | |
| [System.DateTime]$Modified | |
| [String]$DistinguishedName | |
| [System.Boolean]$IsDeleted | |
| [System.Boolean]$IsTombstoned | |
| [System.Boolean]$ProtectedFromAccidentalDeletion | |
| # DnsRecord Properties | |
| [System.UInt32]$UpdateAtSerial | |
| [System.UInt32]$TTL | |
| [System.UInt32]$Age | |
| [Nullable[System.DateTime]]$Timestamp | |
| [System.Boolean]$IsStatic | |
| [String]$RRType | |
| [String]$Data | |
| hidden [Microsoft.ActiveDirectory.Management.ADObject]$OriginalADObject | |
| # Constructors | |
| ADDnsNode () { } | |
| ADDnsNode ([PSCustomObject]$InputObject) { | |
| $this.Name = $InputObject.Name | |
| $this.CanonicalName = $InputObject.CanonicalName | |
| $this.Created = $InputObject.Created | |
| $this.Modified = $InputObject.Modified | |
| $this.DistinguishedName = $InputObject.DistinguishedName | |
| $this.IsDeleted = $InputObject.IsDeleted | |
| $this.IsTombstoned = $InputObject.dNSTombstoned | |
| $this.ProtectedFromAccidentalDeletion = $InputObject.ProtectedFromAccidentalDeletion | |
| $this.OriginalADObject = $InputObject | |
| $This.ConvertFromDnsRecord($InputObject.dnsRecord[0]) | |
| } | |
| # Methods | |
| [void] ConvertFromDnsRecord([byte[]]$DnsRecord) { | |
| try { | |
| $TTLRaw = $DnsRecord[12..15] | |
| $this.TTL = [BitConverter]::ToUInt32($TTLRaw, 0) | |
| [void][array]::Reverse($TTLRaw) # reverse for big endian | |
| $this.UpdateAtSerial = [BitConverter]::ToUInt32($DnsRecord, 8) | |
| $RecordType = $null | |
| switch ([BitConverter]::ToUInt16($DnsRecord, 2)) { | |
| 1 { | |
| $RecordData = '{0}.{1}.{2}.{3}' -f $DnsRecord[24], $DnsRecord[25], $DnsRecord[26], $DnsRecord[27] | |
| $RecordType = 'A' | |
| } | |
| 16 { | |
| [string]$RecordData = '' | |
| [int]$SegmentLength = $DnsRecord[24] | |
| $Index = 25 | |
| while ($SegmentLength-- -gt 0) { | |
| $RecordData += [char]$DnsRecord[$index++] | |
| } | |
| $RecordType = 'TXT' | |
| } | |
| {$_ -in 2,5,12} { | |
| $RecordData = $this.GetName($DnsRecord[24..$DnsRecord.length]) | |
| switch ($_) { | |
| 2 { $RecordType = 'NS' } | |
| 5 { $RecordType = 'CNAME' } | |
| 12 { $RecordType = 'PTR' } | |
| } | |
| } | |
| default { | |
| $RecordData = $([System.Convert]::ToBase64String($DnsRecord[24..$DnsRecord.length])) | |
| switch ($_) { | |
| 6 { $RecordType = 'SOA' } | |
| 13 { $RecordType = 'HINFO' } | |
| 15 { $RecordType = 'MX' } | |
| 17 { $RecordType = 'RP' } | |
| 28 { $RecordType = 'AAAA' } | |
| 33 { $RecordType = 'SRV' } | |
| default { $RecordType = 'UNKNOWN' } | |
| } | |
| } | |
| } | |
| $this.RRType = $RecordType | |
| $this.Data = $RecordData | |
| $this.Age = [BitConverter]::ToUInt32($DnsRecord, 20) | |
| if ($this.Age -ne 0) { | |
| $RecordTimestamp = ((Get-Date '01/01/1601 00:00:00').AddHours($this.Age)) | |
| $RecordIsStatic = $false | |
| } else { | |
| $RecordTimestamp = $null | |
| $RecordIsStatic = $true | |
| } | |
| $this.Timestamp = $RecordTimestamp | |
| $this.IsStatic = $RecordIsStatic | |
| } | |
| catch { | |
| '{0} : {1}' -f $This.Name,$This.DistinguishedName | Write-Error | |
| $PSCmdlet.ThrowTerminatingError($_) | |
| } | |
| } | |
| hidden [string] GetName([byte[]]$Raw) { | |
| try { | |
| [Int]$Segments = $Raw[1] | |
| [Int]$Index = 2 | |
| [String]$GetName = '' | |
| while ($Segments-- -gt 0) { | |
| [Int]$SegmentLength = $Raw[$Index++] | |
| while ($SegmentLength-- -gt 0) { | |
| $GetName += [Char]$Raw[$Index++] | |
| } | |
| $GetName += "." | |
| } | |
| return $GetName | |
| } | |
| catch { | |
| $_ | Write-Warning | |
| return '' | |
| } | |
| } | |
| } | |
| function Search-DnsRecord { | |
| [CmdletBinding(DefaultParameterSetName='ByHostName')] | |
| param( | |
| [Parameter(Mandatory,ParameterSetName='ByHostName')] | |
| [string]$HostName, | |
| [Parameter(Mandatory,ParameterSetName='ByFilter')] | |
| [string]$Filter, | |
| [switch]$IncludeTombstoned, | |
| [Parameter(Mandatory)] | |
| [string]$ZoneName, | |
| [string]$Server, | |
| [ValidateSet('DomainDnsZones','ForestDnsZones')] | |
| [string]$ADZoneType = 'DomainDnsZones' | |
| ) | |
| $SearchBase = 'DC={0},CN=MicrosoftDNS,{1}' -f $ZoneName,($script:ADRootDSE.namingContexts.Where{$_ -match $ADZoneType})[0] | |
| $SearchFilter = @() | |
| # specify the Dns-Node objectCategory which is faster than objectClass | |
| $DnsNodeCategory = 'CN=Dns-Node,{0}' -f $script:ADRootDSE.schemaNamingContext | |
| $SearchFilter += ('objectCategory -eq "{0}"' -f $DnsNodeCategory) | |
| # add tombstone filter, if required | |
| if (-Not $PSBoundParameters.ContainsKey('IncludeTombstoned')) { | |
| $SearchFilter += 'dNSTombstoned -eq $false' | |
| } | |
| # filter by name or partial name | |
| switch ($PSCmdlet.ParameterSetName) { | |
| 'ByHostName' { | |
| $SearchFilter += 'Name -eq "{0}"' -f $HostName | |
| } | |
| 'ByFilter' { | |
| if ($Filter -eq '*') { | |
| $SearchFilter += 'Name -like "*"' -f $Filter | |
| } else { | |
| $SearchFilter += 'Name -like "*{0}*"' -f $Filter | |
| } | |
| } | |
| } | |
| $ADObjectParams = @{ | |
| SearchBase = $SearchBase | |
| Properties = '*' | |
| Server = $Server | |
| ResultSetSize = [int32]::MaxValue | |
| Filter = $SearchFilter -join ' -and ' | |
| } | |
| Get-ADObject @ADObjectParams | ForEach-Object { | |
| [ADDnsNode]::New($_) | |
| } | |
| } |
Thanks for this, Dave. Your SearchADDnsRecord gist is what got me started reading dnsRecord directly out of AD, and the ADDnsNode class was the clearest example of that blob layout I found outside the spec.
I've published a script based on the same idea:
https://github.com/Mike-Crowley/Public-Scripts/blob/main/AD_DS/Get-AdDnsNodeReport.ps1
I reimplemented the parsing inline so there's no runtime dependency, and added the ownership and permissions side: which computer owns each dnsNode, and whether that owner can still write to its own record. That's usually what's behind records that never scavenge. You're credited in the script notes.
Two things I ran into on the binary format, in case they're useful to anyone else here:
- The LDAP
dnsRecordattribute putsReservedbeforeTimeStamp(MS-DNSP 2.3.2.2), the reverse of the RPCDNS_RPC_RECORDstruct. Parse it the RPC way and every record reads as static. DNS_COUNT_NAMEis null-terminated and itsLengthcounts the null, so walking the labels alone lands one byte short. That shows up on SOA, which carries two names back to back.
Thanks again for putting this out there.
There are some record types that are not correctly decoded. I am working on this in my spare time.