Skip to content

Instantly share code, notes, and snippets.

@thedavecarroll
Last active August 3, 2026 19:54
Show Gist options
  • Select an option

  • Save thedavecarroll/ea2b4f0ba7527bd469e59e1aabf3e0b0 to your computer and use it in GitHub Desktop.

Select an option

Save thedavecarroll/ea2b4f0ba7527bd469e59e1aabf3e0b0 to your computer and use it in GitHub Desktop.
This simple PowerShell script module uses a custom class and Get-ADObject to search an Active Directory-integrated DNS Zone by name or partial name.
#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($_)
}
}
@thedavecarroll

Copy link
Copy Markdown
Author

Caveat
I have only used this for DomainDnsZones partition, but it should work for ForestDnsZones. Please let me know here if it does not.

@thedavecarroll

Copy link
Copy Markdown
Author

There are some record types that are not correctly decoded. I am working on this in my spare time.

@Mike-Crowley

Copy link
Copy Markdown

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 dnsRecord attribute puts Reserved before TimeStamp (MS-DNSP 2.3.2.2), the reverse of the RPC DNS_RPC_RECORD struct. Parse it the RPC way and every record reads as static.
  • DNS_COUNT_NAME is null-terminated and its Length counts 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment