Created
January 22, 2026 11:22
-
-
Save azurekid/bc1b9f2b03688bc4396ca57524ca1e1b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <# | |
| .SYNOPSIS | |
| Performs comprehensive email security reconnaissance on one or more domains. | |
| .DESCRIPTION | |
| Invoke-EmailRecon performs parallel DNS lookups and HTTP requests to gather | |
| email security configuration data for specified domains. It collects information | |
| about MX records, SPF, DKIM, DMARC, BIMI, MTA-STS, TLS-RPT, DANE/TLSA, DNSSEC, | |
| CAA records, Microsoft 365/Entra ID tenant details, ADFS federation, and | |
| DNS blocklist status. | |
| The script uses PowerShell 7's ForEach-Object -Parallel for domain-level | |
| parallelism and runspace pools for concurrent DNS resolution within each domain. | |
| .PARAMETER Domain | |
| One or more email domains to analyze. Accepts pipeline input. | |
| Alias: EmailDomain | |
| .PARAMETER ThrottleLimit | |
| Maximum number of domains to process concurrently. Default is 10. | |
| Higher values improve speed but increase resource usage. | |
| .PARAMETER SkipBlocklistCheck | |
| Skip DNS blocklist (DNSBL) lookups. This speeds up execution but | |
| omits spam blocklist status from results. | |
| .INPUTS | |
| System.String | |
| You can pipe domain names to this function. | |
| .OUTPUTS | |
| PSCustomObject | |
| Returns objects with comprehensive email security configuration for each domain. | |
| .EXAMPLE | |
| Invoke-EmailRecon -Domain 'contoso.com' | |
| Analyzes email security configuration for contoso.com. | |
| .EXAMPLE | |
| Invoke-EmailRecon -Domain 'contoso.com', 'fabrikam.com' -ThrottleLimit 20 | |
| Analyzes multiple domains with increased parallelism. | |
| .EXAMPLE | |
| Get-Content -Path 'C:\Temp\domains.txt' | Invoke-EmailRecon | Export-Csv -Path 'results.csv' -NoTypeInformation | |
| Reads domains from a file, analyzes each, and exports results to CSV. | |
| .EXAMPLE | |
| Invoke-EmailRecon -Domain 'contoso.com' -SkipBlocklistCheck -Verbose | |
| Analyzes a domain without blocklist checks, with verbose output. | |
| .NOTES | |
| Name: Invoke-EmailRecon | |
| Author: BlackCat Security Tools | |
| Version: 2.1.0 | |
| Requires: PowerShell 7.0 or later | |
| Platform: Windows (uses Resolve-DnsName), macOS/Linux (uses dig command) | |
| .LINK | |
| https://github.com/azurekid/blackcat | |
| #> | |
| #Requires -Version 7.0 | |
| function Invoke-EmailRecon { | |
| [CmdletBinding(SupportsShouldProcess = $true)] | |
| [OutputType([PSCustomObject])] | |
| param ( | |
| [Parameter( | |
| Mandatory = $true, | |
| ValueFromPipeline = $true, | |
| ValueFromPipelineByPropertyName = $true, | |
| Position = 0, | |
| HelpMessage = 'One or more email domains to analyze.' | |
| )] | |
| [Alias('EmailDomain', 'Name')] | |
| [ValidateNotNullOrEmpty()] | |
| [string[]]$Domain, | |
| [Parameter( | |
| Mandatory = $false, | |
| HelpMessage = 'Maximum number of parallel operations (1-50).' | |
| )] | |
| [ValidateRange(1, 50)] | |
| [int]$ThrottleLimit = 10, | |
| [Parameter( | |
| Mandatory = $false, | |
| HelpMessage = 'Skip DNS blocklist lookups for faster execution.' | |
| )] | |
| [switch]$SkipBlocklistCheck | |
| ) | |
| begin { | |
| Write-Verbose -Message "Initializing Invoke-EmailRecon" | |
| # Detect operating system and available DNS tools | |
| $script:RunningOnWindows = $IsWindows -or ($PSVersionTable.Platform -eq 'Win32NT') -or (-not $PSVersionTable.Platform -and $env:OS -eq 'Windows_NT') | |
| $script:UseResolveDnsName = $script:RunningOnWindows -and (Get-Command -Name 'Resolve-DnsName' -ErrorAction SilentlyContinue) | |
| $script:UseDigCommand = -not $script:RunningOnWindows -and (Get-Command -Name 'dig' -ErrorAction SilentlyContinue) | |
| if (-not $script:UseResolveDnsName -and -not $script:UseDigCommand) { | |
| $errorMessage = if ($script:RunningOnWindows) { | |
| 'Resolve-DnsName cmdlet not found. This cmdlet is required for DNS lookups on Windows.' | |
| } else { | |
| 'dig command not found. Please install dnsutils/bind-utils package for DNS lookups on macOS/Linux.' | |
| } | |
| $PSCmdlet.ThrowTerminatingError( | |
| [System.Management.Automation.ErrorRecord]::new( | |
| [System.InvalidOperationException]::new($errorMessage), | |
| 'DnsToolNotFound', | |
| [System.Management.Automation.ErrorCategory]::ObjectNotFound, | |
| $null | |
| ) | |
| ) | |
| } | |
| Write-Verbose -Message "Using DNS resolver: $(if ($script:UseResolveDnsName) { 'Resolve-DnsName' } else { 'dig' })" | |
| #region Cross-Platform DNS Resolution Function | |
| $script:DnsResolverScriptBlock = @' | |
| function Resolve-DnsRecord { | |
| <# | |
| .SYNOPSIS | |
| Cross-platform DNS resolution wrapper. | |
| .DESCRIPTION | |
| Uses Resolve-DnsName on Windows and dig on macOS/Linux. | |
| #> | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Name, | |
| [Parameter(Mandatory = $false)] | |
| [ValidateSet('A', 'AAAA', 'MX', 'TXT', 'CNAME', 'NS', 'SOA', 'PTR', 'DNSKEY', 'CAA', 'TLSA', 'ANY')] | |
| [string]$Type = 'A', | |
| [Parameter(Mandatory = $false)] | |
| [switch]$DnsOnly | |
| ) | |
| $runningOnWindows = $IsWindows -or ($PSVersionTable.Platform -eq 'Win32NT') -or (-not $PSVersionTable.Platform -and $env:OS -eq 'Windows_NT') | |
| if ($runningOnWindows) { | |
| # Use native Resolve-DnsName on Windows | |
| $params = @{ | |
| Name = $Name | |
| Type = $Type | |
| ErrorAction = 'SilentlyContinue' | |
| } | |
| if ($DnsOnly) { $params['DnsOnly'] = $true } | |
| return Resolve-DnsName @params | |
| } | |
| else { | |
| # Use dig on macOS/Linux | |
| return Invoke-DigQuery -Name $Name -Type $Type | |
| } | |
| } | |
| function Invoke-DigQuery { | |
| <# | |
| .SYNOPSIS | |
| Executes dig command and parses output into PowerShell objects. | |
| #> | |
| [CmdletBinding()] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$Name, | |
| [Parameter(Mandatory = $false)] | |
| [string]$Type = 'A' | |
| ) | |
| try { | |
| $digOutput = & dig +short +noall +answer $Name $Type 2>$null | |
| if ([string]::IsNullOrWhiteSpace($digOutput)) { | |
| return $null | |
| } | |
| $results = @() | |
| $lines = $digOutput -split "`n" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | |
| foreach ($line in $lines) { | |
| $line = $line.Trim() | |
| switch ($Type.ToUpper()) { | |
| 'A' { | |
| $results += [PSCustomObject]@{ | |
| Type = 'A' | |
| Name = $Name | |
| IPAddress = $line | |
| } | |
| } | |
| 'AAAA' { | |
| $results += [PSCustomObject]@{ | |
| Type = 'AAAA' | |
| Name = $Name | |
| IPAddress = $line | |
| } | |
| } | |
| 'MX' { | |
| $parts = $line -split '\s+' | |
| if ($parts.Count -ge 2) { | |
| $results += [PSCustomObject]@{ | |
| Type = 'MX' | |
| Name = $Name | |
| Preference = [int]$parts[0] | |
| NameExchange = $parts[1].TrimEnd('.') | |
| } | |
| } | |
| } | |
| 'TXT' { | |
| # Remove surrounding quotes | |
| $txtValue = $line -replace '^"', '' -replace '"$', '' | |
| $results += [PSCustomObject]@{ | |
| Type = 'TXT' | |
| Name = $Name | |
| Strings = @($txtValue) | |
| } | |
| } | |
| 'CNAME' { | |
| $results += [PSCustomObject]@{ | |
| Type = 'CNAME' | |
| Name = $Name | |
| NameHost = $line.TrimEnd('.') | |
| } | |
| } | |
| 'NS' { | |
| $results += [PSCustomObject]@{ | |
| Type = 'NS' | |
| Name = $Name | |
| NameHost = $line.TrimEnd('.') | |
| } | |
| } | |
| 'SOA' { | |
| # SOA format: mname rname serial refresh retry expire minimum | |
| $soaOutput = & dig +short $Name SOA 2>$null | |
| if ($soaOutput) { | |
| $parts = ($soaOutput -split '\s+') | |
| if ($parts.Count -ge 2) { | |
| $results += [PSCustomObject]@{ | |
| Type = 'SOA' | |
| Name = $Name | |
| PrimaryServer = $parts[0].TrimEnd('.') | |
| NameAdministrator = $parts[1].TrimEnd('.') -replace '\.', '@' | |
| } | |
| } | |
| } | |
| } | |
| 'PTR' { | |
| $results += [PSCustomObject]@{ | |
| Type = 'PTR' | |
| Name = $Name | |
| NameHost = $line.TrimEnd('.') | |
| } | |
| } | |
| 'DNSKEY' { | |
| if (-not [string]::IsNullOrWhiteSpace($line)) { | |
| $results += [PSCustomObject]@{ | |
| Type = 'DNSKEY' | |
| Name = $Name | |
| } | |
| } | |
| } | |
| 'CAA' { | |
| # CAA format: flags tag value | |
| $parts = $line -split '\s+', 3 | |
| if ($parts.Count -ge 3) { | |
| $results += [PSCustomObject]@{ | |
| Type = 'CAA' | |
| Name = $Name | |
| Flags = [int]$parts[0] | |
| Tag = $parts[1] | |
| Value = $parts[2] -replace '^"', '' -replace '"$', '' | |
| } | |
| } | |
| } | |
| 'TLSA' { | |
| if (-not [string]::IsNullOrWhiteSpace($line)) { | |
| $results += [PSCustomObject]@{ | |
| Type = 'TLSA' | |
| Name = $Name | |
| } | |
| } | |
| } | |
| default { | |
| $results += [PSCustomObject]@{ | |
| Type = $Type | |
| Name = $Name | |
| Data = $line | |
| } | |
| } | |
| } | |
| } | |
| return $results | |
| } | |
| catch { | |
| Write-Verbose -Message "dig query failed for $Name ($Type): $_" | |
| return $null | |
| } | |
| } | |
| '@ | |
| #endregion | |
| #region Helper Functions Script Block | |
| # These functions run inside parallel runspaces | |
| $script:HelperFunctionsScriptBlock = @' | |
| function Get-DomainFederationData { | |
| <# | |
| .SYNOPSIS | |
| Retrieves Microsoft 365 federation data for a domain. | |
| #> | |
| [CmdletBinding()] | |
| [OutputType([PSCustomObject])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$DomainName | |
| ) | |
| $uri = "https://login.microsoftonline.com/common/userrealm/?user=testuser@$DomainName&api-version=2.1&checkForMicrosoftAccount=true" | |
| try { | |
| $response = Invoke-RestMethod -Uri $uri -TimeoutSec 10 -ErrorAction Stop | |
| return $response | |
| } | |
| catch { | |
| Write-Verbose -Message "Failed to retrieve federation data for $DomainName`: $_" | |
| return $null | |
| } | |
| } | |
| function Get-MtaStsPolicy { | |
| <# | |
| .SYNOPSIS | |
| Retrieves MTA-STS policy details for a domain. | |
| #> | |
| [CmdletBinding()] | |
| [OutputType([PSCustomObject])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$DomainName | |
| ) | |
| $mtaStsDns = Resolve-DnsRecord -Name "_mta-sts.$DomainName" -Type TXT | |
| if (-not $mtaStsDns) { | |
| return $null | |
| } | |
| try { | |
| $policyUri = "https://mta-sts.$DomainName/.well-known/mta-sts.txt" | |
| $policyContent = (Invoke-WebRequest -Uri $policyUri -TimeoutSec 10 -ErrorAction Stop).Content | |
| $modeMatch = $policyContent | Select-String -Pattern 'mode:.*(enforce|testing|none)' | |
| $mxMatches = $policyContent | Select-String -Pattern 'mx:(.*)' -AllMatches | |
| $maxAgeMatch = $policyContent | Select-String -Pattern 'max_age:(\d+)' | |
| $allowedMx = ($mxMatches.Matches.Groups | | |
| Where-Object { $_.Value -notlike 'mx:*' } | | |
| Select-Object -ExpandProperty Value) -replace '\s' -join ',' | |
| return [PSCustomObject]@{ | |
| DnsRecord = $mtaStsDns.Strings -join '' | |
| Mode = if ($modeMatch.Matches.Count -gt 0) { $modeMatch.Matches[0].Groups[1].Value.ToUpper() } else { $null } | |
| MaxAge = if ($maxAgeMatch.Matches.Count -gt 0) { [int]$maxAgeMatch.Matches[0].Groups[1].Value } else { $null } | |
| AllowedMx = $allowedMx | |
| } | |
| } | |
| catch { | |
| Write-Verbose -Message "Failed to retrieve MTA-STS policy for $DomainName`: $_" | |
| return $null | |
| } | |
| } | |
| function Get-Microsoft365Domains { | |
| <# | |
| .SYNOPSIS | |
| Retrieves all domains associated with a Microsoft 365 tenant. | |
| #> | |
| [CmdletBinding()] | |
| [OutputType([string[]])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$DomainName | |
| ) | |
| $uri = 'https://autodiscover-s.outlook.com/autodiscover/autodiscover.svc' | |
| $soapBody = @" | |
| <soap:Envelope xmlns:soap='http://schemas.xmlsoap.org/soap/envelope/' | |
| xmlns:a='http://www.w3.org/2005/08/addressing' | |
| xmlns:autodiscover='http://schemas.microsoft.com/exchange/2010/Autodiscover'> | |
| <soap:Header> | |
| <a:Action soap:mustUnderstand='1'>http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation</a:Action> | |
| <a:To soap:mustUnderstand='1'>$uri</a:To> | |
| <a:ReplyTo><a:Address>http://www.w3.org/2005/08/addressing/anonymous</a:Address></a:ReplyTo> | |
| </soap:Header> | |
| <soap:Body> | |
| <autodiscover:GetFederationInformationRequestMessage> | |
| <autodiscover:Request><autodiscover:Domain>$DomainName</autodiscover:Domain></autodiscover:Request> | |
| </autodiscover:GetFederationInformationRequestMessage> | |
| </soap:Body> | |
| </soap:Envelope> | |
| "@ | |
| $headers = @{ | |
| 'Content-Type' = 'text/xml; charset=utf-8' | |
| 'SOAPAction' = '"http://schemas.microsoft.com/exchange/2010/Autodiscover/Autodiscover/GetFederationInformation"' | |
| } | |
| try { | |
| $response = Invoke-RestMethod -Method Post -Uri $uri -Body $soapBody -Headers $headers -TimeoutSec 15 -ErrorAction Stop | |
| $domains = $response.Envelope.Body.GetFederationInformationResponseMessage.Response.Domains.Domain | |
| return $domains | Sort-Object | |
| } | |
| catch { | |
| Write-Verbose -Message "Failed to retrieve M365 domains for $DomainName`: $_" | |
| return @() | |
| } | |
| } | |
| function Get-EntraIdDirectoryId { | |
| <# | |
| .SYNOPSIS | |
| Retrieves the Entra ID (Azure AD) directory ID for a domain. | |
| #> | |
| [CmdletBinding()] | |
| [OutputType([string])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$DomainName | |
| ) | |
| $uri = "https://login.windows.net/$DomainName/.well-known/openid-configuration" | |
| try { | |
| $response = Invoke-RestMethod -Uri $uri -TimeoutSec 10 -ErrorAction Stop | |
| if ($response.token_endpoint) { | |
| return $response.token_endpoint.Split('/')[3] | |
| } | |
| return $null | |
| } | |
| catch { | |
| Write-Verbose -Message "Failed to retrieve directory ID for $DomainName`: $_" | |
| return $null | |
| } | |
| } | |
| function Get-AdfsFederationEndpoint { | |
| <# | |
| .SYNOPSIS | |
| Discovers ADFS federation endpoints for a domain. | |
| #> | |
| [CmdletBinding()] | |
| [OutputType([hashtable])] | |
| param ( | |
| [Parameter(Mandatory = $true)] | |
| [string]$DomainName | |
| ) | |
| $prefixes = @('adfs', 'sso', 'sts', 'fs', 'auth', 'idf', 'fed', 'login', 'idp') | |
| foreach ($prefix in $prefixes) { | |
| $federationHost = "$prefix.$DomainName" | |
| $resolved = Resolve-DnsRecord -Name $federationHost -Type A | |
| if (-not $resolved) { | |
| continue | |
| } | |
| $metadataUrl = "https://$federationHost/federationmetadata/2007-06/federationmetadata.xml" | |
| try { | |
| $xmlData = Invoke-RestMethod -Method Get -Uri $metadataUrl -TimeoutSec 5 -ErrorAction Stop | |
| if ($xmlData.EntityDescriptor.entityID) { | |
| return @{ | |
| Hostname = $federationHost | |
| MetadataUrl = $metadataUrl | |
| EntityId = $xmlData.EntityDescriptor.entityID | |
| } | |
| } | |
| } | |
| catch { | |
| # Continue to next prefix | |
| } | |
| } | |
| return $null | |
| } | |
| '@ | |
| #endregion | |
| # Collect domains from pipeline | |
| $script:DomainCollection = [System.Collections.Generic.List[string]]::new() | |
| } | |
| process { | |
| foreach ($domainItem in $Domain) { | |
| if (-not [string]::IsNullOrWhiteSpace($domainItem)) { | |
| $script:DomainCollection.Add($domainItem.Trim().ToLower()) | |
| } | |
| } | |
| } | |
| end { | |
| if ($script:DomainCollection.Count -eq 0) { | |
| Write-Warning -Message 'No valid domains provided.' | |
| return | |
| } | |
| # Remove duplicates | |
| $uniqueDomains = $script:DomainCollection | Select-Object -Unique | |
| Write-Verbose -Message "Processing $($uniqueDomains.Count) domain(s) with ThrottleLimit: $ThrottleLimit" | |
| $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() | |
| # Process domains in parallel | |
| $results = $uniqueDomains | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { | |
| $currentDomain = $_ | |
| $helperScriptBlock = $using:HelperFunctionsScriptBlock | |
| $skipBlocklist = $using:SkipBlocklistCheck | |
| # Import helper functions | |
| . ([scriptblock]::Create($helperScriptBlock)) | |
| # Import DNS resolver function | |
| $dnsResolverBlock = $using:DnsResolverScriptBlock | |
| . ([scriptblock]::Create($dnsResolverBlock)) | |
| # Validate domain exists | |
| $soaCheck = Resolve-DnsRecord -Name $currentDomain -Type SOA | |
| if (-not $soaCheck) { | |
| Write-Warning -Message "Failed to resolve SOA record for $currentDomain. Skipping." | |
| return | |
| } | |
| #region DNS Queries Definition | |
| $dnsQueries = @{ | |
| DMARC = { param($d) Resolve-DnsRecord -Name "_dmarc.$d" -Type TXT } | |
| MX = { param($d) Resolve-DnsRecord -Name $d -Type MX } | |
| TXT = { param($d) Resolve-DnsRecord -Name $d -Type TXT } | |
| SOA = { param($d) Resolve-DnsRecord -Name $d -Type SOA } | |
| NS = { param($d) Resolve-DnsRecord -Name $d -Type NS } | |
| A = { param($d) Resolve-DnsRecord -Name $d -Type A } | |
| AAAA = { param($d) Resolve-DnsRecord -Name $d -Type AAAA } | |
| DNSKEY = { param($d) Resolve-DnsRecord -Name $d -Type DNSKEY } | |
| CAA = { param($d) Resolve-DnsRecord -Name $d -Type CAA } | |
| MSOID = { param($d) Resolve-DnsRecord -Name "msoid.$d" -Type A } | |
| ENTERPRISEREGISTRATION = { param($d) Resolve-DnsRecord -Name "enterpriseregistration.$d" -Type CNAME } | |
| AUTODISCOVER = { param($d) Resolve-DnsRecord -Name "autodiscover.$d" -Type CNAME } | |
| WILDCARDTXT = { param($d) Resolve-DnsRecord -Name "$([guid]::NewGuid().ToString('N')).$d" -Type TXT } | |
| BIMI = { param($d) Resolve-DnsRecord -Name "default._bimi.$d" -Type TXT } | |
| TLSRPT = { param($d) Resolve-DnsRecord -Name "_smtp._tls.$d" -Type TXT } | |
| MTASTS_DNS = { param($d) Resolve-DnsRecord -Name "_mta-sts.$d" -Type TXT } | |
| DKIM_SELECTOR1 = { param($d) Resolve-DnsRecord -Name "selector1._domainkey.$d" -Type CNAME } | |
| DKIM_SELECTOR2 = { param($d) Resolve-DnsRecord -Name "selector2._domainkey.$d" -Type CNAME } | |
| DKIM_GOOGLE = { param($d) Resolve-DnsRecord -Name "google._domainkey.$d" -Type TXT } | |
| DKIM_DEFAULT = { param($d) Resolve-DnsRecord -Name "default._domainkey.$d" -Type TXT } | |
| DKIM_K1 = { param($d) Resolve-DnsRecord -Name "k1._domainkey.$d" -Type TXT } | |
| DKIM_S1 = { param($d) Resolve-DnsRecord -Name "s1._domainkey.$d" -Type TXT } | |
| DKIM_S2 = { param($d) Resolve-DnsRecord -Name "s2._domainkey.$d" -Type TXT } | |
| } | |
| $httpQueries = @{ | |
| FEDERATION = { param($d) Get-DomainFederationData -DomainName $d } | |
| MTASTS = { param($d) Get-MtaStsPolicy -DomainName $d } | |
| M365DOMAINS = { param($d) Get-Microsoft365Domains -DomainName $d } | |
| DIRECTORYID = { param($d) Get-EntraIdDirectoryId -DomainName $d } | |
| ADFS = { param($d) Get-AdfsFederationEndpoint -DomainName $d } | |
| } | |
| #endregion | |
| #region Execute Queries in Parallel | |
| $runspacePool = [runspacefactory]::CreateRunspacePool(1, 20) | |
| $runspacePool.Open() | |
| $jobs = @{} | |
| $allQueries = $dnsQueries + $httpQueries | |
| foreach ($queryName in $allQueries.Keys) { | |
| $powerShell = [powershell]::Create() | |
| $powerShell.RunspacePool = $runspacePool | |
| # Add DNS resolver functions for all queries | |
| $null = $powerShell.AddScript($dnsResolverBlock) | |
| # Add helper functions for HTTP queries | |
| if ($httpQueries.ContainsKey($queryName)) { | |
| $null = $powerShell.AddScript($helperScriptBlock) | |
| } | |
| $null = $powerShell.AddScript($allQueries[$queryName]).AddArgument($currentDomain) | |
| $jobs[$queryName] = @{ | |
| PowerShell = $powerShell | |
| Handle = $powerShell.BeginInvoke() | |
| } | |
| } | |
| # Collect results | |
| $queryResults = @{} | |
| foreach ($queryName in $jobs.Keys) { | |
| try { | |
| $queryResults[$queryName] = $jobs[$queryName].PowerShell.EndInvoke($jobs[$queryName].Handle) | |
| } | |
| catch { | |
| $queryResults[$queryName] = $null | |
| } | |
| finally { | |
| $jobs[$queryName].PowerShell.Dispose() | |
| } | |
| } | |
| $runspacePool.Close() | |
| $runspacePool.Dispose() | |
| #endregion | |
| #region Process MX Server Information | |
| $mxServerInfo = @{ IPv4 = $null; IPv6 = $null; PtrRecord = $null } | |
| if ($queryResults.MX) { | |
| $lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange | |
| if ($lowestMx) { | |
| $mxA = Resolve-DnsRecord -Name $lowestMx -Type A | | |
| Select-Object -First 1 -ExpandProperty IPAddress | |
| $mxAAAA = Resolve-DnsRecord -Name $lowestMx -Type AAAA | | |
| Select-Object -First 1 -ExpandProperty IPAddress | |
| $mxServerInfo.IPv4 = $mxA | |
| $mxServerInfo.IPv6 = $mxAAAA | |
| if ($mxA) { | |
| $ptrResult = Resolve-DnsRecord -Name $mxA -Type PTR | |
| $mxServerInfo.PtrRecord = $ptrResult | Select-Object -First 1 -ExpandProperty NameHost | |
| } | |
| } | |
| } | |
| #endregion | |
| #region DANE/TLSA Check | |
| $daneInfo = @{ Exists = $false } | |
| if ($queryResults.MX) { | |
| $lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange | |
| if ($lowestMx) { | |
| $tlsaRecord = Resolve-DnsRecord -Name "_25._tcp.$lowestMx" -Type TLSA | |
| $daneInfo.Exists = $null -ne $tlsaRecord | |
| } | |
| } | |
| #endregion | |
| #region Blocklist Check | |
| $blocklistInfo = @{ Listed = 'Skipped'; Blocklists = @() } | |
| if (-not $skipBlocklist -and $mxServerInfo.IPv4) { | |
| $dnsBls = @('zen.spamhaus.org', 'bl.spamcop.net', 'b.barracudacentral.org', 'dnsbl.sorbs.net') | |
| $reversedIp = ($mxServerInfo.IPv4.Split('.')[3..0]) -join '.' | |
| $listedOn = @() | |
| foreach ($dnsbl in $dnsBls) { | |
| $blResult = Resolve-DnsRecord -Name "$reversedIp.$dnsbl" -Type A -DnsOnly | |
| if ($blResult) { | |
| $listedOn += $dnsbl | |
| } | |
| } | |
| $blocklistInfo.Listed = $listedOn.Count -gt 0 | |
| $blocklistInfo.Blocklists = $listedOn | |
| } | |
| #endregion | |
| #region MOERA DMARC Check | |
| $moeraDmarc = $null | |
| $moeraDomain = $queryResults.M365DOMAINS | | |
| Where-Object { $_ -like '*.onmicrosoft.com' -and $_ -notlike '*.mail.onmicrosoft.com' } | | |
| Select-Object -First 1 | |
| if ($moeraDomain) { | |
| $moeraDmarcRecord = Resolve-DnsRecord -Name "_dmarc.$moeraDomain" -Type TXT | |
| $moeraDmarc = $moeraDmarcRecord | | |
| Where-Object { $_.Strings -like '*v=DMARC1*' } | | |
| Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue | |
| } | |
| #endregion | |
| #region Parse SPF Record | |
| $spfInfo = @{ Exists = $false; Record = $null; Mode = $null } | |
| $spfRecord = $queryResults.TXT | Where-Object { $_.Strings -like '*v=spf1*' } | |
| if ($spfRecord) { | |
| if (@($spfRecord).Count -gt 1) { | |
| $spfInfo.Exists = 'ERROR: Multiple SPF records' | |
| } | |
| else { | |
| $spfText = if ($spfRecord.Strings.Count -gt 1) { $spfRecord.Strings -join '' } else { $spfRecord.Strings[0] } | |
| $spfInfo.Exists = $true | |
| $spfInfo.Record = $spfText | |
| $spfInfo.Mode = switch -Wildcard ($spfText) { | |
| '*-all' { 'Fail' } | |
| '*+all' { 'Pass' } | |
| '*~all' { 'SoftFail' } | |
| '*?all' { 'Neutral' } | |
| default { 'Unknown' } | |
| } | |
| } | |
| } | |
| #endregion | |
| #region Parse Wildcard SPF | |
| $wildcardSpfInfo = @{ Exists = $false; Record = $null } | |
| $wildcardSpf = $queryResults.WILDCARDTXT | Where-Object { $_.Strings -like '*v=spf1*' } | |
| if ($wildcardSpf) { | |
| $wildcardSpfInfo.Exists = $true | |
| $wildcardSpfInfo.Record = if ($wildcardSpf.Strings.Count -gt 1) { $wildcardSpf.Strings -join '' } else { $wildcardSpf.Strings[0] } | |
| } | |
| #endregion | |
| #region Parse DMARC Record | |
| $dmarcInfo = @{ Exists = $false; Record = $null; Policy = $null; SubdomainPolicy = $null } | |
| $dmarcRecord = $queryResults.DMARC | Where-Object { $_.Strings -like '*v=DMARC1*' } | | |
| Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue | |
| if ($dmarcRecord) { | |
| $dmarcText = if ($dmarcRecord -is [array]) { $dmarcRecord -join '' } else { $dmarcRecord } | |
| $dmarcInfo.Exists = $true | |
| $dmarcInfo.Record = $dmarcText | |
| $policyMatch = $dmarcText.Split(';') | Where-Object { $_ -match '^\s*p=' } | |
| $subPolicyMatch = $dmarcText.Split(';') | Where-Object { $_ -match 'sp=' } | |
| $dmarcInfo.Policy = if ($policyMatch) { ($policyMatch -replace '\s' -replace 'p=').ToUpper() } else { $null } | |
| $dmarcInfo.SubdomainPolicy = if ($subPolicyMatch) { ($subPolicyMatch -replace '\s' -replace 'sp=').ToUpper() } else { $null } | |
| } | |
| #endregion | |
| #region Parse MX Records | |
| $mxInfo = @{ Exists = $false; LowestPreference = $null; Provider = 'No MX record' } | |
| if ($queryResults.MX) { | |
| $lowestMx = $queryResults.MX | Sort-Object -Property Preference | Select-Object -First 1 -ExpandProperty NameExchange | |
| $mxInfo.Exists = $true | |
| $mxInfo.LowestPreference = $lowestMx | |
| $mxInfo.Provider = switch -Wildcard ($lowestMx) { | |
| 'inbound-smtp.*.amazonaws.com' { 'Amazon SES' } | |
| 'aspmx*google.com' { 'Google Workspace' } | |
| '*mimecast*' { 'Mimecast' } | |
| '*barracudanetworks.com' { 'Barracuda ESS' } | |
| '*fireeyecloud.com' { 'FireEye Email Security' } | |
| '*.eo.outlook.com' { 'Microsoft Exchange Online' } | |
| '*sophos.com' { 'Sophos' } | |
| '*pphosted*' { 'Proofpoint' } | |
| '*ppe-hosted*' { 'Proofpoint' } | |
| '*mail.protection.outlook.com*' { 'Microsoft Exchange Online' } | |
| '*.mx.microsoft' { 'Microsoft Exchange Online' } | |
| '*messagelabs*' { 'Symantec.cloud' } | |
| '*trendmicro*' { 'Trend Micro' } | |
| '*.sendgrid.net' { 'SendGrid' } | |
| '*.mailgun.org' { 'Mailgun' } | |
| '*.emailsrvr.com' { 'Rackspace' } | |
| default { 'Other' } | |
| } | |
| } | |
| #endregion | |
| #region Determine Exchange Online Status | |
| $exchangeOnlineStatus = 'No' | |
| if ($queryResults.MSOID | Where-Object { $_.NameHost -like '*clientconfig.microsoftonline*' }) { $exchangeOnlineStatus = 'Possibly' } | |
| if ($queryResults.TXT | Where-Object { $_.Strings -like 'MS=ms*' }) { $exchangeOnlineStatus = 'Possibly' } | |
| if ($queryResults.ENTERPRISEREGISTRATION | Where-Object { $_.NameHost -eq 'enterpriseregistration.windows.net' }) { $exchangeOnlineStatus = 'Likely' } | |
| if ($queryResults.AUTODISCOVER | Where-Object { $_.NameHost -eq 'autodiscover.outlook.com' }) { $exchangeOnlineStatus = 'Yes' } | |
| if ($queryResults.TXT | Where-Object { $_.Strings -like '*spf.protection.outlook.com*' }) { $exchangeOnlineStatus = 'Yes' } | |
| if ($queryResults.MX | Where-Object { $_.NameExchange -like '*mail.protection.outlook.com*' -or $_.NameExchange -like '*eo.outlook.com' }) { $exchangeOnlineStatus = 'Yes' } | |
| #endregion | |
| #region Parse M365 Tenant Name | |
| $m365TenantName = $null | |
| $mxForTenant = $queryResults.MX | | |
| Where-Object { $_.NameExchange -like '*.mail.protection.outlook.com' } | | |
| Sort-Object -Property Preference | | |
| Select-Object -First 1 -ExpandProperty NameExchange | |
| if ($mxForTenant) { | |
| $m365TenantName = $mxForTenant -replace '\.mail\.protection\.outlook\.com$' | |
| } | |
| #endregion | |
| #region Parse DKIM Selectors | |
| $dkimSelectors = @() | |
| if ($queryResults.DKIM_SELECTOR1) { $dkimSelectors += 'selector1' } | |
| if ($queryResults.DKIM_SELECTOR2) { $dkimSelectors += 'selector2' } | |
| if ($queryResults.DKIM_GOOGLE) { $dkimSelectors += 'google' } | |
| if ($queryResults.DKIM_DEFAULT) { $dkimSelectors += 'default' } | |
| if ($queryResults.DKIM_K1) { $dkimSelectors += 'k1' } | |
| if ($queryResults.DKIM_S1) { $dkimSelectors += 's1' } | |
| if ($queryResults.DKIM_S2) { $dkimSelectors += 's2' } | |
| $m365DkimEnabled = ($null -ne $queryResults.DKIM_SELECTOR1) -and ($null -ne $queryResults.DKIM_SELECTOR2) | |
| #endregion | |
| #region Parse BIMI Record | |
| $bimiInfo = @{ Exists = $false; LogoUrl = $null; VmcUrl = $null } | |
| $bimiRecord = $queryResults.BIMI | Where-Object { $_.Strings -like '*v=BIMI1*' } | | |
| Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue | |
| if ($bimiRecord) { | |
| $bimiText = if ($bimiRecord -is [array]) { $bimiRecord -join '' } else { $bimiRecord } | |
| $bimiInfo.Exists = $true | |
| $logoMatch = [regex]::Match($bimiText, 'l=([^;]+)') | |
| $vmcMatch = [regex]::Match($bimiText, 'a=([^;]+)') | |
| $bimiInfo.LogoUrl = if ($logoMatch.Success) { $logoMatch.Groups[1].Value.Trim() } else { $null } | |
| $bimiInfo.VmcUrl = if ($vmcMatch.Success) { $vmcMatch.Groups[1].Value.Trim() } else { $null } | |
| } | |
| #endregion | |
| #region Parse TLS-RPT Record | |
| $tlsRptInfo = @{ Exists = $false; ReportUri = $null } | |
| $tlsRptRecord = $queryResults.TLSRPT | Where-Object { $_.Strings -like '*v=TLSRPTv1*' } | | |
| Select-Object -ExpandProperty Strings -ErrorAction SilentlyContinue | |
| if ($tlsRptRecord) { | |
| $tlsRptText = if ($tlsRptRecord -is [array]) { $tlsRptRecord -join '' } else { $tlsRptRecord } | |
| $tlsRptInfo.Exists = $true | |
| $ruaMatch = [regex]::Match($tlsRptText, 'rua=([^;]+)') | |
| $tlsRptInfo.ReportUri = if ($ruaMatch.Success) { $ruaMatch.Groups[1].Value.Trim() } else { $null } | |
| } | |
| #endregion | |
| #region Parse CAA Records | |
| $caaInfo = @{ Exists = $false; Issuers = $null; WildcardIssuers = $null; IncidentReport = $null } | |
| if ($queryResults.CAA) { | |
| $caaInfo.Exists = $true | |
| $caaInfo.Issuers = ($queryResults.CAA | Where-Object { $_.Tag -eq 'issue' } | Select-Object -ExpandProperty Value) -join ', ' | |
| $caaInfo.WildcardIssuers = ($queryResults.CAA | Where-Object { $_.Tag -eq 'issuewild' } | Select-Object -ExpandProperty Value) -join ', ' | |
| $caaInfo.IncidentReport = ($queryResults.CAA | Where-Object { $_.Tag -eq 'iodef' } | Select-Object -ExpandProperty Value) -join ', ' | |
| } | |
| #endregion | |
| #region Parse Federation Information | |
| $federationInfo = @{ IsFederated = $false; Provider = $null; Hostname = $null; BrandName = $null } | |
| if ($queryResults.FEDERATION) { | |
| $federationInfo.IsFederated = $queryResults.FEDERATION.NameSpaceType -eq 'Federated' | |
| $federationInfo.BrandName = $queryResults.FEDERATION.FederationBrandName | |
| if ($queryResults.FEDERATION.AuthURL) { | |
| $federationInfo.Hostname = ($queryResults.FEDERATION.AuthURL -replace 'https?://').Split('/')[0] | |
| $federationInfo.Provider = switch -Wildcard ($federationInfo.Hostname) { | |
| '*.okta.com' { 'Okta' } | |
| '*.onelogin.com' { 'OneLogin' } | |
| '*.pingidentity.com' { 'Ping Identity' } | |
| '*.duosecurity.com' { 'Duo Security' } | |
| default { 'Other' } | |
| } | |
| } | |
| } | |
| #endregion | |
| #region Build Output Object | |
| [PSCustomObject][ordered]@{ | |
| # Domain | |
| Domain = $currentDomain | |
| # MX Records | |
| MxRecordsExist = $mxInfo.Exists | |
| MxProvider = $mxInfo.Provider | |
| MxLowestPreference = $mxInfo.LowestPreference | |
| MxPrimaryIPv4 = $mxServerInfo.IPv4 | |
| MxPrimaryIPv6 = $mxServerInfo.IPv6 | |
| MxPtrRecord = $mxServerInfo.PtrRecord | |
| # SPF | |
| SpfRecordExists = $spfInfo.Exists | |
| SpfRecord = $spfInfo.Record | |
| SpfMode = $spfInfo.Mode | |
| WildcardSpfExists = $wildcardSpfInfo.Exists | |
| WildcardSpfRecord = $wildcardSpfInfo.Record | |
| # DKIM | |
| DkimSelectorsFound = $dkimSelectors -join ', ' | |
| M365DkimEnabled = $m365DkimEnabled | |
| # DMARC | |
| DmarcRecordExists = $dmarcInfo.Exists | |
| DmarcRecord = $dmarcInfo.Record | |
| DmarcPolicy = $dmarcInfo.Policy | |
| DmarcSubdomainPolicy = $dmarcInfo.SubdomainPolicy | |
| # BIMI | |
| BimiRecordExists = $bimiInfo.Exists | |
| BimiLogoUrl = $bimiInfo.LogoUrl | |
| BimiVmcUrl = $bimiInfo.VmcUrl | |
| # MTA-STS | |
| MtaStsRecordExists = $null -ne $queryResults.MTASTS | |
| MtaStsPolicyMode = if ($queryResults.MTASTS) { $queryResults.MTASTS.Mode } else { $null } | |
| MtaStsMaxAge = if ($queryResults.MTASTS) { $queryResults.MTASTS.MaxAge } else { $null } | |
| MtaStsAllowedMxHosts = if ($queryResults.MTASTS) { $queryResults.MTASTS.AllowedMx } else { $null } | |
| # TLS-RPT | |
| TlsRptRecordExists = $tlsRptInfo.Exists | |
| TlsRptReportUri = $tlsRptInfo.ReportUri | |
| # DANE/TLSA | |
| DaneTlsaExists = $daneInfo.Exists | |
| # DNSSEC | |
| DnssecEnabled = ($null -ne $queryResults.DNSKEY -and @($queryResults.DNSKEY).Count -gt 0) | |
| # CAA | |
| CaaRecordExists = $caaInfo.Exists | |
| CaaAllowedIssuers = $caaInfo.Issuers | |
| CaaWildcardIssuers = $caaInfo.WildcardIssuers | |
| CaaIncidentReport = $caaInfo.IncidentReport | |
| # Microsoft 365 / Entra ID | |
| M365ExchangeOnline = $exchangeOnlineStatus | |
| M365TenantName = $m365TenantName | |
| M365MoeraDomain = $moeraDomain | |
| M365MoeraDmarcRecord = $moeraDmarc | |
| M365Domains = ($queryResults.M365DOMAINS -join ',') | |
| M365IsFederated = $federationInfo.IsFederated | |
| M365FederationProvider = $federationInfo.Provider | |
| M365FederationHostname = $federationInfo.Hostname | |
| M365FederationBrandName = $federationInfo.BrandName | |
| EntraIdDirectoryId = $queryResults.DIRECTORYID | |
| EntraIdIsUnmanaged = if ($queryResults.FEDERATION) { $queryResults.FEDERATION.IsViral } else { $null } | |
| # ADFS | |
| AdfsServerDetected = $null -ne $queryResults.ADFS | |
| AdfsHostname = if ($queryResults.ADFS) { $queryResults.ADFS.Hostname } else { $null } | |
| AdfsMetadataUrl = if ($queryResults.ADFS) { $queryResults.ADFS.MetadataUrl } else { $null } | |
| AdfsEntityId = if ($queryResults.ADFS) { $queryResults.ADFS.EntityId } else { $null } | |
| # Blocklist | |
| OnBlocklist = $blocklistInfo.Listed | |
| Blocklists = $blocklistInfo.Blocklists -join ', ' | |
| # DNS Infrastructure | |
| DomainIPv4 = ($queryResults.A | Select-Object -ExpandProperty IPAddress -ErrorAction SilentlyContinue) -join ', ' | |
| DomainIPv6 = ($queryResults.AAAA | Select-Object -ExpandProperty IPAddress -ErrorAction SilentlyContinue) -join ', ' | |
| DnsRegistrar = $queryResults.SOA | Select-Object -First 1 -ExpandProperty NameAdministrator -ErrorAction SilentlyContinue | |
| DnsNameservers = ($queryResults.NS | Where-Object { $_.NameHost } | Select-Object -ExpandProperty NameHost) -join ', ' | |
| } | |
| #endregion | |
| } | |
| $stopwatch.Stop() | |
| Write-Verbose -Message "Completed processing in $($stopwatch.Elapsed.TotalSeconds.ToString('F2')) seconds" | |
| # Return results | |
| return $results | |
| } | |
| } | |
| # Export function if running as module | |
| if ($MyInvocation.MyCommand.ScriptBlock.Module) { | |
| Export-ModuleMember -Function Invoke-EmailRecon | |
| } | |
| # If running as script, execute with provided parameters | |
| if ($MyInvocation.InvocationName -ne '.') { | |
| # Script was executed directly | |
| $scriptParams = @{} | |
| if ($args.Count -gt 0) { | |
| # Parse command line arguments | |
| for ($i = 0; $i -lt $args.Count; $i++) { | |
| switch -Regex ($args[$i]) { | |
| '^-Domain$|^-EmailDomain$' { | |
| $i++ | |
| $scriptParams['Domain'] = $args[$i] -split ',' | |
| } | |
| '^-ThrottleLimit$' { | |
| $i++ | |
| $scriptParams['ThrottleLimit'] = [int]$args[$i] | |
| } | |
| '^-SkipBlocklistCheck$' { | |
| $scriptParams['SkipBlocklistCheck'] = $true | |
| } | |
| '^-Verbose$' { | |
| $VerbosePreference = 'Continue' | |
| } | |
| } | |
| } | |
| if ($scriptParams.ContainsKey('Domain')) { | |
| Invoke-EmailRecon @scriptParams | |
| } | |
| else { | |
| Get-Help -Name $MyInvocation.MyCommand.Path -Detailed | |
| } | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Invoke-EmailRecon
Synopsis
Performs comprehensive email security reconnaissance on one or more domains.
Description
Invoke-EmailReconperforms parallel DNS lookups and HTTP requests to gather email security configuration data for specified domains. It provides a complete view of email security posture including authentication mechanisms, encryption policies, and infrastructure details.The script is designed for:
Features
Requirements
ForEach-Object -Parallel)Resolve-DnsNamecmdletdigcommand (install viadnsutilsorbind-utils)Installation
The script can be used in multiple ways:
Dot-source the script
Run directly
Import as part of BlackCat module
Syntax
Parameters
-Domain
One or more email domains to analyze.
-ThrottleLimit
Maximum number of domains to process concurrently.
-SkipBlocklistCheck
Skip DNS blocklist (DNSBL) lookups. This speeds up execution but omits spam blocklist status from results.
Output Properties
The function returns a
PSCustomObjectwith the following properties:Domain Information
DomainMX Records
MxRecordsExistMxProviderMxLowestPreferenceMxPrimaryIPv4MxPrimaryIPv6MxPtrRecordSPF (Sender Policy Framework)
SpfRecordExistsSpfRecordSpfModeWildcardSpfExistsWildcardSpfRecordDKIM (DomainKeys Identified Mail)
DkimSelectorsFoundM365DkimEnabledChecked Selectors:
selector1,selector2(M365),google,default,k1,s1,s2DMARC (Domain-based Message Authentication)
DmarcRecordExistsDmarcRecordDmarcPolicyDmarcSubdomainPolicyBIMI (Brand Indicators for Message Identification)
BimiRecordExistsBimiLogoUrlBimiVmcUrlMTA-STS (Mail Transfer Agent Strict Transport Security)
MtaStsRecordExistsMtaStsPolicyModeMtaStsMaxAgeMtaStsAllowedMxHostsTLS-RPT (TLS Reporting)
TlsRptRecordExistsTlsRptReportUriDANE/TLSA
DaneTlsaExistsDNSSEC
DnssecEnabledCAA (Certificate Authority Authorization)
CaaRecordExistsCaaAllowedIssuersCaaWildcardIssuersCaaIncidentReportMicrosoft 365 / Entra ID
M365ExchangeOnlineM365TenantNameM365MoeraDomainM365MoeraDmarcRecordM365DomainsM365IsFederatedM365FederationProviderM365FederationHostnameM365FederationBrandNameEntraIdDirectoryIdEntraIdIsUnmanagedADFS Discovery
AdfsServerDetectedAdfsHostnameAdfsMetadataUrlAdfsEntityIdChecked Prefixes:
adfs,sso,sts,fs,auth,idf,fed,login,idpBlocklist Status
OnBlocklistBlocklistsChecked DNSBLs: Spamhaus ZEN, SpamCop, Barracuda, SORBS
DNS Infrastructure
DomainIPv4DomainIPv6DnsRegistrarDnsNameserversExamples
Example 1: Single Domain Analysis
Example 2: Multiple Domains with Verbose Output
Example 3: High-Speed Parallel Processing
Example 4: Pipeline Input from File
Example 5: Filter for Security Issues
Example 6: M365 Tenant Enumeration
Sample Output:
Cross-Platform Support
The script automatically detects the operating system and uses the appropriate DNS resolver:
Resolve-DnsNamedigdig +shortoutputdigdig +shortoutputInstalling dig on Linux
Debian/Ubuntu:
RHEL/CentOS/Fedora:
Alpine:
Installing dig on macOS
digis included with macOS by default. If missing:brew install bindPerformance
The script uses multiple parallelization strategies:
ForEach-Object -Parallelprocesses multiple domains concurrentlyBenchmark Results:
Security Considerations
Related Links
Version History
Author
BlackCat Security Tools